Compare commits

...
Author SHA1 Message Date
EthanHealy01 3869072cf7 Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into feature/documentClassifierAgent 2026-07-02 15:33:53 +01:00
EthanHealy01 3817779b9e translation 2026-07-02 14:42:07 +01:00
EthanHealy01 c4e66f2c2d Merge branch 'feature/documentClassifierAgent' of https://github.com/Stirling-Tools/Stirling-PDF into feature/documentClassifierAgent 2026-07-02 14:28:07 +01:00
EthanHealy01 c27fd4db69 added the classification taxonomy table in the db, team scoped. Allow admins to edit the taxonomy and set up the backend to query it and send it to python each time it's requested. No longer requires a backend restart to make it get the latest taxonomy. Also fixed up how the ordeering of policies is selected and how they chain together in that order. Also made a tiny fix to the my files section to keep it up to date with the indexdb 2026-07-02 14:28:02 +01:00
EthanHealy01 0ae7052dcb Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into feature/documentClassifierAgent 2026-07-01 10:55:36 +01:00
EthanHealy01 69fc4d5bc1 Merge branch 'main' into feature/documentClassifierAgent 2026-06-30 19:42:24 +01:00
EthanHealy01 2e023a6e78 Merge branch 'main' into feature/documentClassifierAgent 2026-06-30 10:45:27 +01:00
EthanHealy01 7e523d48d7 rename script and remove retry test 2026-06-29 22:27:26 +01:00
EthanHealy01 b8cb020e59 Merge branch 'main' into feature/documentClassifierAgent 2026-06-29 22:10:52 +01:00
EthanHealy01 a92722ff13 remove classify as a user facing tool 2026-06-29 22:10:09 +01:00
EthanHealy01 fed7ad300f document classifier agent that runs as a policy and assigns a category and sub-category docType 2026-06-29 21:58:03 +01:00
72 changed files with 5243 additions and 253 deletions
+21
View File
@@ -55,6 +55,15 @@ tasks:
- editor/src/core/data/ogImageMap.json
- editor/public/og-metadata.json
prepare:classifier-categories:
internal: true
run: when_changed
desc: "Regenerate the engine classifier categories JSON from the TS source of truth"
cmds:
- npx tsx editor/scripts/generate-classification-taxonomy.mts
sources:
- editor/src/proprietary/data/classificationTaxonomy.ts
prepare:
desc: "Set up dev environment"
run: when_changed
@@ -65,6 +74,7 @@ tasks:
vars: { MODE: '{{.MODE}}' }
- prepare:icons
- prepare:og
- prepare:classifier-categories
# ============================================================
# Development
@@ -401,12 +411,23 @@ tasks:
cmds:
- node editor/scripts/generate-og-metadata.mjs --check
classifier-categories:
desc: "Regenerate the engine classifier categories JSON from the TS source"
cmds:
- npx tsx editor/scripts/generate-classification-taxonomy.mts
classifier-categories:check:
desc: "Fail if the committed classifier categories JSON is out of date"
cmds:
- npx tsx editor/scripts/generate-classification-taxonomy.mts --check
check:all:
desc: "Full CI quality gate"
cmds:
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
- task: og:check
- task: classifier-categories:check
- task: typecheck:all
- task: lint
- task: format:check
@@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter;
import java.util.Calendar;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@@ -17,6 +18,11 @@ import stirling.software.common.model.PdfMetadata;
@Service
public class PdfMetadataService {
/**
* ({@code {category, docType, typeConfidence, tags}}). Written by the classify-and-tag tool.
*/
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
private final ApplicationProperties applicationProperties;
private final String stirlingPDFLabel;
private final UserServiceInterface userService;
@@ -177,4 +183,14 @@ public class PdfMetadataService {
}
pdf.getDocumentInformation().setAuthor(author);
}
/**
* Write the document classifier's JSON result into the custom Info-dictionary field {@link
* #CLASSIFICATION_KEY}, leaving all other metadata untouched.
*/
public void setClassificationMetadata(PDDocument pdf, String classificationJson) {
PDDocumentInformation info = pdf.getDocumentInformation();
info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson);
pdf.setDocumentInformation(info);
}
}
@@ -305,6 +305,23 @@ public class GetInfoOnPDF {
}
}
/**
* Info-dictionary keys exposed above via typed getters; any other key in the dictionary is
* surfaced as custom metadata (e.g. the classification policy's StirlingPDFClassification
* entry).
*/
private static final java.util.Set<String> STANDARD_INFO_KEYS =
java.util.Set.of(
"Title",
"Author",
"Subject",
"Keywords",
"Producer",
"Creator",
"CreationDate",
"ModDate",
"Trapped");
private static ObjectNode extractMetadata(PDDocument document) {
ObjectNode metadata = objectMapper.createObjectNode();
@@ -335,6 +352,18 @@ public class GetInfoOnPDF {
if (modificationDate != null) {
metadata.put("ModificationDate", modificationDate);
}
// Surface custom Info-dictionary entries (anything beyond the
// standard fields above) — e.g. StirlingPDFClassification
for (String key : info.getMetadataKeys()) {
if (STANDARD_INFO_KEYS.contains(key)) {
continue;
}
String value = info.getCustomMetadataValue(key);
if (value != null && !value.isBlank()) {
metadata.put(key, value);
}
}
}
} catch (Exception e) {
log.error("Error extracting metadata: {}", e.getMessage());
@@ -0,0 +1,116 @@
package stirling.software.proprietary.classification;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
import stirling.software.proprietary.classification.model.TaxonomyValidator;
import stirling.software.proprietary.classification.store.TaxonomyStore;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Read/write the caller's team classification taxonomy — the vocabulary the document classifier
* runs against. Team-scoped exactly like policies: every user reads their own team's taxonomy, and
* only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
* {@link PolicyManagementAuthority}) may change it. Editing is gated only when login is enabled;
* single-user deployments trust the local operator. A team with no stored taxonomy reads as {@code
* 204} and the classifier falls back to the engine's built-in default.
*/
@RestController
@RequestMapping("/api/v1/classification/taxonomy")
@Hidden
@RequiredArgsConstructor
@Tag(name = "Classification", description = "Team-scoped document-classification taxonomy")
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class TaxonomyController {
private final TaxonomyStore taxonomyStore;
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
private final UserServiceInterface userService;
@GetMapping
@Operation(
summary = "Get the team's classification taxonomy",
description =
"Returns the caller's team taxonomy, or 204 when the team has none (the"
+ " classifier then uses the built-in default).")
public ResponseEntity<ClassificationTaxonomy> getTaxonomy() {
return taxonomyStore
.findByTeam(currentTeamId())
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.noContent().build());
}
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Save the team's classification taxonomy",
description =
"Validates and stores the taxonomy for the caller's team, shared by everyone on"
+ " the team. Requires the policy-editor role for the team.")
public ResponseEntity<ClassificationTaxonomy> saveTaxonomy(
@RequestBody ClassificationTaxonomy taxonomy) {
requireEditingAllowed();
try {
TaxonomyValidator.validate(taxonomy);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}
ClassificationTaxonomy saved =
taxonomyStore.save(currentTeamId(), taxonomy, currentUsername());
return ResponseEntity.ok(saved);
}
@DeleteMapping
@Operation(
summary = "Reset the team's classification taxonomy",
description =
"Removes the team's stored taxonomy so the classifier falls back to the built-in"
+ " default. Requires the policy-editor role for the team.")
public ResponseEntity<Void> resetTaxonomy() {
requireEditingAllowed();
taxonomyStore.deleteByTeam(currentTeamId());
return ResponseEntity.noContent().build();
}
/**
* Editing the taxonomy requires the editor role for the caller's team — the same gate policies
* use (team leader on SaaS, global admin self-hosted). Single-user deployments (login disabled)
* have no such role, so they trust the local operator.
*/
private void requireEditingAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"The classification taxonomy may only be changed by a team leader");
}
}
private Long currentTeamId() {
return policyManagementAuthority.currentUserTeamId();
}
private String currentUsername() {
return userService == null ? null : userService.getCurrentUsername();
}
}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.classification.model;
import java.util.List;
/**
* The vocabulary a document is classified against — team-scoped and admin-editable. Its shape
* mirrors the engine's {@code ClassificationTaxonomy} contract (categories owning doc_types, plus
* free-standing cross-cutting tags), so a stored taxonomy is passed to the engine verbatim as the
* per-request override. When a team has no stored taxonomy the engine falls back to its built-in
* default.
*/
public record ClassificationTaxonomy(List<TaxonomyCategory> categories, List<String> tags) {
public ClassificationTaxonomy {
categories = categories == null ? List.of() : List.copyOf(categories);
tags = tags == null ? List.of() : List.copyOf(tags);
}
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.classification.model;
import java.util.List;
/**
* A structural family of documents, owning the doc_types shaped like it. {@code docTypes} is
* serialized in the engine's camelCase shape (the engine's {@code ClassificationTaxonomy} model
* aliases {@code doc_types} onto it), so a stored taxonomy passes straight through to the engine.
*/
public record TaxonomyCategory(String id, String label, List<TaxonomyDocumentType> docTypes) {
public TaxonomyCategory {
docTypes = docTypes == null ? List.of() : List.copyOf(docTypes);
}
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.classification.model;
/**
* A specific instrument within a category (e.g. {@code nda} under {@code contract}).
* Category-scoped: the engine enforces that a doc_type can only apply to its owning category.
*/
public record TaxonomyDocumentType(String id, String label) {}
@@ -0,0 +1,87 @@
package stirling.software.proprietary.classification.model;
import java.util.HashSet;
import java.util.Set;
/**
* Structural validation for an admin-supplied (or imported) taxonomy, run before it is stored so a
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
* at least one category, non-blank ids/labels everywhere, ids unique among categories and among the
* doc_types within a category, and non-blank unique tags.
*/
public final class TaxonomyValidator {
private TaxonomyValidator() {}
// Generous upper bounds so a legitimate taxonomy is never blocked, but a single team can't
// store an unbounded blob that would bloat the row, balloon the classifier prompt, or exhaust
// memory on deserialize.
static final int MAX_CATEGORIES = 200;
static final int MAX_DOC_TYPES_PER_CATEGORY = 200;
static final int MAX_TAGS = 500;
static final int MAX_TEXT_LENGTH = 128;
/**
* @throws IllegalArgumentException with a human-readable message when the taxonomy is invalid.
*/
public static void validate(ClassificationTaxonomy taxonomy) {
if (taxonomy == null) {
throw new IllegalArgumentException("Taxonomy is required");
}
if (taxonomy.categories().isEmpty()) {
throw new IllegalArgumentException("Taxonomy must have at least one category");
}
if (taxonomy.categories().size() > MAX_CATEGORIES) {
throw new IllegalArgumentException("Too many categories (max " + MAX_CATEGORIES + ")");
}
if (taxonomy.tags().size() > MAX_TAGS) {
throw new IllegalArgumentException("Too many tags (max " + MAX_TAGS + ")");
}
Set<String> categoryIds = new HashSet<>();
for (TaxonomyCategory category : taxonomy.categories()) {
requireText(category.id(), "Category id");
requireText(category.label(), "Category label");
if (category.docTypes().size() > MAX_DOC_TYPES_PER_CATEGORY) {
throw new IllegalArgumentException(
"Too many sub-categories in '"
+ category.id()
+ "' (max "
+ MAX_DOC_TYPES_PER_CATEGORY
+ ")");
}
if (!categoryIds.add(category.id())) {
throw new IllegalArgumentException("Duplicate category id: " + category.id());
}
Set<String> docTypeIds = new HashSet<>();
for (TaxonomyDocumentType docType : category.docTypes()) {
requireText(docType.id(), "Doc type id");
requireText(docType.label(), "Doc type label");
if (!docTypeIds.add(docType.id())) {
throw new IllegalArgumentException(
"Duplicate doc type id '"
+ docType.id()
+ "' in category '"
+ category.id()
+ "'");
}
}
}
Set<String> tags = new HashSet<>();
for (String tag : taxonomy.tags()) {
requireText(tag, "Tag");
if (!tags.add(tag)) {
throw new IllegalArgumentException("Duplicate tag: " + tag);
}
}
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
if (value.length() > MAX_TEXT_LENGTH) {
throw new IllegalArgumentException(
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
}
}
}
@@ -0,0 +1,37 @@
package stirling.software.proprietary.classification.store;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
/**
* In-memory {@link TaxonomyStore} for tests and any future no-database mode. {@link
* JpaTaxonomyStore} is the runtime bean.
*/
public class InProcessTaxonomyStore implements TaxonomyStore {
private final Map<Long, ClassificationTaxonomy> byTeam = new ConcurrentHashMap<>();
@Override
public Optional<ClassificationTaxonomy> findByTeam(Long teamId) {
return Optional.ofNullable(byTeam.get(key(teamId)));
}
@Override
public ClassificationTaxonomy save(
Long teamId, ClassificationTaxonomy taxonomy, String updatedBy) {
byTeam.put(key(teamId), taxonomy);
return taxonomy;
}
@Override
public boolean deleteByTeam(Long teamId) {
return byTeam.remove(key(teamId)) != null;
}
private static long key(Long teamId) {
return teamId == null ? TaxonomyEntity.NO_TEAM : teamId;
}
}
@@ -0,0 +1,79 @@
package stirling.software.proprietary.classification.store;
import java.time.Instant;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Durable {@link TaxonomyStore} backed by JPA; the runtime store. Gated on {@code policies.enabled}
* — a team taxonomy only matters when the Classification policy can run — so it shares the policy
* subsystem's on/off switch. The taxonomy is persisted as JSON via {@link TaxonomyEntity}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class JpaTaxonomyStore implements TaxonomyStore {
private final TaxonomyRepository repository;
private final ObjectMapper objectMapper;
@Override
public Optional<ClassificationTaxonomy> findByTeam(Long teamId) {
Optional<TaxonomyEntity> entity = repository.findById(key(teamId));
if (entity.isEmpty()) {
return Optional.empty();
}
try {
return Optional.of(
objectMapper.readValue(
entity.get().getTaxonomyJson(), ClassificationTaxonomy.class));
} catch (JacksonException e) {
// A stored taxonomy that no longer parses (corruption / manual DB edit) must not break
// classification: drop it so the caller falls back to the built-in default rather than
// surfacing a 500 on every upload for the team.
log.warn(
"Discarding unparseable stored taxonomy for team {}: {}",
teamId,
e.getMessage());
return Optional.empty();
}
}
@Override
public ClassificationTaxonomy save(
Long teamId, ClassificationTaxonomy taxonomy, String updatedBy) {
TaxonomyEntity entity = new TaxonomyEntity();
entity.setTeamId(key(teamId));
entity.setTaxonomyJson(objectMapper.writeValueAsString(taxonomy));
entity.setUpdatedAt(Instant.now());
entity.setUpdatedBy(updatedBy);
repository.save(entity);
return taxonomy;
}
@Override
public boolean deleteByTeam(Long teamId) {
long id = key(teamId);
if (!repository.existsById(id)) {
return false;
}
repository.deleteById(id);
return true;
}
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
private static long key(Long teamId) {
return teamId == null ? TaxonomyEntity.NO_TEAM : teamId;
}
}
@@ -0,0 +1,47 @@
package stirling.software.proprietary.classification.store;
import java.io.Serializable;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JPA row for a team's classification taxonomy — one row per team. The taxonomy lives as JSON in
* {@code taxonomyJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
* not a foreign key — so classification can be enabled or disabled without touching them.
*/
@Entity
@Table(name = "classification_taxonomies")
@NoArgsConstructor
@Getter
@Setter
public class TaxonomyEntity implements Serializable {
private static final long serialVersionUID = 1L;
/** Sentinel key for the unteamed taxonomy (login disabled / no resolvable team). */
public static final long NO_TEAM = 0L;
@Id
@Column(name = "team_id")
private long teamId;
@Column(name = "taxonomy_json", columnDefinition = "text")
private String taxonomyJson;
@Column(name = "updated_at")
private Instant updatedAt;
@Column(name = "updated_by")
private String updatedBy;
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.classification.store;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TaxonomyRepository extends JpaRepository<TaxonomyEntity, Long> {}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.classification.store;
import java.util.Optional;
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
/**
* Stores one {@link ClassificationTaxonomy} per team. A {@code null} teamId addresses the unteamed
* taxonomy (login disabled / no resolvable team), mirroring how the policy store treats a null
* team.
*/
public interface TaxonomyStore {
/** The team's stored taxonomy, or empty when it has none (callers fall back to the default). */
Optional<ClassificationTaxonomy> findByTeam(Long teamId);
/** Create or replace the team's taxonomy. Returns the stored value. */
ClassificationTaxonomy save(Long teamId, ClassificationTaxonomy taxonomy, String updatedBy);
/** Remove the team's taxonomy (reset to default). Returns whether one existed. */
boolean deleteByTeam(Long teamId);
}
@@ -0,0 +1,193 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.classification.store.TaxonomyStore;
import stirling.software.proprietary.model.api.ai.AiPageText;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.PdfContentExtractor;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
*
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
* engine to classify the document, and stores the engine's JSON answer — minus the transport-only
* {@code outcome} field — in the custom Info-dictionary key {@link
* PdfMetadataService#CLASSIFICATION_KEY}. Returns the tagged PDF. Not intended for direct client
* use.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/ai/tools")
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
public class ClassifyTagController {
/** Pages read from each end of the document — mirrors the engine's window. */
private static final int WINDOW_PAGES = 2;
private static final String CLASSIFY_ENDPOINT = "/api/v1/documents/classify";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final PdfContentExtractor pdfContentExtractor;
private final PdfMetadataService pdfMetadataService;
private final AiEngineClient aiEngineClient;
private final ObjectMapper objectMapper;
private final UserServiceInterface userService;
/**
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
* team authority are gated on it. Null otherwise, in which case classification falls back to
* the engine's built-in default taxonomy.
*/
private final TaxonomyStore taxonomyStore;
private final PolicyManagementAuthority policyManagementAuthority;
public ClassifyTagController(
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager,
PdfContentExtractor pdfContentExtractor,
PdfMetadataService pdfMetadataService,
AiEngineClient aiEngineClient,
ObjectMapper objectMapper,
@Autowired(required = false) UserServiceInterface userService,
@Autowired(required = false) TaxonomyStore taxonomyStore,
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.tempFileManager = tempFileManager;
this.pdfContentExtractor = pdfContentExtractor;
this.pdfMetadataService = pdfMetadataService;
this.aiEngineClient = aiEngineClient;
this.objectMapper = objectMapper;
this.userService = userService;
this.taxonomyStore = taxonomyStore;
this.policyManagementAuthority = policyManagementAuthority;
}
@PostMapping(value = "/classify-and-tag", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Classify a PDF and tag its metadata",
description =
"Reads the first two and last two pages, classifies the document via the AI"
+ " engine, and stores the result in the StirlingPDFClassification"
+ " metadata field. Dispatched by the Classification policy; not"
+ " intended for direct client use.")
public ResponseEntity<Resource> classifyAndTag(
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
String fileName = safeFileName(fileInput.getOriginalFilename());
List<AiPageText> pages = extractWindow(document);
String requestBody =
objectMapper.writeValueAsString(
new ClassifyEngineRequest(fileName, pages, resolveTaxonomyOverride()));
String userId = userService != null ? userService.getCurrentUsername() : null;
String responseJson = aiEngineClient.post(CLASSIFY_ENDPOINT, requestBody, userId);
pdfMetadataService.setClassificationMetadata(document, toMetadataValue(responseJson));
log.debug("[classify-and-tag] tagged {} ({} window pages)", fileName, pages.size());
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
}
}
private List<AiPageText> extractWindow(PDDocument document) throws IOException {
List<AiPageText> pages = new ArrayList<>();
for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) {
String text = pdfContentExtractor.extractPageTextRaw(document, pageNumber);
if (text != null && !text.isBlank()) {
pages.add(new AiPageText(pageNumber, text));
}
}
return pages;
}
/** First and last {@code window} page numbers (1-based), de-duplicated and in order. */
static List<Integer> windowPageNumbers(int pageCount, int window) {
Set<Integer> numbers = new LinkedHashSet<>();
for (int page = 1; page <= Math.min(window, pageCount); page++) {
numbers.add(page);
}
for (int page = Math.max(1, pageCount - window + 1); page <= pageCount; page++) {
numbers.add(page);
}
return new ArrayList<>(numbers);
}
/** Drop the transport-only {@code outcome} discriminator; keep the rest verbatim. */
private String toMetadataValue(String engineResponseJson) {
JsonNode node = objectMapper.readTree(engineResponseJson);
if (node instanceof ObjectNode object) {
object.remove("outcome");
}
return objectMapper.writeValueAsString(node);
}
private static String safeFileName(String originalFilename) {
String name = Filenames.toSimpleFileName(originalFilename);
return (name == null || name.isBlank()) ? "classified.pdf" : name;
}
/**
* Resolve the caller's team taxonomy and return it in the engine's shape to classify against;
* {@code null} falls back to the engine's generated default. The stored taxonomy is already in
* the engine's camelCase shape ({@code categories}/{@code docTypes}/{@code tags}), so it is
* passed through verbatim. Returns null when the policy subsystem is disabled (no store), when
* the team has no stored taxonomy, or when the team can't be resolved.
*/
private JsonNode resolveTaxonomyOverride() {
if (taxonomyStore == null) {
return null;
}
Long teamId =
policyManagementAuthority == null
? null
: policyManagementAuthority.currentUserTeamId();
return taxonomyStore
.findByTeam(teamId)
.map(taxonomy -> (JsonNode) objectMapper.valueToTree(taxonomy))
.orElse(null);
}
/** Request body for the engine's {@code /api/v1/documents/classify} endpoint. */
@JsonInclude(JsonInclude.Include.NON_NULL)
private record ClassifyEngineRequest(
String fileName, List<AiPageText> pages, JsonNode taxonomy) {}
}
@@ -21,4 +21,11 @@ public class AiWorkflowResultFile {
@Schema(description = "MIME type of the file", example = "application/pdf")
private String contentType;
@Schema(
description =
"Index into the request's fileInputs that this output was derived from, or null"
+ " when it has no single source (e.g. a merge, or a generated file)."
+ " Lets the client replace that input in place as a new version.")
private Integer sourceIndex;
}
@@ -8,7 +8,11 @@ import tools.jackson.databind.JsonNode;
/**
* Result of a {@link PolicyExecutor} run. {@code files} are final temp files (not yet stored).
* {@code report}/{@code reportTool} carry the last step's structured report and its operation, or
* null if no step produced one.
* {@code origins} is parallel to {@code files}: each entry is the index into the original pipeline
* inputs that the output traces back to, or {@code null} when it has no single source (e.g. a merge
* combining several inputs, or a generated file). Callers use it to map an output back onto the
* file it came from. {@code report}/{@code reportTool} carry the last step's structured report and
* its operation, or null if no step produced one.
*/
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
public record PolicyExecutionResult(
List<Resource> files, List<Integer> origins, JsonNode report, String reportTool) {}
@@ -58,6 +58,11 @@ public class PolicyExecutor {
// payload the tool surfaced alongside or instead of a file.
private record ToolResult(List<Resource> files, JsonNode report) {}
// A step's output files paired with each file's origin (the index into the original pipeline
// inputs it traces back to, or null when it has no single source). Origins compose across steps
// so the final result can be mapped back onto the files that entered the pipeline.
private record StepOutput(List<Resource> files, List<Integer> origins, JsonNode report) {}
/**
* Run every step in order, feeding each step's output into the next. Supporting files in {@code
* inputs} bind to named file fields and never enter the document stream.
@@ -75,6 +80,12 @@ public class PolicyExecutor {
List<Resource> currentFiles = inputs.primary();
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
// Seed each input with its own index as origin; steps carry these through so the final
// outputs can be traced back to the files that entered the pipeline.
List<Integer> currentOrigins = new ArrayList<>();
for (int k = 0; k < currentFiles.size(); k++) {
currentOrigins.add(k);
}
// Last non-null report wins: the terminal step defines the output.
JsonNode lastReport = null;
String lastReportTool = null;
@@ -87,8 +98,10 @@ public class PolicyExecutor {
"Pipeline step " + (i + 1) + " has no operation");
}
listener.onStepStart(i + 1, steps.size(), operation);
ToolResult stepResult = executeStep(step, currentFiles, supportingFiles);
StepOutput stepResult =
executeStep(step, currentFiles, currentOrigins, supportingFiles);
currentFiles = stepResult.files();
currentOrigins = stepResult.origins();
if (stepResult.report() != null) {
lastReport = stepResult.report();
lastReportTool = operation;
@@ -96,7 +109,7 @@ public class PolicyExecutor {
listener.onStepComplete(i + 1, steps.size(), operation);
}
return new PolicyExecutionResult(currentFiles, lastReport, lastReportTool);
return new PolicyExecutionResult(currentFiles, currentOrigins, lastReport, lastReportTool);
}
/**
@@ -104,32 +117,50 @@ public class PolicyExecutor {
* responses are unpacked so each inner file is its own result (e.g. split). For per-file
* dispatch the first non-null report wins.
*/
private ToolResult executeStep(
private StepOutput executeStep(
PipelineStep step,
List<Resource> inputFiles,
List<Integer> inputOrigins,
Map<String, List<Resource>> supportingFiles)
throws IOException {
requireAcceptedTypes(step.operation(), inputFiles);
List<Resource> files = new ArrayList<>();
List<Integer> origins = new ArrayList<>();
JsonNode report = null;
if (toolMetadataService.isMultiInput(step.operation())) {
// One call over all inputs. The outputs derive from a single input only when exactly
// one entered; otherwise (a genuine merge) there is no single source.
ToolResult r = callEndpoint(step, inputFiles, supportingFiles);
files.addAll(r.files());
Integer origin = inputOrigins.size() == 1 ? inputOrigins.get(0) : null;
for (Resource file : r.files()) {
files.add(file);
origins.add(origin);
}
report = r.report();
} else if (inputFiles.isEmpty()) {
ToolResult r = callEndpoint(step, List.of(), supportingFiles);
files.addAll(r.files());
for (Resource file : r.files()) {
files.add(file);
origins.add(null);
}
report = r.report();
} else {
for (Resource file : inputFiles) {
ToolResult r = callEndpoint(step, List.of(file), supportingFiles);
files.addAll(r.files());
// One call per file: every output of this call inherits that input's origin, so a 1:1
// op keeps its chain and a split (one input, many outputs) tags each output with the
// same source.
for (int k = 0; k < inputFiles.size(); k++) {
Integer origin = inputOrigins.get(k);
ToolResult r = callEndpoint(step, List.of(inputFiles.get(k)), supportingFiles);
for (Resource file : r.files()) {
files.add(file);
origins.add(origin);
}
if (report == null) {
report = r.report();
}
}
}
return new ToolResult(files, report);
return new StepOutput(files, origins, report);
}
/**
@@ -36,7 +36,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.source",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.repository",
"stirling.software.proprietary.integration.repository"
"stirling.software.proprietary.integration.repository",
"stirling.software.proprietary.classification.store"
})
@EntityScan({
"stirling.software.proprietary.security.model",
@@ -47,7 +48,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.policy.source",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.access.model",
"stirling.software.proprietary.integration.model"
"stirling.software.proprietary.integration.model",
"stirling.software.proprietary.classification.store"
})
public class DatabaseConfig {
@@ -352,6 +352,7 @@ public class AiWorkflowService {
try {
List<Resource> resultFiles = new ArrayList<>();
List<Integer> origins = new ArrayList<>();
List<String> inputNames = new ArrayList<>();
for (int i = 0; i < filesToConvert.size(); i++) {
AiFile file = filesToConvert.get(i);
@@ -376,11 +377,15 @@ public class AiWorkflowService {
definition,
PolicyInputs.of(List.of(input)),
PolicyProgressListener.NOOP);
resultFiles.addAll(result.files());
// Each conversion runs on one input, so every output traces back to file i.
for (Resource output : result.files()) {
resultFiles.add(output);
origins.add(i);
}
inputNames.add(multipartFile.getOriginalFilename());
}
return new WorkflowState.Terminal(
buildCompletedResponse(null, resultFiles, inputNames, null));
buildCompletedResponse(null, resultFiles, origins, inputNames, null));
} catch (InternalApiTimeoutException e) {
log.error("PDF to Markdown conversion timed out: {}", e.getMessage());
return new WorkflowState.Terminal(
@@ -472,6 +477,7 @@ public class AiWorkflowService {
buildCompletedResponse(
response.getRationale(),
result.files(),
result.origins(),
inputFileNames(filesById),
result.report()));
} catch (InternalApiTimeoutException e) {
@@ -533,7 +539,8 @@ public class AiWorkflowService {
}
};
return new WorkflowState.Terminal(
buildCompletedResponse(response.getSummary(), List.of(resource), List.of(), null));
buildCompletedResponse(
response.getSummary(), List.of(resource), null, List.of(), null));
}
@SuppressWarnings("unchecked")
@@ -591,7 +598,11 @@ public class AiWorkflowService {
return new WorkflowState.Terminal(
buildCompletedResponse(
summary, result.files(), inputFileNames(filesById), result.report()));
summary,
result.files(),
result.origins(),
inputFileNames(filesById),
result.report()));
} catch (InternalApiTimeoutException e) {
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
return new WorkflowState.Terminal(
@@ -680,19 +691,35 @@ public class AiWorkflowService {
private AiWorkflowResponse buildCompletedResponse(
String summary,
List<Resource> resultFiles,
List<Integer> origins,
List<String> inputFileNames,
JsonNode report)
throws IOException {
// Store every output file individually so each gets its own Stirling file ID and the
// frontend can add them as independent variants without going through a zip.
boolean preserveInputNames = inputFileNames.size() == resultFiles.size();
// Count outputs per source so only a clean 1:1 transform (one output for a source) reuses
// the input's name; a split (one input → many outputs) keeps each entry's own name.
Map<Integer, Long> outputsPerOrigin =
origins == null
? Map.of()
: origins.stream()
.filter(o -> o != null)
.collect(Collectors.groupingBy(o -> o, Collectors.counting()));
List<AiWorkflowResultFile> descriptors = new ArrayList<>();
for (int i = 0; i < resultFiles.size(); i++) {
Resource resource = resultFiles.get(i);
String responseName = resource.getFilename();
String inputName = preserveInputNames ? inputFileNames.get(i) : null;
// Prefer the input name only for 1:1 operations where the output keeps the same
// extension (rotate, compress, etc.). For converters and other extension-changing
// The output's source input (from the executor), used both to name it and to tell the
// client which file to version in place.
Integer origin = origins != null && i < origins.size() ? origins.get(i) : null;
boolean uniqueOrigin =
origin != null && outputsPerOrigin.getOrDefault(origin, 0L) == 1L;
String inputName =
uniqueOrigin && origin >= 0 && origin < inputFileNames.size()
? inputFileNames.get(origin)
: null;
// Prefer the source input's name only for 1:1 operations where the output keeps the
// same extension (rotate, compress, etc.). For converters and other extension-changing
// tools, the response filename from Content-Disposition is authoritative.
String name;
if (inputName != null
@@ -712,7 +739,11 @@ public class AiWorkflowService {
try (java.io.InputStream is = resource.getInputStream()) {
fileId = fileStorage.storeInputStream(is, name).fileId();
}
descriptors.add(new AiWorkflowResultFile(fileId, name, contentType));
// Only expose the source when this is a clean 1:1 transform, so the client can treat a
// present sourceIndex as "replace that input in place" without further disambiguation.
descriptors.add(
new AiWorkflowResultFile(
fileId, name, contentType, uniqueOrigin ? origin : null));
}
AiWorkflowResponse completed = new AiWorkflowResponse();
@@ -0,0 +1,134 @@
package stirling.software.proprietary.classification;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
import stirling.software.proprietary.classification.model.TaxonomyCategory;
import stirling.software.proprietary.classification.model.TaxonomyDocumentType;
import stirling.software.proprietary.classification.store.InProcessTaxonomyStore;
import stirling.software.proprietary.classification.store.TaxonomyStore;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
@ExtendWith(MockitoExtension.class)
@DisplayName("TaxonomyController")
class TaxonomyControllerTest {
private static final Long TEAM = 7L;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@Mock private UserServiceInterface userService;
private TaxonomyStore store;
private ApplicationProperties applicationProperties;
private TaxonomyController controller;
@BeforeEach
void setUp() {
store = new InProcessTaxonomyStore();
applicationProperties = new ApplicationProperties();
controller =
new TaxonomyController(
store, policyManagementAuthority, applicationProperties, userService);
}
private static ClassificationTaxonomy sample() {
return new ClassificationTaxonomy(
List.of(
new TaxonomyCategory(
"invoice",
"Invoice",
List.of(new TaxonomyDocumentType("receipt", "Receipt")))),
List.of("finance"));
}
private void loginEnabled(boolean enabled) {
applicationProperties.getSecurity().setEnableLogin(enabled);
}
@Test
@DisplayName("GET returns 204 when the team has no taxonomy")
void getEmpty() {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
ResponseEntity<ClassificationTaxonomy> response = controller.getTaxonomy();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("PUT then GET round-trips the team's taxonomy (login disabled)")
void saveThenGet() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTaxonomy(sample());
ResponseEntity<ClassificationTaxonomy> got = controller.getTaxonomy();
assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(got.getBody()).isNotNull();
assertThat(got.getBody().categories()).hasSize(1);
assertThat(got.getBody().categories().getFirst().id()).isEqualTo("invoice");
}
@Test
@DisplayName("PUT is scoped per team")
void perTeam() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTaxonomy(sample());
when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L);
assertThat(controller.getTaxonomy().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@DisplayName("PUT is rejected for a non-editor when login is enabled")
void putForbiddenForNonEditor() {
loginEnabled(true);
when(policyManagementAuthority.canEditPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.saveTaxonomy(sample()))
.isInstanceOf(ResponseStatusException.class)
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("PUT rejects an invalid taxonomy with 400")
void putInvalid() {
loginEnabled(false);
assertThatThrownBy(
() ->
controller.saveTaxonomy(
new ClassificationTaxonomy(List.of(), List.of())))
.isInstanceOf(ResponseStatusException.class)
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("DELETE resets the team back to no stored taxonomy")
void deleteResets() {
loginEnabled(false);
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
controller.saveTaxonomy(sample());
ResponseEntity<Void> response = controller.resetTaxonomy();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
assertThat(controller.getTaxonomy().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
@@ -0,0 +1,110 @@
package stirling.software.proprietary.classification.model;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("TaxonomyValidator")
class TaxonomyValidatorTest {
private static TaxonomyCategory category(String id, TaxonomyDocumentType... docTypes) {
return new TaxonomyCategory(id, id + " label", List.of(docTypes));
}
private static TaxonomyDocumentType docType(String id) {
return new TaxonomyDocumentType(id, id + " label");
}
@Test
@DisplayName("accepts a well-formed taxonomy")
void acceptsValid() {
ClassificationTaxonomy taxonomy =
new ClassificationTaxonomy(
List.of(category("invoice", docType("receipt")), category("contract")),
List.of("finance", "legal"));
assertThatCode(() -> TaxonomyValidator.validate(taxonomy)).doesNotThrowAnyException();
}
@Test
@DisplayName("rejects a taxonomy with no categories")
void rejectsEmpty() {
ClassificationTaxonomy taxonomy = new ClassificationTaxonomy(List.of(), List.of());
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("at least one category");
}
@Test
@DisplayName("rejects duplicate category ids")
void rejectsDuplicateCategory() {
ClassificationTaxonomy taxonomy =
new ClassificationTaxonomy(
List.of(category("invoice"), category("invoice")), List.of());
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate category id");
}
@Test
@DisplayName("rejects duplicate doc type ids within a category")
void rejectsDuplicateDocType() {
ClassificationTaxonomy taxonomy =
new ClassificationTaxonomy(
List.of(category("invoice", docType("receipt"), docType("receipt"))),
List.of());
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate doc type id");
}
@Test
@DisplayName("rejects blank ids and labels")
void rejectsBlank() {
ClassificationTaxonomy taxonomy =
new ClassificationTaxonomy(
List.of(new TaxonomyCategory(" ", "label", List.of())), List.of());
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must not be blank");
}
@Test
@DisplayName("rejects duplicate tags")
void rejectsDuplicateTags() {
ClassificationTaxonomy taxonomy =
new ClassificationTaxonomy(
List.of(category("invoice")), List.of("finance", "finance"));
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Duplicate tag");
}
@Test
@DisplayName("rejects more categories than the cap")
void rejectsTooManyCategories() {
List<TaxonomyCategory> categories =
java.util.stream.IntStream.rangeClosed(0, TaxonomyValidator.MAX_CATEGORIES)
.mapToObj(i -> category("cat" + i))
.toList();
ClassificationTaxonomy taxonomy = new ClassificationTaxonomy(categories, List.of());
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Too many categories");
}
@Test
@DisplayName("rejects an over-long label")
void rejectsOverLongLabel() {
String longLabel = "x".repeat(TaxonomyValidator.MAX_TEXT_LENGTH + 1);
ClassificationTaxonomy taxonomy =
new ClassificationTaxonomy(
List.of(new TaxonomyCategory("invoice", longLabel, List.of())), List.of());
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("too long");
}
}
@@ -0,0 +1,105 @@
package stirling.software.proprietary.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.PdfContentExtractor;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ClassifyTagControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private PdfContentExtractor pdfContentExtractor;
@Mock private PdfMetadataService pdfMetadataService;
@Mock private AiEngineClient aiEngineClient;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private ClassifyTagController controller;
@BeforeEach
void setUp() {
controller =
new ClassifyTagController(
pdfDocumentFactory,
tempFileManager,
pdfContentExtractor,
pdfMetadataService,
aiEngineClient,
objectMapper,
null,
null,
null);
}
@Test
void classifyAndTag_writesClassificationWithoutOutcome() throws Exception {
PDDocument document = mock(PDDocument.class);
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("invoice.pdf");
when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document);
when(document.getNumberOfPages()).thenReturn(1);
when(pdfContentExtractor.extractPageTextRaw(document, 1))
.thenReturn("Invoice total due 100.00");
when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull()))
.thenReturn(
"{\"outcome\":\"classification\",\"category\":\"invoice\","
+ "\"docType\":\"invoice\",\"typeConfidence\":0.98,"
+ "\"tags\":[\"finance\"]}");
try {
controller.classifyAndTag(file);
} catch (Exception ignored) {
// WebResponseUtils.pdfDocToWebResponse needs a real temp file; the metadata write we
// assert on has already happened by the time it runs.
}
ArgumentCaptor<String> value = ArgumentCaptor.forClass(String.class);
verify(pdfMetadataService).setClassificationMetadata(eq(document), value.capture());
JsonNode written = objectMapper.readTree(value.getValue());
assertThat(written.has("outcome")).isFalse();
assertThat(written.get("category").asText()).isEqualTo("invoice");
assertThat(written.get("docType").asText()).isEqualTo("invoice");
assertThat(written.get("tags").get(0).asText()).isEqualTo("finance");
}
@Test
void windowPageNumbers_takesFirstAndLastWithoutOverlap() {
assertEquals(List.of(1, 2, 4, 5), ClassifyTagController.windowPageNumbers(5, 2));
assertEquals(List.of(1, 2, 3), ClassifyTagController.windowPageNumbers(3, 2));
// Short docs clamp + dedupe rather than throwing or going out of range.
assertEquals(List.of(1, 2), ClassifyTagController.windowPageNumbers(2, 2));
assertEquals(List.of(1), ClassifyTagController.windowPageNumbers(1, 2));
assertEquals(List.of(), ClassifyTagController.windowPageNumbers(0, 2));
}
}
@@ -2,6 +2,7 @@ package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
@@ -229,6 +230,57 @@ class AiWorkflowServiceTest {
// 1:1 mapping preserves each input's filename.
assertEquals("a.pdf", result.getResultFiles().get(0).getFileName());
assertEquals("b.pdf", result.getResultFiles().get(1).getFileName());
// Each output points back at the input it came from so the client versions it in place.
assertEquals(0, result.getResultFiles().get(0).getSourceIndex());
assertEquals(1, result.getResultFiles().get(1).getSourceIndex());
}
@Test
void mergeOutputHasNoSourceIndex() throws IOException {
MockMultipartFile a = pdf("a.pdf", "a-bytes");
MockMultipartFile b = pdf("b.pdf", "b-bytes");
stubOrchestrator(
"""
{"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Merging"}
"""
.formatted(MERGE_ENDPOINT));
when(toolMetadataService.isMultiInput(MERGE_ENDPOINT)).thenReturn(true);
when(toolMetadataService.shouldUnpackZipResponse(MERGE_ENDPOINT)).thenReturn(false);
stubEndpoint(MERGE_ENDPOINT, pdfResource("merged-bytes", "merged.pdf"));
stubFileStorage();
AiWorkflowResponse result =
service.orchestrate(requestFor(new MockMultipartFile[] {a, b}, "merge these"));
// A merge draws on several inputs, so there is no single source to version in place.
assertNull(result.getResultFiles().get(0).getSourceIndex());
}
@Test
void splitOutputsHaveNoSourceIndex() throws IOException {
MockMultipartFile input = pdf("doc.pdf", "original");
stubOrchestrator(
"""
{"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Splitting"}
"""
.formatted(SPLIT_ENDPOINT));
when(toolMetadataService.isMultiInput(SPLIT_ENDPOINT)).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(SPLIT_ENDPOINT)).thenReturn(true);
stubEndpoint(
SPLIT_ENDPOINT,
zipResource(
"doc.zip",
List.of(
new ZipEntryBytes("page-1.pdf", "page-one"),
new ZipEntryBytes("page-2.pdf", "page-two"))));
stubFileStorage();
AiWorkflowResponse result = service.orchestrate(requestFor(input, "split"));
// One input fanned out to many outputs, so none is a clean 1:1 version — the client adds
// them as fresh files and leaves the original in place.
assertNull(result.getResultFiles().get(0).getSourceIndex());
assertNull(result.getResultFiles().get(1).getSourceIndex());
}
@Test
@@ -0,0 +1,14 @@
-- Per-team classification taxonomy (gated by policies.enabled): the admin-editable vocabulary the
-- document classifier runs against. One row per team; the whole taxonomy lives as JSON in
-- taxonomy_json (authoritative on read). team_id is the natural key and a plain value (not a foreign
-- key) to stay decoupled from the security entities, so classification can be enabled or disabled
-- without touching them; the sentinel 0 holds the unteamed (login-disabled) taxonomy. Hibernate
-- ddl-auto would also create this, but this keeps the schema explicit for the Flyway-managed
-- deployments.
CREATE TABLE IF NOT EXISTS classification_taxonomies (
team_id BIGINT PRIMARY KEY,
taxonomy_json TEXT,
updated_at TIMESTAMP,
updated_by VARCHAR(255)
);
+2
View File
@@ -1,5 +1,6 @@
"""Agent modules for Stirling AI reasoning flows."""
from .document_classifier import DocumentClassifierAgent
from .execution import ExecutionPlanningAgent
from .orchestrator import OrchestratorAgent
from .pdf_create import PdfCreateAgent
@@ -9,6 +10,7 @@ from .pdf_review import PdfReviewAgent
from .user_spec import UserSpecAgent
__all__ = [
"DocumentClassifierAgent",
"ExecutionPlanningAgent",
"OrchestratorAgent",
"PdfCreateAgent",
@@ -0,0 +1,569 @@
{
"_generated": "AUTO-GENERATED from frontend/editor/src/proprietary/data/classificationTaxonomy.ts by editor/scripts/generate-classification-taxonomy.mts — do NOT edit by hand; run `task frontend:classifier-categories`.",
"categories": [
{
"id": "invoice",
"label": "Invoice",
"docTypes": [
{
"id": "invoice",
"label": "Invoice"
},
{
"id": "receipt",
"label": "Receipt"
},
{
"id": "credit_note",
"label": "Credit note"
},
{
"id": "purchase_order",
"label": "Purchase order"
},
{
"id": "quote",
"label": "Quote"
}
]
},
{
"id": "contract",
"label": "Contract",
"docTypes": [
{
"id": "nda",
"label": "Non-disclosure agreement"
},
{
"id": "employment_agreement",
"label": "Employment agreement"
},
{
"id": "service_agreement",
"label": "Service agreement"
},
{
"id": "lease_agreement",
"label": "Lease agreement"
},
{
"id": "master_service_agreement",
"label": "Master service agreement"
},
{
"id": "statement_of_work",
"label": "Statement of work"
},
{
"id": "terms_of_service",
"label": "Terms of service"
}
]
},
{
"id": "financial_statement",
"label": "Financial statement",
"docTypes": [
{
"id": "balance_sheet",
"label": "Balance sheet"
},
{
"id": "income_statement",
"label": "Income statement"
},
{
"id": "cash_flow_statement",
"label": "Cash flow statement"
},
{
"id": "bank_statement",
"label": "Bank statement"
},
{
"id": "annual_report",
"label": "Annual report"
}
]
},
{
"id": "report",
"label": "Report",
"docTypes": [
{
"id": "business_report",
"label": "Business report"
},
{
"id": "project_report",
"label": "Project report"
},
{
"id": "research_report",
"label": "Research report"
},
{
"id": "status_report",
"label": "Status report"
},
{
"id": "incident_report",
"label": "Incident report"
}
]
},
{
"id": "letter",
"label": "Letter",
"docTypes": [
{
"id": "business_letter",
"label": "Business letter"
},
{
"id": "cover_letter",
"label": "Cover letter"
},
{
"id": "recommendation_letter",
"label": "Recommendation letter"
},
{
"id": "complaint_letter",
"label": "Complaint letter"
},
{
"id": "demand_letter",
"label": "Demand letter"
}
]
},
{
"id": "form",
"label": "Form",
"docTypes": [
{
"id": "application_form",
"label": "Application form"
},
{
"id": "registration_form",
"label": "Registration form"
},
{
"id": "consent_form",
"label": "Consent form"
},
{
"id": "survey",
"label": "Survey"
},
{
"id": "questionnaire",
"label": "Questionnaire"
}
]
},
{
"id": "resume",
"label": "Resume",
"docTypes": [
{
"id": "resume",
"label": "Resume"
},
{
"id": "curriculum_vitae",
"label": "Curriculum vitae"
},
{
"id": "portfolio",
"label": "Portfolio"
},
{
"id": "reference_sheet",
"label": "Reference sheet"
}
]
},
{
"id": "tax_form",
"label": "Tax form",
"docTypes": [
{
"id": "tax_return",
"label": "Tax return"
},
{
"id": "w2",
"label": "W-2"
},
{
"id": "w9",
"label": "W-9"
},
{
"id": "form_1099",
"label": "Form 1099"
},
{
"id": "vat_return",
"label": "VAT return"
}
]
},
{
"id": "expense_report",
"label": "Expense report",
"docTypes": [
{
"id": "expense_report",
"label": "Expense report"
},
{
"id": "reimbursement_request",
"label": "Reimbursement request"
},
{
"id": "mileage_log",
"label": "Mileage log"
},
{
"id": "per_diem_claim",
"label": "Per diem claim"
}
]
},
{
"id": "presentation",
"label": "Presentation",
"docTypes": [
{
"id": "slide_deck",
"label": "Slide deck"
},
{
"id": "pitch_deck",
"label": "Pitch deck"
},
{
"id": "training_deck",
"label": "Training deck"
},
{
"id": "webinar_deck",
"label": "Webinar deck"
}
]
},
{
"id": "medical_record",
"label": "Medical record",
"docTypes": [
{
"id": "lab_result",
"label": "Lab result"
},
{
"id": "prescription",
"label": "Prescription"
},
{
"id": "discharge_summary",
"label": "Discharge summary"
},
{
"id": "medical_history",
"label": "Medical history"
},
{
"id": "imaging_report",
"label": "Imaging report"
},
{
"id": "vaccination_record",
"label": "Vaccination record"
}
]
},
{
"id": "legal_filing",
"label": "Legal filing",
"docTypes": [
{
"id": "court_filing",
"label": "Court filing"
},
{
"id": "complaint",
"label": "Complaint"
},
{
"id": "motion",
"label": "Motion"
},
{
"id": "subpoena",
"label": "Subpoena"
},
{
"id": "affidavit",
"label": "Affidavit"
},
{
"id": "deposition",
"label": "Deposition"
}
]
},
{
"id": "identity_document",
"label": "Identity document",
"docTypes": [
{
"id": "passport",
"label": "Passport"
},
{
"id": "drivers_license",
"label": "Driver's license"
},
{
"id": "national_id",
"label": "National ID"
},
{
"id": "birth_certificate",
"label": "Birth certificate"
},
{
"id": "visa",
"label": "Visa"
}
]
},
{
"id": "insurance",
"label": "Insurance",
"docTypes": [
{
"id": "insurance_policy",
"label": "Insurance policy"
},
{
"id": "insurance_claim",
"label": "Insurance claim"
},
{
"id": "certificate_of_insurance",
"label": "Certificate of insurance"
},
{
"id": "explanation_of_benefits",
"label": "Explanation of benefits"
}
]
},
{
"id": "real_estate",
"label": "Real estate",
"docTypes": [
{
"id": "deed",
"label": "Deed"
},
{
"id": "mortgage_agreement",
"label": "Mortgage agreement"
},
{
"id": "property_appraisal",
"label": "Property appraisal"
},
{
"id": "closing_disclosure",
"label": "Closing disclosure"
},
{
"id": "title_report",
"label": "Title report"
}
]
},
{
"id": "shipping",
"label": "Shipping",
"docTypes": [
{
"id": "bill_of_lading",
"label": "Bill of lading"
},
{
"id": "packing_slip",
"label": "Packing slip"
},
{
"id": "customs_declaration",
"label": "Customs declaration"
},
{
"id": "delivery_note",
"label": "Delivery note"
},
{
"id": "air_waybill",
"label": "Air waybill"
}
]
},
{
"id": "hr_document",
"label": "HR document",
"docTypes": [
{
"id": "offer_letter",
"label": "Offer letter"
},
{
"id": "performance_review",
"label": "Performance review"
},
{
"id": "payslip",
"label": "Payslip"
},
{
"id": "employee_handbook",
"label": "Employee handbook"
},
{
"id": "termination_letter",
"label": "Termination letter"
},
{
"id": "timesheet",
"label": "Timesheet"
}
]
},
{
"id": "academic_record",
"label": "Academic record",
"docTypes": [
{
"id": "transcript",
"label": "Transcript"
},
{
"id": "diploma",
"label": "Diploma"
},
{
"id": "certificate",
"label": "Certificate"
},
{
"id": "syllabus",
"label": "Syllabus"
},
{
"id": "thesis",
"label": "Thesis"
},
{
"id": "report_card",
"label": "Report card"
}
]
},
{
"id": "marketing_material",
"label": "Marketing material",
"docTypes": [
{
"id": "brochure",
"label": "Brochure"
},
{
"id": "flyer",
"label": "Flyer"
},
{
"id": "case_study",
"label": "Case study"
},
{
"id": "white_paper",
"label": "White paper"
},
{
"id": "press_release",
"label": "Press release"
}
]
},
{
"id": "technical_document",
"label": "Technical document",
"docTypes": [
{
"id": "user_manual",
"label": "User manual"
},
{
"id": "specification",
"label": "Specification"
},
{
"id": "api_documentation",
"label": "API documentation"
},
{
"id": "installation_guide",
"label": "Installation guide"
},
{
"id": "datasheet",
"label": "Datasheet"
},
{
"id": "release_notes",
"label": "Release notes"
}
]
}
],
"tags": [
"finance",
"legal",
"medical",
"hr",
"tax",
"insurance",
"marketing",
"technical",
"operations",
"academic",
"government",
"draft",
"final",
"signed",
"unsigned",
"executed",
"expired",
"amended",
"void",
"confidential",
"internal",
"public",
"pii",
"phi",
"certified",
"notarized",
"scanned",
"redacted",
"template",
"urgent"
]
}
@@ -0,0 +1,193 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from pydantic import Field
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.contracts import (
ClassificationTaxonomy,
ClassifyDocumentRequest,
ClassifyDocumentResponse,
DocumentClassificationResponse,
PageText,
)
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
# Sentinel id for an answer that fell outside the supplied vocabulary.
UNKNOWN_LABEL = "unknown"
# Human-readable label shown for the off-list sentinel.
UNKNOWN_DISPLAY_LABEL = "Unknown"
# An off-list answer can never be reported as more confident than this, so a
# confident-but-wrong model answer can't clear an organisation's accept
# threshold downstream. See the design doc's "Validate" step.
UNKNOWN_MAX_CONFIDENCE = 0.2
# Pages read from each end of the document. A document's type is evident from
# its opening (and closing) pages, so a fixed window keeps cost and latency flat
# regardless of length. Promote to AppSettings if it ever needs tuning.
WINDOW_PAGES = 2
# The built-in vocabulary the classifier falls back to when a request doesn't
# supply its own. GENERATED from the TS source of truth
# (frontend/editor/src/proprietary/data/classificationTaxonomy.ts) via
# `task frontend:classifier-categories` — edit that file, not this JSON. Validated into the
# typed contract on import, so a malformed entry fails fast.
_DEFAULT_TAXONOMY_PATH = Path(__file__).with_name("default_classification_taxonomy.generated.json")
# The file carries an underscore-prefixed "_generated" notice (JSON has no
# comments); drop meta keys before validating against the strict contract.
_raw_taxonomy = json.loads(_DEFAULT_TAXONOMY_PATH.read_text(encoding="utf-8"))
DEFAULT_TAXONOMY = ClassificationTaxonomy.model_validate(
{key: value for key, value in _raw_taxonomy.items() if not key.startswith("_")}
)
_SYSTEM_PROMPT = (
"You identify what a document is, choosing only from a fixed vocabulary you "
"are given. Decide along three axes:\n"
"- category: the document's structural family. Choose EXACTLY ONE category id.\n"
"- doc_type: the specific instrument within that family. Choose EXACTLY ONE "
"doc_type id listed under the category you chose.\n"
"- tags: zero or more descriptor ids from the tag list.\n"
"\n"
"Rules:\n"
"- Use only ids from the supplied vocabulary. If nothing fits, return "
f'"{UNKNOWN_LABEL}" for category and/or doc_type.\n'
"- The doc_type you pick must belong to the category you pick.\n"
"- type_confidence (0.0-1.0) is how sure you are that doc_type is correct.\n"
"- Judge from the document's content and structure, not from keywords alone. "
"The document may be in any language.\n"
"- You are shown only the first and last pages; that is enough to identify the type."
)
class _ClassifierOutput(ApiModel):
"""Raw model answer, before it is validated against the taxonomy."""
category: str = Field(description="A category id from the vocabulary, or 'unknown'.")
doc_type: str = Field(description="A doc_type id belonging to the chosen category, or 'unknown'.")
type_confidence: float = Field(ge=0.0, le=1.0, description="Confidence that doc_type is correct.")
tags: list[str] = Field(default_factory=list, description="Descriptor ids drawn from the tag list.")
def render_taxonomy(taxonomy: ClassificationTaxonomy) -> str:
"""Render the vocabulary for the prompt, ids first so the model echoes them."""
lines = ["Categories (id (label): doc_types as id (label)):"]
for category in taxonomy.categories:
types = ", ".join(f"{doc_type.id} ({doc_type.label})" for doc_type in category.doc_types) or "(none)"
lines.append(f"- {category.id} ({category.label}): {types}")
lines.append(f"Tags: {', '.join(taxonomy.tags) or '(none)'}")
return "\n".join(lines)
def select_window(pages: list[PageText], window: int = WINDOW_PAGES) -> list[PageText]:
"""Return the first and last ``window`` pages, never overlapping.
Documents short enough that the two ends would meet are returned whole. The
caller usually sends just the window already; this is a defensive trim in
case it sends more.
"""
if window <= 0 or len(pages) <= window * 2:
return list(pages)
return [*pages[:window], *pages[-window:]]
def format_window(pages: list[PageText]) -> str:
if not pages:
return "(no extractable text)"
return "\n\n".join(f"[Page {page.page_number}]\n{page.text}" for page in pages)
def validate_against_taxonomy(
output: _ClassifierOutput,
taxonomy: ClassificationTaxonomy,
) -> DocumentClassificationResponse:
"""Coerce a raw model answer onto the supplied vocabulary.
An off-list category collapses both axes to ``unknown``; a doc_type that
isn't a child of its (valid) category collapses the type alone. Either
collapse caps confidence. Tags are filtered to the known set, de-duplicated,
and returned in the model's order. The model identifies; these rules decide
what is allowed to stand.
"""
categories_by_id = {category.id.lower(): category for category in taxonomy.categories}
allowed_tags = {tag.lower(): tag for tag in taxonomy.tags}
kept_tags: list[str] = []
for tag in output.tags:
canonical = allowed_tags.get(tag.strip().lower())
if canonical is not None and canonical not in kept_tags:
kept_tags.append(canonical)
category = categories_by_id.get(output.category.strip().lower())
if category is None:
return DocumentClassificationResponse(
category=UNKNOWN_LABEL,
category_label=UNKNOWN_DISPLAY_LABEL,
doc_type=UNKNOWN_LABEL,
doc_type_label=UNKNOWN_DISPLAY_LABEL,
type_confidence=min(output.type_confidence, UNKNOWN_MAX_CONFIDENCE),
tags=kept_tags,
)
types_by_id = {doc_type.id.lower(): doc_type for doc_type in category.doc_types}
doc_type = types_by_id.get(output.doc_type.strip().lower())
if doc_type is None:
return DocumentClassificationResponse(
category=category.id,
category_label=category.label,
doc_type=UNKNOWN_LABEL,
doc_type_label=UNKNOWN_DISPLAY_LABEL,
type_confidence=min(output.type_confidence, UNKNOWN_MAX_CONFIDENCE),
tags=kept_tags,
)
return DocumentClassificationResponse(
category=category.id,
category_label=category.label,
doc_type=doc_type.id,
doc_type_label=doc_type.label,
type_confidence=output.type_confidence,
tags=kept_tags,
)
class DocumentClassifierAgent:
"""Identifies a document's category, type, and tags against a taxonomy.
Reads the bounded page window supplied on the request (first/last
``WINDOW_PAGES``) and runs a single fast-model pass, then validates the
answer against the vocabulary so nothing off-list survives.
"""
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self._agent = Agent(
model=runtime.fast_model,
output_type=NativeOutput(_ClassifierOutput),
system_prompt=_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
# Override point: a request-supplied taxonomy (e.g. a future per-org / DB
# vocabulary the backend resolves) wins; the generated default is the fallback.
taxonomy = request.taxonomy or DEFAULT_TAXONOMY
window = select_window(request.pages)
prompt = self._build_prompt(request.file_name, taxonomy, window)
logger.debug("[classify] prompt:\n%s", prompt)
result = await self._agent.run(prompt)
return validate_against_taxonomy(result.output, taxonomy)
@staticmethod
def _build_prompt(file_name: str, taxonomy: ClassificationTaxonomy, window: list[PageText]) -> str:
return (
f"{render_taxonomy(taxonomy)}\n\n"
f"Document file name: {file_name}\n"
f"Document content (first and last pages):\n{format_window(window)}"
)
+4
View File
@@ -10,6 +10,7 @@ from pydantic_ai import Agent
from pydantic_ai.models.instrumented import InstrumentationSettings
from stirling.agents import (
DocumentClassifierAgent,
ExecutionPlanningAgent,
OrchestratorAgent,
PdfEditAgent,
@@ -24,6 +25,7 @@ from stirling.api.middleware import UserIdMiddleware
from stirling.api.routes import (
agent_capabilities_router,
agent_draft_router,
document_classifier_router,
document_router,
execution_router,
ledger_router,
@@ -95,6 +97,7 @@ async def lifespan(fast_api: FastAPI):
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
fast_api.state.math_auditor_agent = MathAuditorAgent(runtime)
fast_api.state.pdf_comment_agent = PdfCommentAgent(runtime)
fast_api.state.document_classifier_agent = DocumentClassifierAgent(runtime)
tracer_provider = setup_posthog_tracking(settings)
if tracer_provider:
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
@@ -131,6 +134,7 @@ app.include_router(document_router, dependencies=_user_gate)
app.include_router(ledger_router, dependencies=_user_gate)
app.include_router(pdf_comments_router, dependencies=_user_gate)
app.include_router(agent_capabilities_router, dependencies=_user_gate)
app.include_router(document_classifier_router, dependencies=_user_gate)
@app.get("/health", response_model=HealthResponse)
+5
View File
@@ -5,6 +5,7 @@ from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from stirling.agents import (
DocumentClassifierAgent,
ExecutionPlanningAgent,
OrchestratorAgent,
PdfEditAgent,
@@ -55,6 +56,10 @@ def get_pdf_comment_agent(request: Request) -> PdfCommentAgent:
return request.app.state.pdf_comment_agent
def get_document_classifier_agent(request: Request) -> DocumentClassifierAgent:
return request.app.state.document_classifier_agent
def require_user_id() -> UserId:
"""FastAPI dependency for routes that touch per-user storage.
@@ -1,5 +1,6 @@
from .agent_capabilities import router as agent_capabilities_router
from .agent_drafts import router as agent_draft_router
from .document_classifier import router as document_classifier_router
from .documents import router as document_router
from .execution import router as execution_router
from .ledger import router as ledger_router
@@ -11,6 +12,7 @@ from .pdf_questions import router as pdf_question_router
__all__ = [
"agent_capabilities_router",
"agent_draft_router",
"document_classifier_router",
"document_router",
"execution_router",
"ledger_router",
@@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from stirling.agents import DocumentClassifierAgent
from stirling.api.dependencies import get_document_classifier_agent
from stirling.contracts import ClassifyDocumentRequest, ClassifyDocumentResponse
router = APIRouter(prefix="/api/v1/documents/classify", tags=["document-classifier"])
@router.post("", response_model=ClassifyDocumentResponse)
async def classify_document(
request: ClassifyDocumentRequest,
agent: Annotated[DocumentClassifierAgent, Depends(get_document_classifier_agent)],
) -> ClassifyDocumentResponse:
"""Classify a document from its supplied page text against the default taxonomy.
The caller sends the bounded page window inline, so no per-user document
storage is touched here — the request is self-contained.
"""
return await agent.classify(request)
+14
View File
@@ -36,6 +36,14 @@ from .contradiction import (
ContradictionReport,
ContradictionSeverity,
)
from .document_classifier import (
ClassificationTaxonomy,
ClassifyDocumentRequest,
ClassifyDocumentResponse,
DocumentCategory,
DocumentClassificationResponse,
DocumentType,
)
from .documents import (
DeleteDocumentResponse,
IngestDocumentRequest,
@@ -129,6 +137,9 @@ __all__ = [
"AiToolAgentStep",
"ArtifactKind",
"CannotContinueExecutionAction",
"ClassificationTaxonomy",
"ClassifyDocumentRequest",
"ClassifyDocumentResponse",
"Claim",
"CommentSpec",
"CompletedExecutionAction",
@@ -139,8 +150,11 @@ __all__ = [
"DeleteDocumentResponse",
"PurgeOwnerResponse",
"Discrepancy",
"DocumentCategory",
"DocumentClassificationResponse",
"DocumentMeta",
"DocumentSections",
"DocumentType",
"DiscrepancyKind",
"EditCannotDoResponse",
"EditClarificationRequest",
@@ -0,0 +1,73 @@
from __future__ import annotations
from pydantic import Field
from stirling.models import ApiModel
from .documents import PageText
class DocumentType(ApiModel):
"""A specific instrument within a category (e.g. ``nda`` inside ``contract``)."""
id: str = Field(min_length=1)
label: str = Field(min_length=1)
class DocumentCategory(ApiModel):
"""A structural family of documents, owning the doc_types shaped like it."""
id: str = Field(min_length=1)
label: str = Field(min_length=1)
doc_types: list[DocumentType] = Field(default_factory=list)
class ClassificationTaxonomy(ApiModel):
"""The vocabulary a document is classified against.
Supplied per request by the backend. When omitted, the engine falls back to
its small built-in default (see ``DEFAULT_TAXONOMY``). Tags are free-standing
descriptors that never own doc_types.
"""
categories: list[DocumentCategory] = Field(min_length=1)
tags: list[str] = Field(default_factory=list)
class ClassifyDocumentRequest(ApiModel):
"""Classify one document from its page text.
The caller sends the page text directly — typically just the bounded window
(first/last pages), since the classifier reads no more than that. There is no
ingestion or RAG step.
"""
file_name: str = Field(min_length=1)
pages: list[PageText] = Field(default_factory=list)
taxonomy: ClassificationTaxonomy | None = None
class DocumentClassificationResponse(ApiModel):
"""Terminal classification result.
``category`` and ``doc_type`` are ids drawn from the taxonomy (the internal
matching keys), or the sentinel ``"unknown"`` when the model's answer fell
outside it. ``category_label`` and ``doc_type_label`` are the human-readable
labels for those ids (what the UI shows); Python derives them from the
matched taxonomy entry so the two never drift. ``tags`` are the subset of the
model's tags that exist in the taxonomy. This is a plain answer from a
dedicated endpoint — it carries no ``outcome`` discriminator (it isn't one of
the orchestrator's WorkflowOutcome-routed union responses).
"""
category: str
category_label: str
doc_type: str
doc_type_label: str
type_confidence: float = Field(ge=0.0, le=1.0)
tags: list[str] = Field(default_factory=list)
# Only one response shape today; kept as a named alias so routes and agents have
# a stable response type to import.
ClassifyDocumentResponse = DocumentClassificationResponse
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from stirling.agents.document_classifier import (
DEFAULT_TAXONOMY,
UNKNOWN_LABEL,
UNKNOWN_MAX_CONFIDENCE,
DocumentClassifierAgent,
_ClassifierOutput,
render_taxonomy,
select_window,
validate_against_taxonomy,
)
from stirling.contracts import (
ClassificationTaxonomy,
ClassifyDocumentRequest,
DocumentCategory,
DocumentClassificationResponse,
PageText,
)
from stirling.services.runtime import AppRuntime
def _page(number: int, text: str = "x") -> PageText:
return PageText(page_number=number, text=text)
# ── select_window ───────────────────────────────────────────────────────────
def test_select_window_returns_short_documents_whole() -> None:
pages = [_page(1), _page(2), _page(3), _page(4)]
assert select_window(pages, window=2) == pages
def test_select_window_takes_both_ends_without_overlap() -> None:
pages = [_page(n) for n in range(1, 6)] # 5 pages
selected = select_window(pages, window=2)
assert [p.page_number for p in selected] == [1, 2, 4, 5]
def test_select_window_handles_empty() -> None:
assert select_window([], window=2) == []
def test_select_window_zero_returns_all() -> None:
pages = [_page(1), _page(2), _page(3)]
assert select_window(pages, window=0) == pages
# ── validate_against_taxonomy ────────────────────────────────────────────────
def test_valid_classification_is_preserved() -> None:
output = _ClassifierOutput(category="contract", doc_type="nda", type_confidence=0.95, tags=["legal", "signed"])
result = validate_against_taxonomy(output, DEFAULT_TAXONOMY)
assert isinstance(result, DocumentClassificationResponse)
assert result.category == "contract"
assert result.doc_type == "nda"
assert result.type_confidence == 0.95
assert result.tags == ["legal", "signed"]
def test_off_list_category_collapses_to_unknown_with_capped_confidence() -> None:
output = _ClassifierOutput(category="spaceship", doc_type="warp_core", type_confidence=0.99)
result = validate_against_taxonomy(output, DEFAULT_TAXONOMY)
assert result.category == UNKNOWN_LABEL
assert result.doc_type == UNKNOWN_LABEL
assert result.type_confidence == UNKNOWN_MAX_CONFIDENCE
def test_off_list_type_keeps_category_but_unknown_type() -> None:
output = _ClassifierOutput(category="contract", doc_type="invoice", type_confidence=0.9)
result = validate_against_taxonomy(output, DEFAULT_TAXONOMY)
assert result.category == "contract"
assert result.doc_type == UNKNOWN_LABEL
assert result.type_confidence == UNKNOWN_MAX_CONFIDENCE
def test_type_from_a_different_category_is_not_a_child() -> None:
# "lab_result" is a valid type, but only under medical_record, not contract.
output = _ClassifierOutput(category="contract", doc_type="lab_result", type_confidence=0.8)
result = validate_against_taxonomy(output, DEFAULT_TAXONOMY)
assert result.category == "contract"
assert result.doc_type == UNKNOWN_LABEL
def test_matching_is_case_insensitive_and_returns_canonical_ids() -> None:
output = _ClassifierOutput(category="Contract", doc_type="NDA", type_confidence=0.7, tags=["LEGAL"])
result = validate_against_taxonomy(output, DEFAULT_TAXONOMY)
assert result.category == "contract"
assert result.doc_type == "nda"
assert result.tags == ["legal"]
def test_unknown_tags_dropped_and_deduplicated_in_order() -> None:
output = _ClassifierOutput(
category="invoice",
doc_type="invoice",
type_confidence=0.9,
tags=["finance", "made-up", "finance", "legal"],
)
result = validate_against_taxonomy(output, DEFAULT_TAXONOMY)
assert result.tags == ["finance", "legal"]
def test_low_confidence_is_not_raised_when_collapsing() -> None:
output = _ClassifierOutput(category="nope", doc_type="nope", type_confidence=0.05)
result = validate_against_taxonomy(output, DEFAULT_TAXONOMY)
assert result.type_confidence == 0.05 # min(0.05, 0.2)
# ── render_taxonomy ──────────────────────────────────────────────────────────
def test_render_taxonomy_lists_ids_and_tags() -> None:
rendered = render_taxonomy(DEFAULT_TAXONOMY)
assert "contract" in rendered
assert "nda" in rendered
assert "finance" in rendered
def test_render_taxonomy_handles_category_without_types() -> None:
taxonomy = ClassificationTaxonomy(
categories=[DocumentCategory(id="memo", label="Memo", doc_types=[])],
tags=[],
)
rendered = render_taxonomy(taxonomy)
assert "(none)" in rendered
# ── DocumentClassifierAgent (inline page text) ───────────────────────────────
@pytest.mark.anyio
async def test_classify_validates_model_output_against_default_taxonomy(runtime: AppRuntime) -> None:
agent = DocumentClassifierAgent(runtime)
agent._agent.run = AsyncMock(
return_value=SimpleNamespace(
output=_ClassifierOutput(category="invoice", doc_type="invoice", type_confidence=0.97, tags=["finance"])
)
)
result = await agent.classify(
ClassifyDocumentRequest(
file_name="invoice.pdf",
pages=[PageText(page_number=1, text="Invoice INV-1 total due 100.00")],
)
)
assert isinstance(result, DocumentClassificationResponse)
assert result.category == "invoice"
assert result.doc_type == "invoice"
assert result.tags == ["finance"]
@pytest.mark.anyio
async def test_classify_collapses_off_list_model_answer(runtime: AppRuntime) -> None:
agent = DocumentClassifierAgent(runtime)
agent._agent.run = AsyncMock(
return_value=SimpleNamespace(
output=_ClassifierOutput(category="boarding_pass", doc_type="seat", type_confidence=0.9)
)
)
result = await agent.classify(
ClassifyDocumentRequest(file_name="weird.pdf", pages=[PageText(page_number=1, text="Some text")])
)
assert isinstance(result, DocumentClassificationResponse)
assert result.category == UNKNOWN_LABEL
assert result.doc_type == UNKNOWN_LABEL
assert result.type_confidence == UNKNOWN_MAX_CONFIDENCE
@@ -0,0 +1,73 @@
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from stirling.api import app
from stirling.api.dependencies import get_document_classifier_agent
from stirling.contracts import (
ClassifyDocumentRequest,
ClassifyDocumentResponse,
DocumentClassificationResponse,
)
class StubClassifierAgent:
"""Stands in for DocumentClassifierAgent so route tests don't call a model."""
def __init__(self, response: ClassifyDocumentResponse) -> None:
self._response = response
async def classify(self, _request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
return self._response
@pytest.fixture
def classification_client() -> Iterator[TestClient]:
app.dependency_overrides[get_document_classifier_agent] = lambda: StubClassifierAgent(
DocumentClassificationResponse(
category="contract",
category_label="Contract",
doc_type="nda",
doc_type_label="Non-disclosure agreement",
type_confidence=0.96,
tags=["legal", "signed"],
)
)
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_document_classifier_agent, None)
def test_classify_returns_camel_cased_result(classification_client: TestClient) -> None:
response = classification_client.post(
"/api/v1/documents/classify",
json={"fileName": "nda.pdf", "pages": [{"pageNumber": 1, "text": "Mutual NDA between A and B."}]},
)
assert response.status_code == 200
body = response.json()
assert body["category"] == "contract"
assert body["categoryLabel"] == "Contract"
assert body["docType"] == "nda"
assert body["docTypeLabel"] == "Non-disclosure agreement"
assert body["typeConfidence"] == 0.96
assert body["tags"] == ["legal", "signed"]
def test_classify_accepts_empty_pages(classification_client: TestClient) -> None:
response = classification_client.post(
"/api/v1/documents/classify",
json={"fileName": "blank.pdf", "pages": []},
)
assert response.status_code == 200
def test_classify_rejects_empty_file_name(classification_client: TestClient) -> None:
response = classification_client.post(
"/api/v1/documents/classify",
json={"fileName": "", "pages": []},
)
assert response.status_code == 422
@@ -3715,6 +3715,7 @@ backToFolder = "Back to {{folder}}"
backToMyFiles = "Back to My Files"
breadcrumbs = "Folder path"
cancel = "Cancel"
classification = "Classification"
clearSearch = "Clear search"
clearSelection = "Clear selection"
closeDetails = "Close details"
@@ -3872,12 +3873,14 @@ uploadFilesFailedDetail = "Could not upload files: {{message}}"
[filesPage.field]
added = "Added"
category = "Category"
confidence = "Confidence"
count = "Files"
folder = "Folder"
modified = "Modified"
name = "Name"
size = "Size"
toolHistory = "Tool history"
tags = "Tags"
toolHistoryAtVersion = "Cumulative tool chain"
totalSize = "Total size"
type = "Type"
@@ -5979,21 +5982,68 @@ ssn = "Social Security numbers"
[policies.sidebar]
activeCount = "{{count}} active"
infoAriaLabel = "What is a policy?"
infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
loading = "Loading…"
optionsAriaLabel = "Policy options"
policySettings = "Policy settings"
railAriaLabel = "{{label}} policy — {{status}}"
railSuffixActive = " (Active)"
railSuffixPaused = " (Paused)"
setUp = "Set up"
title = "Policies"
upgradeToEnterprise = "Upgrade to enterprise"
whatIsPolicy = "What is a policy?"
[policies.settings]
onExport = "On export"
onUpload = "On upload"
noneExport = "No policies currently run on export."
noneUpload = "No policies currently run on upload."
reorderHandle = "Drag to reorder"
runOrderDesc = "When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder."
title = "Policy settings"
[policies.status]
active = "Active"
paused = "Paused"
setup = "Set up"
[policies.taxonomy]
add = "Add"
addCategory = "Add category"
addSub = "Add sub-category"
addTag = "Add a tag"
addTagPlaceholder = "Add a tag"
categories = "categories"
categoryLabel = "Category"
collapse = "Collapse"
customNote = "Customized for your team."
defaultNote = "Using the built-in default, shared with your team."
edit = "Edit taxonomy"
emptyCategories = "No categories yet — add one to get started."
expand = "Expand"
export = "Export JSON"
id = "ID"
import = "Import JSON"
importError = "Couldn't import that file."
managedNote = "The taxonomy is managed by your team leader."
modalSubtitle = "Shared with your whole team. Categories, their sub-categories, and tags the classifier uses."
modalTitle = "Classification taxonomy"
noTags = "No tags yet."
removeCategory = "Remove category"
removeSub = "Remove sub-category"
removeTag = "Remove {{tag}}"
resetToDefault = "Reset to default"
saveForTeam = "Save for team"
saving = "Saving…"
sectionLabel = "Classification taxonomy"
startFromScratch = "Start from scratch"
subCategories = "sub-categories"
subCount = "{{count}} sub"
subLabel = "Sub-category"
tags = "Tags"
view = "View taxonomy"
[policies.toolConfig]
enableAriaLabel = "Enable {{tool}}"
infoAriaLabel = "What does {{tool}} do?"
@@ -0,0 +1,64 @@
/**
* Generates the engine's default classification taxonomy JSON from the type-safe
* TS source of truth (src/proprietary/data/classificationTaxonomy.ts).
*
* The Python engine can't import TypeScript, so it reads the generated JSON at
* startup. Editing the .ts and regenerating keeps the two in lockstep — the .ts
* is type-checked, so a malformed entry fails the build rather than shipping.
*
* Run: `npx tsx editor/scripts/generate-classification-taxonomy.mts` (writes the JSON)
* `npx tsx editor/scripts/generate-classification-taxonomy.mts --check` (CI drift guard)
*
* .mts (not .ts) so `import.meta.url` resolves paths relative to this script —
* Task invokes it from the workspace root (frontend/), same as setup-env.mts.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// frontend/package.json has no "type": "module", so tsx treats the source .ts as
// CommonJS. require() it (tsx hooks require for .ts) to read its named export
// reliably — a named ESM import can't see the export of a CJS-interpreted file.
const require = createRequire(import.meta.url);
const {
DEFAULT_CLASSIFICATION_TAXONOMY,
} = require("../src/proprietary/data/classificationTaxonomy");
const here = dirname(fileURLToPath(import.meta.url));
// editor/scripts -> repo root is three levels up (scripts -> editor -> frontend).
const repoRoot = resolve(here, "../../..");
const outPath = resolve(
repoRoot,
"engine/src/stirling/agents/default_classification_taxonomy.generated.json",
);
const NOTICE =
"AUTO-GENERATED from frontend/editor/src/proprietary/data/classificationTaxonomy.ts " +
"by editor/scripts/generate-classification-taxonomy.mts — do NOT edit by hand; run `task frontend:classifier-categories`.";
const json =
JSON.stringify(
{ _generated: NOTICE, ...DEFAULT_CLASSIFICATION_TAXONOMY },
null,
2,
) + "\n";
if (process.argv.includes("--check")) {
const current = existsSync(outPath) ? readFileSync(outPath, "utf8") : "";
if (current !== json) {
console.error(
"default_classification_taxonomy.generated.json is stale. Run `task frontend:classifier-categories` " +
"(npx tsx editor/scripts/generate-classification-taxonomy.mts).",
);
process.exit(1);
}
console.log("default_classification_taxonomy.generated.json is up to date.");
} else {
writeFileSync(outPath, json);
const categories = DEFAULT_CLASSIFICATION_TAXONOMY.categories.length;
const tags = DEFAULT_CLASSIFICATION_TAXONOMY.tags.length;
console.log(
`Wrote ${outPath}\n ${categories} categories, ${tags} loose tags`,
);
}
@@ -20,15 +20,75 @@ import {
downloadFileFromStorage,
downloadMultipleFiles,
} from "@app/utils/downloadUtils";
import ToolChain from "@app/components/shared/ToolChain";
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
import { fileStorage } from "@app/services/fileStorage";
import { extractPDFMetadata } from "@app/services/pdfMetadataService";
import {
VersionTimeline,
DetailField,
} from "@app/components/filesPage/VersionTimeline";
/** Custom PDF Info-dictionary key the classify-and-tag tool writes (must match
* the backend's PdfMetadataService.CLASSIFICATION_KEY). */
const CLASSIFICATION_KEY = "StirlingPDFClassification";
/** Reading classification means loading the file's bytes through PDF.js, so cap
* the auto-read by size — the app handles very large PDFs and we won't pull a
* multi-GB file into memory just to surface a metadata tag. */
const MAX_CLASSIFICATION_READ_BYTES = 25 * 1024 * 1024;
interface DocumentClassification {
category: string;
categoryLabel: string;
docType: string;
docTypeLabel: string;
typeConfidence?: number;
tags: string[];
}
/** Parse the classification JSON stored in PDF metadata; null if absent/invalid. */
function parseClassification(value: string): DocumentClassification | null {
try {
const raw = JSON.parse(value) as Record<string, unknown>;
const category = typeof raw.category === "string" ? raw.category : "";
const docType = typeof raw.docType === "string" ? raw.docType : "";
if (!category && !docType) return null;
// The classifier stores the human label alongside the id; older files
// predate it, so fall back to prettifying the id.
const categoryLabel =
typeof raw.categoryLabel === "string" && raw.categoryLabel
? raw.categoryLabel
: prettyLabel(category);
const docTypeLabel =
typeof raw.docTypeLabel === "string" && raw.docTypeLabel
? raw.docTypeLabel
: prettyLabel(docType);
return {
category,
categoryLabel,
docType,
docTypeLabel,
typeConfidence:
typeof raw.typeConfidence === "number" ? raw.typeConfidence : undefined,
tags: Array.isArray(raw.tags)
? raw.tags.filter((tag): tag is string => typeof tag === "string")
: [],
};
} catch {
return null;
}
}
/** "lab_result" / "lab-result" → "Lab result" — fallback for pre-label files. */
function prettyLabel(id: string): string {
return id
.split(/[_\-\s]+/)
.filter(Boolean)
.map((word) => word[0].toUpperCase() + word.slice(1))
.join(" ");
}
interface FileDetailsPanelProps {
selectedFileIds: FileId[];
fileMap: Map<FileId, StirlingFileStub>;
@@ -76,6 +136,12 @@ export function FileDetailsPanel({
// Metadata (size/type/dates) is collapsed by default so the panel stays
// short and the action buttons keep their pinned footer in view.
const [fieldsOpen, setFieldsOpen] = useState(false);
// Version journey is collapsed by default so the panel stays short.
const [versionsOpen, setVersionsOpen] = useState(false);
// Document classification read from PDF metadata, plus its (collapsed) section.
const [classification, setClassification] =
useState<DocumentClassification | null>(null);
const [classificationOpen, setClassificationOpen] = useState(false);
// Version chain for the selected file; empty for v1 or multi-select.
const [versionChain, setVersionChain] = useState<StirlingFileStub[]>([]);
const singleFileForChain = files.length === 1 ? files[0] : null;
@@ -101,6 +167,35 @@ export function FileDetailsPanel({
};
}, [singleFileForChain]);
// Read the classification the policy wrote into PDF metadata
useEffect(() => {
setClassification(null);
const stub = singleFileForChain;
if (!stub) return;
if (stub.type && !stub.type.toLowerCase().includes("pdf")) return;
if (stub.size > MAX_CLASSIFICATION_READ_BYTES) return;
let cancelled = false;
(async () => {
try {
const file = await fileStorage.getStirlingFile(stub.id);
if (cancelled || !file) return;
const result = await extractPDFMetadata(file);
if (cancelled || !result.success) return;
const entry = result.metadata.customMetadata.find(
(item) => item.key === CLASSIFICATION_KEY,
);
if (!entry) return;
const parsed = parseClassification(entry.value);
if (parsed && !cancelled) setClassification(parsed);
} catch (err) {
console.error("Failed to read classification metadata", err);
}
})();
return () => {
cancelled = true;
};
}, [singleFileForChain]);
if (files.length === 0) {
return null;
}
@@ -232,17 +327,74 @@ export function FileDetailsPanel({
/>
</div>
)}
{single.toolHistory && single.toolHistory.length > 0 && (
<div className="files-page-details-tool-history">
<div className="files-page-details-tool-history-label">
{t("filesPage.field.toolHistory", "Tool history")}
</div>
<ToolChain
toolChain={single.toolHistory}
displayStyle="badges"
size="xs"
/>
</div>
{classification && (
<>
<button
type="button"
className="files-page-details-collapse-toggle"
onClick={() => setClassificationOpen((o) => !o)}
aria-expanded={classificationOpen}
>
<span>{t("filesPage.classification", "Classification")}</span>
<KeyboardArrowDownIcon
className={`files-page-details-collapse-chevron${
classificationOpen ? " is-open" : ""
}`}
fontSize="small"
/>
</button>
{classificationOpen && (
<div className="files-page-details-fieldlist">
{classification.category && (
<DetailField
label={t("filesPage.field.category", "Category")}
value={classification.categoryLabel}
/>
)}
{classification.docType && (
<DetailField
label={t("filesPage.field.type", "Type")}
value={classification.docTypeLabel}
/>
)}
{classification.typeConfidence != null && (
<DetailField
label={t("filesPage.field.confidence", "Confidence")}
value={`${Math.round(
classification.typeConfidence * 100,
)}%`}
/>
)}
{classification.tags.length > 0 && (
<div className="files-page-details-field">
<span className="files-page-details-field-label">
{t("filesPage.field.tags", "Tags")}
</span>
<span
className="files-page-details-field-value"
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
justifyContent: "flex-end",
}}
>
{classification.tags.map((tag) => (
<Badge
key={tag}
size="xs"
variant="light"
color="orange"
>
{tag}
</Badge>
))}
</span>
</div>
)}
</div>
)}
</>
)}
{/* Version journey. Each tool run writes a new StirlingFile
with the same `originalFileId` and an incremented
@@ -266,12 +418,37 @@ export function FileDetailsPanel({
)}
</Button>
) : (
<VersionTimeline
chain={versionChain}
currentId={single.id}
onAddToWorkspace={onAddToWorkspace}
onRemove={onRemove}
/>
<>
<button
type="button"
className="files-page-details-collapse-toggle"
onClick={() => setVersionsOpen((o) => !o)}
aria-expanded={versionsOpen}
>
<span>
{t(
"filesPage.viewVersionHistory",
"Version journey ({{count}})",
{ count: versionChain.length },
)}
</span>
<KeyboardArrowDownIcon
className={`files-page-details-collapse-chevron${
versionsOpen ? " is-open" : ""
}`}
fontSize="small"
/>
</button>
{versionsOpen && (
<VersionTimeline
chain={versionChain}
currentId={single.id}
onAddToWorkspace={onAddToWorkspace}
onRemove={onRemove}
hideHeader
/>
)}
</>
))}
</>
) : (
@@ -55,6 +55,7 @@ export interface VersionTimelineProps {
currentId: FileId;
onAddToWorkspace: (fileIds: FileId[]) => void;
onRemove: (fileIds: FileId[]) => void;
hideHeader?: boolean;
}
/** Version timeline with per-row tool deltas and collapse-when-long. */
@@ -63,6 +64,7 @@ export function VersionTimeline({
currentId,
onAddToWorkspace,
onRemove,
hideHeader = false,
}: VersionTimelineProps) {
const { t } = useTranslation();
const [expandedIds, setExpandedIds] = useState<Set<FileId>>(new Set());
@@ -120,15 +122,17 @@ export function VersionTimeline({
return (
<div className="files-page-details-version-timeline">
<div className="files-page-details-version-timeline-label">
<HistoryIcon fontSize="small" />
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
<span className="files-page-details-version-timeline-count">
{t("filesPage.versionsCount", "{{count}} versions", {
count: ordered.length,
})}
</span>
</div>
{!hideHeader && (
<div className="files-page-details-version-timeline-label">
<HistoryIcon fontSize="small" />
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
<span className="files-page-details-version-timeline-count">
{t("filesPage.versionsCount", "{{count}} versions", {
count: ordered.length,
})}
</span>
</div>
)}
<ol className="files-page-details-version-timeline-list">
{rows.map((row, idx) => {
const isLast = idx === rows.length - 1;
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Menu, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
@@ -135,6 +135,8 @@ export interface FileItemFolderRef {
export interface FileItemPolicyRef {
id: string;
name: string;
/** Badge glyph — the policy's own icon; falls back to a shield. */
icon?: ReactNode;
/** CSS colour for the badge (matches the policy's accent). */
accentColor: string;
/** True only just after the policy was applied — drives the one-off glow, so
@@ -313,7 +315,9 @@ export function FileItem({
className="file-sidebar-policy-badge"
style={{ color: policy.accentColor }}
>
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
{policy.icon ?? (
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
)}
</span>
</Tooltip>
))}
@@ -267,10 +267,13 @@ function FileContextInner({
if (options?.selectFiles && stirlingFiles.length > 0) {
selectFiles(stirlingFiles);
}
if (stirlingFiles.length > 0) {
indexedDB?.bumpRevision?.();
}
return stirlingFiles;
},
[enablePersistence, requestConfirmation],
[enablePersistence, requestConfirmation, indexedDB],
);
const addFilesWithOptions = useCallback(
@@ -306,9 +309,13 @@ function FileContextInner({
selectFiles(stirlingFiles);
}
if (stirlingFiles.length > 0) {
indexedDB?.bumpRevision?.();
}
return stirlingFiles;
},
[enablePersistence],
[enablePersistence, indexedDB],
);
const addStirlingFileStubsAction = useCallback(
+13
View File
@@ -0,0 +1,13 @@
/**
* Turn a human label into a url/id-safe slug: lowercased, with every run of
* non-alphanumerics collapsed to a single hyphen and leading/trailing hyphens
* trimmed. May return an empty string (e.g. an all-symbol input); callers that
* need a non-empty id should supply their own fallback.
*/
export function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
@@ -162,6 +162,11 @@ interface AiWorkflowResultFile {
fileId: string;
fileName: string;
contentType: string;
/**
* Index into the files we sent that this output was derived from, or null/undefined when it has
* no single source (merge, generated file). Used to replace that input in place as a new version.
*/
sourceIndex?: number | null;
}
interface AiWorkflowResponse {
@@ -432,9 +437,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
// Import the files produced by an AI workflow result into FileContext.
//
// If the workflow produced the same number of outputs as inputs, map each output to its
// corresponding input as a new version in the same chain. Otherwise (merge, split, etc.)
// add the outputs as new root files.
// Each output carries a sourceIndex telling us which input it came from. An input that produced
// exactly one output is replaced in place as a new version of that file; everything else (merge,
// split, generated files, or an input that produced nothing) is added as a fresh root and leaves
// the original files untouched — we never remove or deselect a file the workflow didn't clearly
// transform 1:1.
const importResultFile = useCallback(
async (
result: AiWorkflowResponse,
@@ -456,27 +463,43 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const files = await Promise.all(descriptors.map(downloadFile));
if (sourceStubs.length > 0) {
// Always consume the inputs so merge/split inputs are removed from the workbench.
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
// merge/split they're fresh roots.
const operation: ToolOperation = {
toolId: "ai-workflow",
timestamp: Date.now(),
};
const isVersionMapping = files.length === sourceStubs.length;
const stubs = files.map((file, i) =>
isVersionMapping
? createChildStub(sourceStubs[i], operation, file)
: createNewStirlingFileStub(file),
);
// Resolve each output to the input it came from (sourceIndex, from the backend).
const sourceForOutput = descriptors.map((descriptor) => {
const idx = descriptor.sourceIndex;
return typeof idx === "number" && idx >= 0 && idx < sourceStubs.length
? sourceStubs[idx]
: null;
});
// Only replace a source in place when it maps to exactly one output (a clean 1:1 transform).
// A split (one input → many outputs) or a source shared by several outputs stays a set of
// fresh roots so we don't collapse them onto one version chain.
const outputsPerSource = new Map<StirlingFileStub["id"], number>();
for (const source of sourceForOutput) {
if (source) {
outputsPerSource.set(
source.id,
(outputsPerSource.get(source.id) ?? 0) + 1,
);
}
}
const consumedIds: StirlingFileStub["id"][] = [];
const stubs = files.map((file, i) => {
const source = sourceForOutput[i];
if (source && outputsPerSource.get(source.id) === 1) {
consumedIds.push(source.id);
return createChildStub(source, operation, file);
}
return createNewStirlingFileStub(file);
});
const stirlingFiles = files.map((file, i) =>
createStirlingFile(file, stubs[i].id),
);
await fileActions.consumeFiles(
sourceStubs.map((s) => s.id),
stirlingFiles,
stubs,
);
// Consume only the inputs we actually versioned; unrelated files are left in place.
await fileActions.consumeFiles(consumedIds, stirlingFiles, stubs);
} else {
// No inputs: pass raw files so addFiles assigns consistent IDs. Pre-assigning stub IDs
// here would cause a fileId mismatch in filesRef, making getFiles() clone the file
@@ -0,0 +1,179 @@
/**
* The Classification policy's taxonomy control, shown in its Edit-Settings view.
* Renders a compact summary (counts + category chips) with an Expand button that
* opens the fat {@link TaxonomyEditorModal}. Owns the editable draft and the
* load/save/reset/import/export wiring via {@link useClassificationTaxonomy}. The
* taxonomy is team-shared; only users who can configure policies may edit it.
*/
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Card } from "@shared/components/Card";
import { Button } from "@shared/components/Button";
import { Chip } from "@shared/components/Chip";
import { Banner } from "@shared/components/Banner";
import { useClassificationTaxonomy } from "@app/hooks/useClassificationTaxonomy";
import { TaxonomyEditorModal } from "@app/components/policies/TaxonomyEditorModal";
import {
downloadTaxonomy,
parseTaxonomyFile,
validateTaxonomy,
} from "@app/services/taxonomyFile";
import {
DEFAULT_CLASSIFICATION_TAXONOMY,
type ClassificationTaxonomy,
} from "@app/data/classificationTaxonomy";
import "@app/components/policies/TaxonomyEditor.css";
interface ClassificationTaxonomySectionProps {
canConfigure: boolean;
}
export function ClassificationTaxonomySection({
canConfigure,
}: ClassificationTaxonomySectionProps) {
const { t } = useTranslation();
const { taxonomy, isCustom, loading, saving, error, save } =
useClassificationTaxonomy(true);
const [draft, setDraft] = useState<ClassificationTaxonomy>(taxonomy);
const [open, setOpen] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
// Sync the draft to server truth whenever it changes (load / save / reset).
// Local edits don't change `taxonomy`, so this never clobbers them mid-edit.
useEffect(() => setDraft(taxonomy), [taxonomy]);
const dirty = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(taxonomy),
[draft, taxonomy],
);
const subCount = useMemo(
() => taxonomy.categories.reduce((n, c) => n + c.docTypes.length, 0),
[taxonomy],
);
const close = () => {
setDraft(taxonomy);
setLocalError(null);
setOpen(false);
};
const onImportFile = (file: File) => {
setLocalError(null);
void parseTaxonomyFile(file)
.then(setDraft)
.catch((e: unknown) =>
setLocalError(
e instanceof Error
? e.message
: t("policies.taxonomy.importError", "Couldn't import that file."),
),
);
};
const onSave = () => {
const errors = validateTaxonomy(draft);
if (errors.length > 0) {
setLocalError(errors[0]);
return;
}
setLocalError(null);
void save(draft).then(() => setOpen(false));
};
return (
<div className="tax-summary">
<p className="pol-section-label">
{t("policies.taxonomy.sectionLabel", "Classification taxonomy")}
</p>
<Card>
{loading ? (
<span className="tax-empty">{t("loading", "Loading…")}</span>
) : (
<div className="tax-summary">
<div className="tax-summary-stats">
<span>
<strong>{taxonomy.categories.length}</strong>{" "}
{t("policies.taxonomy.categories", "categories")}
</span>
<span>
<strong>{subCount}</strong>{" "}
{t("policies.taxonomy.subCategories", "sub-categories")}
</span>
<span>
<strong>{taxonomy.tags.length}</strong>{" "}
{t("policies.taxonomy.tags", "tags")}
</span>
</div>
<div className="tax-summary-cats">
{taxonomy.categories.map((c) => (
<Chip key={c.id} tone="neutral" size="sm">
{c.label}
</Chip>
))}
</div>
<span className="tax-empty">
{isCustom
? t("policies.taxonomy.customNote", "Customized for your team.")
: t(
"policies.taxonomy.defaultNote",
"Using the built-in default, shared with your team.",
)}
</span>
<Button
variant="outline"
size="sm"
leadingIcon={<OpenInFullIcon sx={{ fontSize: "1rem" }} />}
onClick={() => setOpen(true)}
style={{ alignSelf: "flex-start" }}
>
{canConfigure
? t("policies.taxonomy.edit", "Edit taxonomy")
: t("policies.taxonomy.view", "View taxonomy")}
</Button>
</div>
)}
</Card>
{!canConfigure && (
<Banner
tone="neutral"
icon={<LockOutlinedIcon sx={{ fontSize: "1rem" }} />}
description={t(
"policies.taxonomy.managedNote",
"The taxonomy is managed by your team leader.",
)}
/>
)}
<TaxonomyEditorModal
open={open}
onClose={close}
draft={draft}
onDraftChange={setDraft}
onImportFile={onImportFile}
onExport={() => downloadTaxonomy(draft)}
onReset={() => {
// Stage the built-in default into the draft — reversible via Cancel,
// only persisted on Save (no immediate destructive server delete).
setLocalError(null);
setDraft(DEFAULT_CLASSIFICATION_TAXONOMY);
}}
onClear={() => {
// Stage an empty taxonomy to build from scratch — also reversible until Save.
setLocalError(null);
setDraft({ categories: [], tags: [] });
}}
onSave={onSave}
dirty={dirty}
saving={saving}
readOnly={!canConfigure}
error={localError ?? error}
/>
</div>
);
}
@@ -128,6 +128,81 @@
flex-shrink: 0;
}
/* ── Policy settings: per-trigger run-order lists ── */
.pol-reorder-section {
margin-top: var(--space-3);
}
.pol-reorder-list {
display: flex;
flex-direction: column;
margin-top: var(--space-1);
}
/* A reorder row: leading grip + tinted icon + label. Square (no radius) so the
drop line reads as one straight rule across the list. */
.pol-reorder-row {
position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1_5) var(--space-2);
}
.pol-reorder-row[data-dragging] {
opacity: 0.4;
}
/* Straight, full-width blue insertion line at the drop position (no curves). */
.pol-reorder-row[data-drop="above"]::before,
.pol-reorder-row[data-drop="below"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--color-blue);
pointer-events: none;
}
.pol-reorder-row[data-drop="above"]::before {
top: -1px;
}
.pol-reorder-row[data-drop="below"]::after {
bottom: -1px;
}
.pol-reorder-grip {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.25rem;
color: var(--color-text-4);
cursor: grab;
}
.pol-reorder-grip:active {
cursor: grabbing;
}
.pol-reorder-label {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-1);
}
/* The drag ghost (a cloned row): the whole row with a full blue outline, kept
translucent so the list shows through as it moves. */
.pol-reorder-row--ghost {
border-radius: var(--radius-lg);
outline: 2px solid var(--color-blue);
outline-offset: -2px;
background: var(--color-surface);
box-shadow: var(--shadow-md);
opacity: 0.55;
}
/* Empty-state line for a trigger with no policies. */
.pol-reorder-empty {
margin: var(--space-1) 0 0;
padding: var(--space-1_5) var(--space-2);
font-size: 0.8125rem;
color: var(--color-text-3);
}
/* Retry button on a failed activity row. */
/* Expandable error text in the activity feed — long backend errors are clamped
and collapsed by default so they don't blow up the row. */
@@ -273,6 +348,36 @@
margin: 0 0 var(--space-2);
}
/* A section label rendered as a collapse toggle (Recent Activity): strip the
button chrome but keep the .pol-section-label typography, chevron pushed right. */
.pol-section-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
font-family: inherit;
}
.pol-section-chevron {
margin-left: auto;
color: var(--color-text-4);
transition: transform 0.15s ease;
}
.pol-section-chevron.is-open {
transform: rotate(180deg);
}
/* Recent-activity feed: cap to ~4.5 rows (and never more than ~45% of the
viewport) then scroll, so a long history doesn't push the stats footer away. */
.pol-activity-list {
max-height: min(22rem, 45vh);
overflow-y: auto;
}
/* Sub-section header inside a settings card (e.g. "Output filename"). The field
directly below it carries data-first so the borders don't double up. */
.pol-subhead {
@@ -12,10 +12,20 @@
* collapsed; clicking an icon selects the policy and expands the rail.
*/
import { useState, useEffect, useMemo, type ReactNode } from "react";
import {
useState,
useEffect,
useMemo,
type DragEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Menu } from "@mantine/core";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import LocalIcon from "@app/components/shared/LocalIcon";
import DragIndicatorRounded from "@mui/icons-material/DragIndicatorRounded";
import MoreHorizRounded from "@mui/icons-material/MoreHorizRounded";
import TuneRounded from "@mui/icons-material/TuneRounded";
import InfoOutlined from "@mui/icons-material/InfoOutlined";
import { usePolicies } from "@app/hooks/usePolicies";
import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -44,12 +54,15 @@ import { SectionHeader } from "@shared/components/SectionHeader";
import { PolicySetupWizard } from "@app/components/policies/PolicySetupWizard";
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
import { PolicyDeleteConfirmModal } from "@app/components/policies/PolicyDeleteConfirmModal";
import type { PolicyConfigResult } from "@app/types/policies";
import type { PolicyCategory, PolicyConfigResult } from "@app/types/policies";
import { PanelHeader } from "@shared/components/PanelHeader";
import {
usePolicySelection,
selectPolicy,
setPolicyDetailView,
closePolicy,
openPolicySettings,
closePolicySettings,
} from "@app/components/policies/policySelectionStore";
import "@app/components/policies/Policies.css";
@@ -95,8 +108,8 @@ function promptGuestSignup(): void {
* place of the tool list. False when the feature is off or nothing is selected.
*/
export function usePolicyDetailActive(): boolean {
const { selectedId } = usePolicySelection();
return POLICIES_ENABLED && selectedId != null;
const { selectedId, settingsOpen } = usePolicySelection();
return POLICIES_ENABLED && (selectedId != null || settingsOpen);
}
/** The collapsible policy list, rendered above the Tools section. */
@@ -146,6 +159,13 @@ export function PoliciesSection({
(c) => pol.policies[c.id]?.configured,
).length;
// Rows render in execution order (defaults to catalog order until reordered
// on the Policy settings page).
const displayCategories = [...visibleCategories].sort(
(a, b) =>
(pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0),
);
return (
<div className="pol-list">
<div className="pol-list-head">
@@ -159,36 +179,51 @@ export function PoliciesSection({
expanded={expanded}
onToggle={toggleExpanded}
/>
<AppTooltip
content={t(
"policies.sidebar.infoTooltip",
"A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps.",
)}
sidebarTooltip
pinOnClick
>
<button
type="button"
className="pol-info-btn"
aria-label={t(
"policies.sidebar.infoAriaLabel",
"What is a policy?",
<Menu position="bottom-end" width="13rem" withinPortal>
<Menu.Target>
<button
type="button"
className="pol-info-btn"
aria-label={t(
"policies.sidebar.optionsAriaLabel",
"Policy options",
)}
>
<MoreHorizRounded sx={{ fontSize: "1.25rem" }} />
</button>
</Menu.Target>
<Menu.Dropdown>
{/* Hovering surfaces the same explanation the info tooltip used to show. */}
<AppTooltip
content={t(
"policies.sidebar.infoTooltip",
"A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps.",
)}
position="left"
maxWidth="16rem"
>
<Menu.Item
leftSection={<InfoOutlined sx={{ fontSize: "1rem" }} />}
>
{t("policies.sidebar.whatIsPolicy", "What is a policy?")}
</Menu.Item>
</AppTooltip>
{pol.canConfigure && (
<Menu.Item
leftSection={<TuneRounded sx={{ fontSize: "1rem" }} />}
onClick={() => openPolicySettings()}
>
{t("policies.sidebar.policySettings", "Policy settings")}
</Menu.Item>
)}
>
<LocalIcon
icon="info-outline-rounded"
width="1.25rem"
height="1.25rem"
style={{ color: "var(--icon-files-color)" }}
/>
</button>
</AppTooltip>
</Menu.Dropdown>
</Menu>
</div>
{expanded && (
<>
<div className="pol-list-rows">
{visibleCategories.map((cat) => {
{displayCategories.map((cat) => {
if (cat.comingSoon) {
return (
<div key={cat.id} className="pol-row pol-row--soon">
@@ -258,11 +293,25 @@ export function PoliciesSection({
);
}
/**
* Takeover dispatcher: shows the policy-settings page (execution order), an open
* policy's detail, or nothing — whichever the selection store currently holds.
* The heavy per-policy hooks live in {@link PolicyOpenDetail}, so the settings
* page doesn't pay for (or trip over) them.
*/
export function PolicyDetailTakeover() {
const { selectedId, settingsOpen } = usePolicySelection();
if (!POLICIES_ENABLED) return null;
if (settingsOpen && selectedId == null) return <PolicySettingsPanel />;
if (selectedId == null) return null;
return <PolicyOpenDetail />;
}
/**
* The open-policy view — narrative detail, setup wizard, or edit-settings —
* which replaces the Tools area while a policy is selected.
*/
export function PolicyDetailTakeover() {
function PolicyOpenDetail() {
const { t } = useTranslation();
const pol = usePolicies();
const { categories, configs, sources, docTypes } = usePolicyCatalog();
@@ -459,6 +508,232 @@ export function PolicyDetailTakeover() {
);
}
/**
* The Policy settings takeover — reached from the section header's "…" menu.
* Each trigger (upload / export) gets its own run-order list, since a chain only
* spans policies that fire on the same trigger — reordering one never affects the
* other. Admin-only (the menu entry is gated on canConfigure).
*/
function PolicySettingsPanel() {
const { t } = useTranslation();
const pol = usePolicies();
const { categories } = usePolicyCatalog();
// Configured, live policies for a trigger, in execution order.
const inOrder = (trigger: "upload" | "export") =>
categories
.filter(
(c) =>
pol.policies[c.id]?.configured &&
!c.comingSoon &&
(pol.policies[c.id]?.runOn ?? "upload") === trigger,
)
.sort(
(a, b) =>
(pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0),
);
const uploadCats = inOrder("upload");
const exportCats = inOrder("export");
// Order is one global sort key, so persist both groups together (upload first)
// to keep each group's members contiguous — the auto-run chain reads relative
// order within a trigger.
const persist = (uploadIds: string[], exportIds: string[]) =>
pol.reorderPolicies([...uploadIds, ...exportIds]);
return (
<div className="pol-detail">
<PanelHeader
icon={<TuneRounded sx={{ fontSize: "1.1rem" }} />}
title={t("policies.settings.title", "Policy settings")}
onClose={() => closePolicySettings()}
closeLabel={t("policies.detail.close", "Close")}
/>
<div className="pol-scroll">
<p className="pol-desc">
{t(
"policies.settings.runOrderDesc",
"When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder.",
)}
</p>
{/* Both triggers are always shown so the run order for each is explicit,
with an empty note when a trigger has no policies. */}
<PolicyReorderSection
title={t("policies.settings.onUpload", "On upload")}
cats={uploadCats}
emptyText={t(
"policies.settings.noneUpload",
"No policies currently run on upload.",
)}
onReorder={(ids) =>
persist(
ids,
exportCats.map((c) => c.id),
)
}
/>
<PolicyReorderSection
title={t("policies.settings.onExport", "On export")}
cats={exportCats}
emptyText={t(
"policies.settings.noneExport",
"No policies currently run on export.",
)}
onReorder={(ids) =>
persist(
uploadCats.map((c) => c.id),
ids,
)
}
/>
</div>
</div>
);
}
/**
* One trigger's run-order list. Rows drag to reorder (only when there's more than
* one to order); the drag ghost is the whole row with a blue outline, and a
* straight blue line marks where the policy will land. Reorders in isolation and
* hands the new id order back to the parent to persist.
*/
function PolicyReorderSection({
title,
cats,
emptyText,
onReorder,
}: {
title: string;
cats: PolicyCategory[];
emptyText: string;
onReorder: (orderedIds: string[]) => void;
}) {
const { t } = useTranslation();
const [dragId, setDragId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
// Whether the drop would land after (vs before) the hovered row.
const [overBelow, setOverBelow] = useState(false);
const draggable = cats.length >= 2;
const clear = () => {
setDragId(null);
setOverId(null);
};
const handleDrop = (targetId: string) => {
if (!dragId || dragId === targetId) return clear();
const ids = cats.map((c) => c.id);
const from = ids.indexOf(dragId);
let to = ids.indexOf(targetId) + (overBelow ? 1 : 0);
if (from < 0 || to < 0) return clear();
ids.splice(from, 1);
if (from < to) to -= 1;
ids.splice(to, 0, dragId);
onReorder(ids);
clear();
};
// The native drag image would be just the grip under the cursor; instead snapshot
// the whole row (a styled clone) so the ghost that follows the mouse is the full
// row with a blue outline.
const startDrag = (e: DragEvent<HTMLSpanElement>, catId: string) => {
setDragId(catId);
e.dataTransfer.effectAllowed = "move";
const row = (e.currentTarget as HTMLElement).closest(".pol-reorder-row");
if (row instanceof HTMLElement) {
const clone = row.cloneNode(true) as HTMLElement;
clone.classList.add("pol-reorder-row--ghost");
clone.style.width = `${row.offsetWidth}px`;
clone.style.position = "fixed";
clone.style.top = "-1000px";
clone.style.left = "-1000px";
clone.style.pointerEvents = "none";
document.body.appendChild(clone);
e.dataTransfer.setDragImage(clone, 24, row.offsetHeight / 2);
window.setTimeout(() => clone.remove(), 0);
}
};
if (cats.length === 0) {
return (
<section className="pol-reorder-section">
<p className="pol-section-label">{title}</p>
<p className="pol-reorder-empty">{emptyText}</p>
</section>
);
}
return (
<section className="pol-reorder-section">
<p className="pol-section-label">{title}</p>
<div className="pol-reorder-list">
{cats.map((cat) => (
<div
key={cat.id}
className="pol-reorder-row"
data-dragging={dragId === cat.id || undefined}
data-drop={
draggable && overId === cat.id && dragId !== cat.id
? overBelow
? "below"
: "above"
: undefined
}
onDragOver={
draggable
? (e) => {
if (!dragId) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
setOverId(cat.id);
setOverBelow(e.clientY > rect.top + rect.height / 2);
}
: undefined
}
onDragLeave={
draggable
? () => setOverId((id) => (id === cat.id ? null : id))
: undefined
}
onDrop={
draggable
? (e) => {
e.preventDefault();
handleDrop(cat.id);
}
: undefined
}
>
{draggable && (
<span
className="pol-reorder-grip"
draggable
onDragStart={(e) => startDrag(e, cat.id)}
onDragEnd={clear}
role="button"
tabIndex={-1}
aria-label={t(
"policies.settings.reorderHandle",
"Drag to reorder",
)}
>
<DragIndicatorRounded sx={{ fontSize: "1rem" }} />
</span>
)}
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
{cat.icon}
</IconBadge>
<span className="pol-reorder-label">
{t(`policies.catalog.${cat.id}`, cat.label)}
</span>
</div>
))}
</div>
</section>
);
}
/**
* Collapsed-rail policy icons. Each tints blue when active and carries a small
* status dot (green active / amber paused). Clicking selects the policy and
@@ -7,6 +7,7 @@ import DescriptionIcon from "@mui/icons-material/Description";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import LockIcon from "@mui/icons-material/Lock";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import { PanelHeader } from "@shared/components/PanelHeader";
@@ -115,6 +116,7 @@ export function PolicyDetailPanel({
}: PolicyDetailPanelProps) {
const { t } = useTranslation();
const isPaused = status === "paused";
const [activityOpen, setActivityOpen] = useState(true);
// Real configured steps drive the flow; fall back to the preset's rule labels.
const enforceItems =
steps && steps.length > 0
@@ -181,70 +183,87 @@ export function PolicyDetailPanel({
{/* Recent Activity */}
<div>
<p className="pol-section-label">
{t("policies.detail.recentActivity", "Recent Activity")}
</p>
{activityItems.length > 0 ? (
<Card padding="none">
{activityItems.map((item, i) => (
<ListRow
key={item.runId ?? `${item.doc}-${item.time}`}
divider={i > 0}
leadingTone={
item.status === "flagged"
? "warning"
: item.status === "processing"
? "info"
: "success"
}
leading={
item.status === "flagged" ? (
<WarningAmberIcon sx={{ fontSize: "0.85rem" }} />
) : item.status === "processing" ? (
<AutorenewIcon
className="pol-spin"
sx={{ fontSize: "0.85rem" }}
/>
) : (
<CheckCircleIcon sx={{ fontSize: "0.85rem" }} />
)
}
title={item.doc}
description={
item.status === "flagged" ? (
<ActivityError message={item.action} t={t} />
) : (
item.action
)
}
meta={item.time}
trailing={
item.status === "flagged" && onRetry ? (
<Button
variant="ghost"
size="sm"
onClick={() => onRetry(item)}
>
{t("policies.detail.retry", "Retry")}
</Button>
) : undefined
}
<button
type="button"
className="pol-section-label pol-section-toggle"
onClick={() => setActivityOpen((o) => !o)}
aria-expanded={activityOpen}
>
<span>
{t("policies.detail.recentActivity", "Recent Activity")}
</span>
<KeyboardArrowDownIcon
className={`pol-section-chevron${activityOpen ? " is-open" : ""}`}
fontSize="small"
/>
</button>
{activityOpen &&
(activityItems.length > 0 ? (
<Card padding="none">
<div className="pol-activity-list">
{activityItems.map((item, i) => (
<ListRow
key={item.runId ?? `${item.doc}-${item.time}`}
divider={i > 0}
leadingTone={
item.status === "flagged"
? "warning"
: item.status === "processing"
? "info"
: "success"
}
leading={
item.status === "flagged" ? (
<WarningAmberIcon sx={{ fontSize: "0.85rem" }} />
) : item.status === "processing" ? (
<AutorenewIcon
className="pol-spin"
sx={{ fontSize: "0.85rem" }}
/>
) : (
<CheckCircleIcon sx={{ fontSize: "0.85rem" }} />
)
}
title={item.doc}
description={
item.status === "flagged" ? (
<ActivityError message={item.action} t={t} />
) : (
item.action
)
}
meta={item.time}
trailing={
item.status === "flagged" && onRetry ? (
<Button
variant="ghost"
size="sm"
onClick={() => onRetry(item)}
>
{t("policies.detail.retry", "Retry")}
</Button>
) : undefined
}
/>
))}
</div>
</Card>
) : (
<Card padding="default">
<EmptyState
size="compact"
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
title={t(
"policies.detail.noActivityTitle",
"No activity yet",
)}
description={t(
"policies.detail.noActivityDescription",
"Documents will appear here once this policy runs.",
)}
/>
))}
</Card>
) : (
<Card padding="default">
<EmptyState
size="compact"
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
title={t("policies.detail.noActivityTitle", "No activity yet")}
description={t(
"policies.detail.noActivityDescription",
"Documents will appear here once this policy runs.",
)}
/>
</Card>
)}
</Card>
))}
</div>
{/* Stats — one grouped card with divided columns, intentionally
@@ -36,6 +36,7 @@ import {
} from "@app/components/policies/PolicyWorkflowStep";
import { PolicyToolConfigStep } from "@app/components/policies/PolicyToolConfigStep";
import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
import { ClassificationTaxonomySection } from "@app/components/policies/ClassificationTaxonomySection";
// Sources are always "editor" for this release, so the Sources step is dropped
// from the flow (its panel code is kept below for when other sources return).
@@ -103,6 +104,9 @@ export function PolicySetupWizard({
// Preset (tool-chain) policies render the locked tool config as their Workflow
// step instead of the add/remove builder.
const toolChain = getPolicyToolChain(category.id);
// A single-tool chain has nothing to toggle/configure, so its config UI is
// hidden (kept mounted so the submit trigger still emits that one tool).
const singleToolChain = toolChain != null && toolChain.length === 1;
const { user } = useAuth();
const [step, setStep] = useState(1);
const [fieldValues, setFieldValues] = useState(() =>
@@ -337,22 +341,29 @@ export function PolicySetupWizard({
<div style={{ display: step === 1 ? undefined : "none" }}>
{toolChain ? (
<>
<p className="pol-desc">
{t(
"policies.wizard.toolChainDesc",
"Configure the tools this policy runs on each document.",
)}
</p>
<PolicyToolConfigStep
chainIds={toolChain}
initialOperations={
existingAutomation?.operations ?? config.defaultOperations
}
presetOperations={config.defaultOperations}
categoryLabel={category.label}
saveTriggerRef={workflowSave}
onComplete={handleToolConfigSaved}
/>
{/* Single-tool chains have nothing to configure — hide the prompt
and the toggle, but keep the step mounted (display:none) so the
final submit still emits that one tool. */}
{!singleToolChain && (
<p className="pol-desc">
{t(
"policies.wizard.toolChainDesc",
"Configure the tools this policy runs on each document.",
)}
</p>
)}
<div style={{ display: singleToolChain ? "none" : undefined }}>
<PolicyToolConfigStep
chainIds={toolChain}
initialOperations={
existingAutomation?.operations ?? config.defaultOperations
}
presetOperations={config.defaultOperations}
categoryLabel={category.label}
saveTriggerRef={workflowSave}
onComplete={handleToolConfigSaved}
/>
</div>
</>
) : (
<>
@@ -371,6 +382,12 @@ export function PolicySetupWizard({
/>
</>
)}
{/* The Classification policy owns the editable, team-shared taxonomy
(categories → sub-categories → tags) the classifier runs against.
Kept on the first step alongside the tool so it's not buried. */}
{category.id === "classification" && (
<ClassificationTaxonomySection canConfigure={canConfigure} />
)}
</div>
{step === 2 && (
@@ -0,0 +1,303 @@
.tax-editor {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* --- Category / sub-category table (grid rows, not a <table>). --- */
.tax-table {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface);
}
.tax-head,
.tax-row {
display: grid;
grid-template-columns: 1.5rem minmax(0, 1fr) minmax(3rem, 9rem) 1.75rem;
align-items: center;
gap: var(--space-2);
}
.tax-head {
padding: var(--space-2) var(--space-3);
background: var(--color-bg-muted);
border-bottom: 1px solid var(--border-subtle);
}
.tax-empty-row {
padding: var(--space-4, 1rem) var(--space-3);
text-align: center;
font-size: 0.82rem;
color: var(--color-text-4);
}
.tax-head-label,
.tax-head-id {
grid-column: auto;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-3);
}
.tax-head-label {
grid-column: 2;
}
.tax-head-id {
grid-column: 3;
}
.tax-group + .tax-group {
border-top: 1px solid var(--border-subtle);
}
.tax-row {
padding: var(--space-2) var(--space-3);
transition: background var(--motion-fast);
}
.tax-row-category {
background: var(--color-surface);
}
.tax-row-category:hover {
background: var(--color-bg-hover);
}
.tax-name {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.tax-name .sui-input,
.tax-name input {
min-width: 0;
width: 100%;
}
.tax-subcount {
flex: none;
font-size: 0.7rem;
color: var(--color-text-4);
white-space: nowrap;
}
.tax-label-text {
font-size: 0.85rem;
color: var(--color-text-1);
}
.tax-id-text {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.72rem;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Indented, guide-lined sub-category block. */
.tax-subs {
margin: 0 var(--space-3) var(--space-2) 1.6rem;
padding: var(--space-1) 0;
border-left: 2px solid var(--border-default);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
background: var(--color-bg-muted);
}
.tax-row-doctype {
grid-template-columns: minmax(0, 1fr) minmax(3rem, 9rem) 1.75rem;
padding-left: var(--space-3);
}
.tax-row-doctype:hover {
background: var(--color-bg-hover);
}
.tax-toggle,
.tax-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
border-radius: var(--radius-md);
padding: 2px;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.tax-toggle:hover,
.tax-icon-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.tax-icon-btn-danger:hover {
background: var(--color-red-light);
color: var(--color-red);
}
/* Sub-category delete buttons are quiet until you hover (or keyboard-focus) the
row; the parent category's delete stays visible without hover. */
.tax-row-doctype .tax-icon-btn {
opacity: 0;
transition:
opacity var(--motion-fast),
background var(--motion-fast),
color var(--motion-fast);
}
.tax-row-doctype:hover .tax-icon-btn,
.tax-row-doctype:focus-within .tax-icon-btn {
opacity: 1;
}
.tax-add-sub {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: var(--space-1) 0 var(--space-1) var(--space-3);
padding: 0.3rem 0;
border: none;
background: transparent;
color: var(--color-blue);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
}
.tax-add-sub:hover {
text-decoration: underline;
}
/* --- Tags --- */
.tax-tags {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tax-tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-1_5, 0.375rem);
}
.tax-tag {
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0.15rem 0.3rem 0.15rem 0.6rem;
border-radius: var(--radius-pill);
background: var(--color-bg-muted);
border: 1px solid var(--color-border);
font-size: 0.75rem;
color: var(--color-text-2);
}
.tax-tag-label {
line-height: 1.2;
}
.tax-tag-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.05rem;
height: 1.05rem;
border: none;
background: transparent;
color: var(--color-text-4);
border-radius: 50%;
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.tax-tag-remove:hover {
background: var(--color-red-light);
color: var(--color-red);
}
.tax-tag-add {
display: flex;
gap: var(--space-2);
align-items: center;
}
.tax-empty {
font-size: 0.8rem;
color: var(--color-text-4);
}
/* --- Fat modal: give the editor room beyond the sidebar's width. --- */
.tax-modal {
width: min(1100px, 94vw);
max-width: min(1100px, 94vw);
}
.tax-modal-body {
max-height: min(70vh, 640px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* --- Compact summary shown inline in the settings step. --- */
.tax-summary {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tax-summary-stats {
display: flex;
gap: var(--space-3);
font-size: 0.78rem;
color: var(--color-text-3);
}
.tax-summary-stats strong {
color: var(--color-text-1);
}
.tax-summary-cats {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
max-height: 6.5rem;
overflow-y: auto;
}
.tax-toolbar {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.tax-toolbar-spacer {
flex: 1 1 auto;
}
/* --- Modal footer: destructive actions left, Save/Cancel right. --- */
.tax-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
width: 100%;
flex-wrap: wrap;
}
.tax-footer-left,
.tax-footer-right {
display: flex;
align-items: center;
gap: var(--space-2);
}
@@ -0,0 +1,314 @@
/**
* Editable view of a classification taxonomy: a table of categories, each with a
* collapsible, indented list of sub-categories (doc types), plus the
* free-standing tags. Purely presentational — it renders a draft and emits a new
* draft on every edit; the owner decides when to persist. Ids are never edited
* directly: they're derived from the label (lowercased, spaces → hyphens) and
* shown read-only. `readOnly` renders the same layout without edit affordances.
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import CloseIcon from "@mui/icons-material/Close";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineOutlined";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { Input } from "@shared/components/Input";
import { Button } from "@shared/components/Button";
import { slugify } from "@app/utils/slug";
import type {
ClassificationTaxonomy,
DocumentCategory,
} from "@app/data/classificationTaxonomy";
import "@app/components/policies/TaxonomyEditor.css";
interface TaxonomyEditorProps {
value: ClassificationTaxonomy;
onChange: (next: ClassificationTaxonomy) => void;
readOnly?: boolean;
}
export function TaxonomyEditor({
value,
onChange,
readOnly = false,
}: TaxonomyEditorProps) {
const { t } = useTranslation();
// Track expansion by category index (stable while editing, since ids change
// as labels are typed and rows aren't reordered).
const [expanded, setExpanded] = useState<Set<number>>(new Set([0]));
const [newTag, setNewTag] = useState("");
const toggle = (index: number) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
const setCategories = (categories: DocumentCategory[]) =>
onChange({ ...value, categories });
const updateCategory = (index: number, patch: Partial<DocumentCategory>) =>
setCategories(
value.categories.map((c, i) => (i === index ? { ...c, ...patch } : c)),
);
const renameCategory = (index: number, label: string) =>
updateCategory(index, { label, id: slugify(label) });
const addCategory = () => {
setExpanded((prev) => new Set(prev).add(value.categories.length));
setCategories([...value.categories, { id: "", label: "", docTypes: [] }]);
};
const removeCategory = (index: number) =>
setCategories(value.categories.filter((_, i) => i !== index));
const renameDocType = (catIndex: number, docIndex: number, label: string) =>
updateCategory(catIndex, {
docTypes: value.categories[catIndex].docTypes.map((d, i) =>
i === docIndex ? { id: slugify(label), label } : d,
),
});
const addDocType = (catIndex: number) => {
setExpanded((prev) => new Set(prev).add(catIndex));
updateCategory(catIndex, {
docTypes: [...value.categories[catIndex].docTypes, { id: "", label: "" }],
});
};
const removeDocType = (catIndex: number, docIndex: number) =>
updateCategory(catIndex, {
docTypes: value.categories[catIndex].docTypes.filter(
(_, i) => i !== docIndex,
),
});
const addTag = () => {
const tag = slugify(newTag);
if (tag === "" || value.tags.includes(tag)) return;
onChange({ ...value, tags: [...value.tags, tag] });
setNewTag("");
};
const removeTag = (tag: string) =>
onChange({ ...value, tags: value.tags.filter((tg) => tg !== tag) });
return (
<div className="tax-editor">
<div className="tax-table" role="table">
<div className="tax-head" role="row">
<span className="tax-head-label">
{t("policies.taxonomy.categoryLabel", "Category")}
</span>
<span className="tax-head-id">{t("policies.taxonomy.id", "ID")}</span>
{!readOnly && <span className="tax-head-actions" aria-hidden />}
</div>
{value.categories.length === 0 && (
<div className="tax-empty-row">
{t(
"policies.taxonomy.emptyCategories",
"No categories yet — add one to get started.",
)}
</div>
)}
{value.categories.map((category, catIndex) => {
const isOpen = expanded.has(catIndex);
return (
<div className="tax-group" key={catIndex}>
<div className="tax-row tax-row-category" role="row">
<button
type="button"
className="tax-toggle"
onClick={() => toggle(catIndex)}
aria-expanded={isOpen}
aria-label={
isOpen
? t("policies.taxonomy.collapse", "Collapse")
: t("policies.taxonomy.expand", "Expand")
}
>
{isOpen ? (
<ExpandMoreIcon sx={{ fontSize: "1.15rem" }} />
) : (
<ChevronRightIcon sx={{ fontSize: "1.15rem" }} />
)}
</button>
<div className="tax-name">
{readOnly ? (
<span className="tax-label-text">{category.label}</span>
) : (
<Input
inputSize="sm"
value={category.label}
onChange={(e) => renameCategory(catIndex, e.target.value)}
placeholder={t(
"policies.taxonomy.categoryLabel",
"Category",
)}
aria-label={t(
"policies.taxonomy.categoryLabel",
"Category",
)}
/>
)}
<span className="tax-subcount">
{t("policies.taxonomy.subCount", "{{count}} sub", {
count: category.docTypes.length,
})}
</span>
</div>
<code className="tax-id-text">{category.id}</code>
{!readOnly && (
<button
type="button"
className="tax-icon-btn tax-icon-btn-danger"
onClick={() => removeCategory(catIndex)}
aria-label={t(
"policies.taxonomy.removeCategory",
"Remove category",
)}
>
<DeleteOutlineIcon sx={{ fontSize: "1.15rem" }} />
</button>
)}
</div>
{isOpen && (
<div className="tax-subs">
{category.docTypes.map((docType, docIndex) => (
<div
className="tax-row tax-row-doctype"
role="row"
key={docIndex}
>
<div className="tax-name">
{readOnly ? (
<span className="tax-label-text">
{docType.label}
</span>
) : (
<Input
inputSize="sm"
value={docType.label}
onChange={(e) =>
renameDocType(catIndex, docIndex, e.target.value)
}
placeholder={t(
"policies.taxonomy.subLabel",
"Sub-category",
)}
aria-label={t(
"policies.taxonomy.subLabel",
"Sub-category",
)}
/>
)}
</div>
<code className="tax-id-text">{docType.id}</code>
{!readOnly && (
<button
type="button"
className="tax-icon-btn tax-icon-btn-danger"
onClick={() => removeDocType(catIndex, docIndex)}
aria-label={t(
"policies.taxonomy.removeSub",
"Remove sub-category",
)}
>
<DeleteOutlineIcon sx={{ fontSize: "1.05rem" }} />
</button>
)}
</div>
))}
{!readOnly && (
<button
type="button"
className="tax-add-sub"
onClick={() => addDocType(catIndex)}
>
<AddIcon sx={{ fontSize: "1rem" }} />
{t("policies.taxonomy.addSub", "Add sub-category")}
</button>
)}
</div>
)}
</div>
);
})}
</div>
{!readOnly && (
<Button
variant="outline"
size="sm"
leadingIcon={<AddIcon sx={{ fontSize: "1rem" }} />}
onClick={addCategory}
>
{t("policies.taxonomy.addCategory", "Add category")}
</Button>
)}
<div className="tax-tags">
<p className="pol-section-label">
{t("policies.taxonomy.tags", "Tags")}
</p>
<div className="tax-tag-list">
{value.tags.length === 0 && (
<span className="tax-empty">
{t("policies.taxonomy.noTags", "No tags yet.")}
</span>
)}
{value.tags.map((tag) => (
<span className="tax-tag" key={tag}>
<span className="tax-tag-label">{tag}</span>
{!readOnly && (
<button
type="button"
className="tax-tag-remove"
onClick={() => removeTag(tag)}
aria-label={t(
"policies.taxonomy.removeTag",
"Remove {{tag}}",
{ tag },
)}
>
<CloseIcon sx={{ fontSize: "0.85rem" }} />
</button>
)}
</span>
))}
</div>
{!readOnly && (
<div className="tax-tag-add">
<Input
inputSize="sm"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
placeholder={t(
"policies.taxonomy.addTagPlaceholder",
"Add a tag",
)}
aria-label={t("policies.taxonomy.addTag", "Add a tag")}
/>
<Button variant="ghost" size="sm" onClick={addTag}>
{t("policies.taxonomy.add", "Add")}
</Button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,176 @@
/**
* Full-screen ("fat") editor for the classification taxonomy — the roomy view the
* sidebar's Expand button opens. Hosts the {@link TaxonomyEditor} table plus an
* Import/Export toolbar and a footer holding the destructive actions (reset /
* start-from-scratch) and the Save/Cancel buttons. Editing is staged: nothing is
* persisted until Save, which stays disabled until the draft actually changes.
* The draft is owned by the caller so the sidebar summary reflects saved changes.
*/
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import DeleteSweepOutlinedIcon from "@mui/icons-material/DeleteSweepOutlined";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { Modal } from "@shared/components/Modal";
import { Button } from "@shared/components/Button";
import { Banner } from "@shared/components/Banner";
import { TaxonomyEditor } from "@app/components/policies/TaxonomyEditor";
import type { ClassificationTaxonomy } from "@app/data/classificationTaxonomy";
interface TaxonomyEditorModalProps {
open: boolean;
onClose: () => void;
draft: ClassificationTaxonomy;
onDraftChange: (next: ClassificationTaxonomy) => void;
onImportFile: (file: File) => void;
onExport: () => void;
/** Stage the built-in default into the draft. */
onReset: () => void;
/** Stage an empty taxonomy into the draft (build from scratch). */
onClear: () => void;
onSave: () => void;
dirty: boolean;
saving: boolean;
readOnly: boolean;
/** Save/reset (server) or import (file) failure to surface, if any. */
error: string | null;
}
export function TaxonomyEditorModal({
open,
onClose,
draft,
onDraftChange,
onImportFile,
onExport,
onReset,
onClear,
onSave,
dirty,
saving,
readOnly,
error,
}: TaxonomyEditorModalProps) {
const { t } = useTranslation();
const fileInput = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so picking the same file twice still fires onChange.
e.target.value = "";
if (file) onImportFile(file);
};
return (
<Modal
open={open}
onClose={onClose}
width="xl"
className="tax-modal"
title={t("policies.taxonomy.modalTitle", "Classification taxonomy")}
subtitle={t(
"policies.taxonomy.modalSubtitle",
"Shared with your whole team. Categories, their sub-categories, and tags the classifier uses.",
)}
footer={
<div className="tax-footer">
<div className="tax-footer-left">
{!readOnly && (
<>
<Button
variant="ghost"
accent="red"
size="sm"
leadingIcon={
<DeleteSweepOutlinedIcon sx={{ fontSize: "1rem" }} />
}
onClick={onClear}
disabled={saving}
>
{t(
"policies.taxonomy.startFromScratch",
"Start from scratch",
)}
</Button>
<Button
variant="ghost"
accent="red"
size="sm"
leadingIcon={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
onClick={onReset}
disabled={saving}
>
{t("policies.taxonomy.resetToDefault", "Reset to default")}
</Button>
</>
)}
</div>
<div className="tax-footer-right">
<Button variant="ghost" size="sm" onClick={onClose}>
{readOnly ? t("close", "Close") : t("cancel", "Cancel")}
</Button>
{!readOnly && (
<Button
variant="gradient"
size="sm"
onClick={onSave}
disabled={!dirty || saving}
>
{saving
? t("policies.taxonomy.saving", "Saving…")
: t("policies.taxonomy.saveForTeam", "Save for team")}
</Button>
)}
</div>
</div>
}
>
<div className="tax-modal-body">
{error && (
<Banner
tone="danger"
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
description={error}
/>
)}
{!readOnly && (
<div className="tax-toolbar">
<Button
variant="outline"
size="sm"
leadingIcon={<FileUploadOutlinedIcon sx={{ fontSize: "1rem" }} />}
onClick={() => fileInput.current?.click()}
>
{t("policies.taxonomy.import", "Import JSON")}
</Button>
<Button
variant="outline"
size="sm"
leadingIcon={
<FileDownloadOutlinedIcon sx={{ fontSize: "1rem" }} />
}
onClick={onExport}
>
{t("policies.taxonomy.export", "Export JSON")}
</Button>
<input
ref={fileInput}
type="file"
accept="application/json,.json"
hidden
onChange={handleFile}
/>
</div>
)}
<TaxonomyEditor
value={draft}
onChange={onDraftChange}
readOnly={readOnly}
/>
</div>
</Modal>
);
}
@@ -15,9 +15,16 @@ import type { PolicyDetailView } from "@app/types/policies";
interface PolicySelection {
selectedId: string | null;
detailView: PolicyDetailView;
/** The policy-settings page (execution order) takes over the rail. Independent
* of {@link selectedId} — it's a section-level view, not tied to one policy. */
settingsOpen: boolean;
}
let state: PolicySelection = { selectedId: null, detailView: "detail" };
let state: PolicySelection = {
selectedId: null,
detailView: "detail",
settingsOpen: false,
};
const listeners = new Set<() => void>();
function emit() {
@@ -37,6 +44,7 @@ function getSnapshot(): PolicySelection {
const SERVER_SNAPSHOT: PolicySelection = {
selectedId: null,
detailView: "detail",
settingsOpen: false,
};
function getServerSnapshot(): PolicySelection {
return SERVER_SNAPSHOT;
@@ -44,7 +52,7 @@ function getServerSnapshot(): PolicySelection {
/** Open a policy's detail (resets the sub-view to the narrative). */
export function selectPolicy(id: string | null) {
state = { selectedId: id, detailView: "detail" };
state = { selectedId: id, detailView: "detail", settingsOpen: false };
emit();
}
@@ -55,6 +63,19 @@ export function setPolicyDetailView(view: PolicyDetailView) {
emit();
}
/** Open the policy-settings page (execution order). Clears any open policy. */
export function openPolicySettings() {
state = { selectedId: null, detailView: "detail", settingsOpen: true };
emit();
}
/** Close the policy-settings page and return to the list. */
export function closePolicySettings() {
if (!state.settingsOpen) return;
state = { ...state, settingsOpen: false };
emit();
}
/** Close the open policy and return to the list. */
export function closePolicy() {
selectPolicy(null);
@@ -62,7 +83,7 @@ export function closePolicy() {
/** Reset to the initial state — used by tests to isolate the module store. */
export function resetPolicySelection() {
state = { selectedId: null, detailView: "detail" };
state = { selectedId: null, detailView: "detail", settingsOpen: false };
emit();
}
@@ -22,6 +22,7 @@ export const STATUS_LABEL: Record<PolicyRowStatus, string> = {
*/
export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
ingestion: "blue",
classification: "orange",
security: "purple",
compliance: "green",
routing: "amber",
@@ -8,6 +8,9 @@ export const POLICY_TOOL_CHAINS: Record<string, string[]> = {
// Security: redact PII + watermark + sanitize (strips JS). Which are enabled
// by default comes from the preset's defaultOperations, not this list.
security: ["redact", "watermark", "sanitize"],
// Classification: a single backend step that classifies the document and
// writes the result into its metadata.
classification: ["classify"],
};
/** The configurable tool chain for a category, or null if it has none yet. */
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
// Two active upload policies, so the auto-run should CHAIN them: fire the first on
// the upload, then the second on the first's output. Stub the contexts + network so
// we can drive the dispatch against the REAL run store.
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
const fileStubs: { id: string; name: string; derivedFromTool?: boolean }[] = [];
vi.mock("@app/contexts/FileContext", () => ({
useAllFiles: () => ({ fileStubs }),
useFileManagement: () => ({ addFiles: vi.fn() }),
useFileContext: () => ({ consumeFiles: vi.fn() }),
}));
vi.mock("@app/hooks/usePolicies", () => ({
usePolicies: () => ({
policies: {
security: {
configured: true,
status: "active",
backendId: "backend-sec",
runOn: "upload",
order: 0,
},
classification: {
configured: true,
status: "active",
backendId: "backend-cls",
runOn: "upload",
order: 1,
},
},
}),
}));
vi.mock("@app/services/policyApi", () => ({
runStoredPolicy: vi.fn(),
getPolicyRun: vi.fn(),
downloadPolicyOutput: vi.fn(),
resolvePolicyRunTarget: () => "saas",
}));
vi.mock("@app/services/fileStorage", () => ({
fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() },
}));
vi.mock("@app/contexts/IndexedDBContext", () => ({
useIndexedDB: () => ({ bumpRevision: vi.fn() }),
}));
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
import {
recordRunStart,
updateRun,
resetPolicyRuns,
} from "@app/components/policies/policyRunStore";
import { runStoredPolicy } from "@app/services/policyApi";
import { fileStorage } from "@app/services/fileStorage";
const runStored = vi.mocked(runStoredPolicy);
const getFile = vi.mocked(fileStorage.getStirlingFile);
/** Reset the shared file list between tests without swapping the array identity. */
function setFileStubs(next: typeof fileStubs) {
fileStubs.length = 0;
fileStubs.push(...next);
}
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
resetPolicyRuns();
setFileStubs([]);
runStored.mockReset();
getFile.mockReset();
getFile.mockResolvedValue({ size: 100 } as never);
});
afterEach(() => vi.useRealTimers());
describe("auto-run ordered chaining", () => {
it("dispatches only the FIRST ordered policy on upload, not the whole set", async () => {
setFileStubs([{ id: "file-1", name: "doc.pdf" }]);
runStored.mockResolvedValue("run-sec");
renderHook(() => usePolicyAutoRun());
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
// The first policy (order 0) runs on the upload; the second waits for the chain.
expect(runStored).toHaveBeenCalledTimes(1);
expect(runStored).toHaveBeenCalledWith("backend-sec", [{ size: 100 }]);
});
it("chains the next policy onto a completed run's output", async () => {
// A first-policy run that has completed and imported its output as file-1-v2.
recordRunStart({
runId: "run-sec",
categoryId: "security",
fileId: "file-1",
fileName: "doc.pdf",
fileSize: 100,
target: "saas",
status: "PENDING",
outputs: [],
error: null,
startedAt: 0,
});
updateRun("run-sec", {
status: "COMPLETED",
imported: true,
outputFileIds: ["file-1-v2"],
});
runStored.mockResolvedValue("run-cls");
renderHook(() => usePolicyAutoRun());
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
// The next policy (order 1) fires on the first policy's output, not the original.
expect(runStored).toHaveBeenCalledWith("backend-cls", [{ size: 100 }]);
});
});
@@ -1,9 +1,13 @@
/**
* Auto-run controller: every enabled policy enforces on every uploaded
* file. Watches the session's files and, for each (active policy × not-yet-run
* file), fires a real backend run (`POST /api/v1/policies/{id}/run`) and polls it
* to completion, recording progress in {@link policyRunStore} for the activity
* feed.
* Auto-run controller: every enabled policy enforces on every uploaded file.
* Watches the session's files and fires a real backend run
* (`POST /api/v1/policies/{id}/run`) per file, polling it to completion and
* recording progress in {@link policyRunStore} for the activity feed.
*
* When several policies enforce on the same trigger they run as an ordered chain:
* the first fires on the upload, and each subsequent policy fires on the previous
* one's output once it lands — so their effects accumulate in the admin-defined
* order rather than racing to fork the same version.
*
* Headless — call it from {@link PolicyAutoRunController}, which is mounted once
* wherever the editor is open so enforcement happens regardless of whether the
@@ -11,7 +15,7 @@
* in the run store), so re-renders and remounts don't re-fire.
*/
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
useAllFiles,
useFileManagement,
@@ -141,6 +145,33 @@ export function usePolicyAutoRun(): void {
// run so a folder-watch burst opens the modal once, not once per file.
const firedLimitModal = useRef<Set<string>>(new Set());
// Active upload policies in execution order. When several enforce on upload they
// run as a chain — the first fires on the upload, each subsequent one on the
// previous policy's output — so their effects accumulate in a defined order
// instead of racing to fork the same version. Mirrors the dispatch filter
// (incl. the editor-source gate) so the chain honours the same eligibility.
const orderedUploadCategories = useMemo(
() =>
Object.entries(policies)
.filter(
([, s]) =>
s.configured &&
s.status === "active" &&
s.backendId &&
(!s.sources ||
s.sources.length === 0 ||
s.sources.includes("editor")) &&
(s.runOn ?? "upload") === "upload",
)
.sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0))
.map(([id]) => id),
[policies],
);
// Runs whose chain-continuation we've already handled this session, so the next
// policy is dispatched exactly once per completed run.
const chained = useRef<Set<string>>(new Set());
// Latest policies, read from inside the stable retry callback (which has no deps).
const policiesRef = useRef(policies);
policiesRef.current = policies;
@@ -204,50 +235,71 @@ export function usePolicyAutoRun(): void {
[scheduleQueueRetry],
);
// Dispatch: for each active policy × each session file not yet run, fire a run.
// Dispatch: fire only the FIRST upload policy on each not-yet-run file. The rest
// of the chain is dispatched by the chaining effect below, each on the previous
// policy's output, so the policies apply cumulatively in order.
useEffect(() => {
if (!POLICIES_ENABLED) return;
const active = Object.entries(policies).filter(
([, s]) =>
s.configured &&
s.status === "active" &&
s.backendId &&
// Only enforce in the editor when the policy includes "editor" as a source.
// runOn is an editor-specific parameter: "upload" fires here, "export" fires
// at export time via policyExport. Non-editor sources have their own triggers.
(!s.sources ||
s.sources.length === 0 ||
s.sources.includes("editor")) &&
(s.runOn ?? "upload") === "upload",
);
for (const [categoryId, s] of active) {
for (const stub of fileStubs) {
// Input-mode policies enforce only on files that actually entered the
// system as an upload — not on files a tool/automation produced in-app
// (versioned edits or independent artifacts like convert/split/merge).
// Those are enforced only by export-mode policies, at export time.
if (stub.derivedFromTool) continue;
const key = dispatchKey(categoryId, stub.id);
// Skip if already run (persisted) or a dispatch is in flight — the
// in-memory guard prevents double-firing during the async wait.
if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) {
continue;
}
dispatching.current.add(key);
void runPolicyOnFile(
categoryId,
s.backendId as string,
stub.id,
stub.name,
)
.catch(() => {
// runPolicyOnFile handles its own failures; this is just a backstop
// so an unexpected rejection never becomes an unhandled rejection.
})
.finally(() => dispatching.current.delete(key));
const firstCategory = orderedUploadCategories[0];
if (!firstCategory) return;
const backendId = policies[firstCategory]?.backendId;
if (!backendId) return;
for (const stub of fileStubs) {
// Input-mode policies enforce only on files that actually entered the
// system as an upload — not on files a tool/automation produced in-app
// (versioned edits or independent artifacts like convert/split/merge).
// Those are enforced only by export-mode policies, at export time.
if (stub.derivedFromTool) continue;
const key = dispatchKey(firstCategory, stub.id);
// Skip if already run (persisted) or a dispatch is in flight — the
// in-memory guard prevents double-firing during the async wait.
if (
isDispatched(firstCategory, stub.id) ||
dispatching.current.has(key)
) {
continue;
}
dispatching.current.add(key);
void runPolicyOnFile(firstCategory, backendId, stub.id, stub.name)
.catch(() => {
// runPolicyOnFile handles its own failures; this is just a backstop
// so an unexpected rejection never becomes an unhandled rejection.
})
.finally(() => dispatching.current.delete(key));
}
}, [fileStubs, policies]);
}, [fileStubs, policies, orderedUploadCategories]);
// Chain: once a run has completed AND its output landed in the workspace, fire the
// next upload policy on that output. Only chains on success (a failed run has no
// output), and only once per run. isDispatched guards re-dispatch across reloads.
useEffect(() => {
if (!POLICIES_ENABLED) return;
for (const run of runs) {
if (run.status !== "COMPLETED" || !run.imported) continue;
if (chained.current.has(run.runId)) continue;
const nextCategory = nextUploadCategory(
orderedUploadCategories,
run.categoryId,
);
const outputId = run.outputFileIds?.[0];
if (!nextCategory || !outputId) {
// End of the chain (or nothing to chain onto): don't revisit this run.
chained.current.add(run.runId);
continue;
}
const backendId = policies[nextCategory]?.backendId;
// Next policy not ready yet (still reconciling) — retry when policies change.
if (!backendId) continue;
chained.current.add(run.runId);
if (isDispatched(nextCategory, outputId as FileId)) continue;
void runPolicyOnFile(
nextCategory,
backendId,
outputId as FileId,
run.fileName,
).catch(() => {});
}
}, [runs, policies, orderedUploadCategories]);
// Poll each in-flight run to a terminal state.
useEffect(() => {
@@ -349,6 +401,17 @@ function applyOutputName(
return `${base}_${outputName}${ext}`;
}
/** The next upload policy after {@code categoryId} in the chain, or undefined if
* it's last or no longer in the ordered set (e.g. paused since it ran). */
function nextUploadCategory(
orderedUploadCategories: string[],
categoryId: string,
): string | undefined {
const index = orderedUploadCategories.indexOf(categoryId);
if (index < 0) return undefined;
return orderedUploadCategories[index + 1];
}
async function reconcileServerRuns(
policies: PoliciesByCategory,
): Promise<void> {
@@ -0,0 +1,302 @@
/**
* Document-classification vocabulary — the single, type-safe source of truth.
*
* The Python engine can't import TypeScript, so this is GENERATED into
* `engine/src/stirling/agents/default_classification_taxonomy.generated.json` by
* `editor/scripts/generate-classification-taxonomy.mts`
* (`task frontend:classifier-categories`, drift-guarded by
* `task frontend:classifier-categories:check`). Edit THIS file, never the
* generated JSON.
*
* Shape mirrors the engine's `ClassificationTaxonomy` contract; the camelCase
* keys here map onto that model's aliases.
*
* Override points for later (kept simple now, designed to drop in):
* - This is the built-in DEFAULT. A per-org / DB-configured taxonomy is meant to
* layer on top, not replace this file.
* - The engine already accepts a `taxonomy` per classify request; when one is
* supplied it wins, and this default is the fallback.
* - The backend `ClassifyTagController.resolveTaxonomyOverride()` is the seam to
* load the caller's org/DB taxonomy and pass it through; today it returns none.
*/
/** A specific instrument within a category — category-scoped (e.g. `nda` only
* under `contract`); the engine enforces it can't apply to another category. */
export interface DocumentType {
id: string;
label: string;
}
export interface DocumentCategory {
id: string;
label: string;
/** Category-scoped tags. */
docTypes: DocumentType[];
}
export interface ClassificationTaxonomy {
/** Ordered most-common → least-common. */
categories: DocumentCategory[];
/** Loose, cross-cutting tags that aren't tied to any single category. */
tags: string[];
}
export const DEFAULT_CLASSIFICATION_TAXONOMY: ClassificationTaxonomy = {
categories: [
{
id: "invoice",
label: "Invoice",
docTypes: [
{ id: "invoice", label: "Invoice" },
{ id: "receipt", label: "Receipt" },
{ id: "credit_note", label: "Credit note" },
{ id: "purchase_order", label: "Purchase order" },
{ id: "quote", label: "Quote" },
],
},
{
id: "contract",
label: "Contract",
docTypes: [
{ id: "nda", label: "Non-disclosure agreement" },
{ id: "employment_agreement", label: "Employment agreement" },
{ id: "service_agreement", label: "Service agreement" },
{ id: "lease_agreement", label: "Lease agreement" },
{ id: "master_service_agreement", label: "Master service agreement" },
{ id: "statement_of_work", label: "Statement of work" },
{ id: "terms_of_service", label: "Terms of service" },
],
},
{
id: "financial_statement",
label: "Financial statement",
docTypes: [
{ id: "balance_sheet", label: "Balance sheet" },
{ id: "income_statement", label: "Income statement" },
{ id: "cash_flow_statement", label: "Cash flow statement" },
{ id: "bank_statement", label: "Bank statement" },
{ id: "annual_report", label: "Annual report" },
],
},
{
id: "report",
label: "Report",
docTypes: [
{ id: "business_report", label: "Business report" },
{ id: "project_report", label: "Project report" },
{ id: "research_report", label: "Research report" },
{ id: "status_report", label: "Status report" },
{ id: "incident_report", label: "Incident report" },
],
},
{
id: "letter",
label: "Letter",
docTypes: [
{ id: "business_letter", label: "Business letter" },
{ id: "cover_letter", label: "Cover letter" },
{ id: "recommendation_letter", label: "Recommendation letter" },
{ id: "complaint_letter", label: "Complaint letter" },
{ id: "demand_letter", label: "Demand letter" },
],
},
{
id: "form",
label: "Form",
docTypes: [
{ id: "application_form", label: "Application form" },
{ id: "registration_form", label: "Registration form" },
{ id: "consent_form", label: "Consent form" },
{ id: "survey", label: "Survey" },
{ id: "questionnaire", label: "Questionnaire" },
],
},
{
id: "resume",
label: "Resume",
docTypes: [
{ id: "resume", label: "Resume" },
{ id: "curriculum_vitae", label: "Curriculum vitae" },
{ id: "portfolio", label: "Portfolio" },
{ id: "reference_sheet", label: "Reference sheet" },
],
},
{
id: "tax_form",
label: "Tax form",
docTypes: [
{ id: "tax_return", label: "Tax return" },
{ id: "w2", label: "W-2" },
{ id: "w9", label: "W-9" },
{ id: "form_1099", label: "Form 1099" },
{ id: "vat_return", label: "VAT return" },
],
},
{
id: "expense_report",
label: "Expense report",
docTypes: [
{ id: "expense_report", label: "Expense report" },
{ id: "reimbursement_request", label: "Reimbursement request" },
{ id: "mileage_log", label: "Mileage log" },
{ id: "per_diem_claim", label: "Per diem claim" },
],
},
{
id: "presentation",
label: "Presentation",
docTypes: [
{ id: "slide_deck", label: "Slide deck" },
{ id: "pitch_deck", label: "Pitch deck" },
{ id: "training_deck", label: "Training deck" },
{ id: "webinar_deck", label: "Webinar deck" },
],
},
{
id: "medical_record",
label: "Medical record",
docTypes: [
{ id: "lab_result", label: "Lab result" },
{ id: "prescription", label: "Prescription" },
{ id: "discharge_summary", label: "Discharge summary" },
{ id: "medical_history", label: "Medical history" },
{ id: "imaging_report", label: "Imaging report" },
{ id: "vaccination_record", label: "Vaccination record" },
],
},
{
id: "legal_filing",
label: "Legal filing",
docTypes: [
{ id: "court_filing", label: "Court filing" },
{ id: "complaint", label: "Complaint" },
{ id: "motion", label: "Motion" },
{ id: "subpoena", label: "Subpoena" },
{ id: "affidavit", label: "Affidavit" },
{ id: "deposition", label: "Deposition" },
],
},
{
id: "identity_document",
label: "Identity document",
docTypes: [
{ id: "passport", label: "Passport" },
{ id: "drivers_license", label: "Driver's license" },
{ id: "national_id", label: "National ID" },
{ id: "birth_certificate", label: "Birth certificate" },
{ id: "visa", label: "Visa" },
],
},
{
id: "insurance",
label: "Insurance",
docTypes: [
{ id: "insurance_policy", label: "Insurance policy" },
{ id: "insurance_claim", label: "Insurance claim" },
{ id: "certificate_of_insurance", label: "Certificate of insurance" },
{ id: "explanation_of_benefits", label: "Explanation of benefits" },
],
},
{
id: "real_estate",
label: "Real estate",
docTypes: [
{ id: "deed", label: "Deed" },
{ id: "mortgage_agreement", label: "Mortgage agreement" },
{ id: "property_appraisal", label: "Property appraisal" },
{ id: "closing_disclosure", label: "Closing disclosure" },
{ id: "title_report", label: "Title report" },
],
},
{
id: "shipping",
label: "Shipping",
docTypes: [
{ id: "bill_of_lading", label: "Bill of lading" },
{ id: "packing_slip", label: "Packing slip" },
{ id: "customs_declaration", label: "Customs declaration" },
{ id: "delivery_note", label: "Delivery note" },
{ id: "air_waybill", label: "Air waybill" },
],
},
{
id: "hr_document",
label: "HR document",
docTypes: [
{ id: "offer_letter", label: "Offer letter" },
{ id: "performance_review", label: "Performance review" },
{ id: "payslip", label: "Payslip" },
{ id: "employee_handbook", label: "Employee handbook" },
{ id: "termination_letter", label: "Termination letter" },
{ id: "timesheet", label: "Timesheet" },
],
},
{
id: "academic_record",
label: "Academic record",
docTypes: [
{ id: "transcript", label: "Transcript" },
{ id: "diploma", label: "Diploma" },
{ id: "certificate", label: "Certificate" },
{ id: "syllabus", label: "Syllabus" },
{ id: "thesis", label: "Thesis" },
{ id: "report_card", label: "Report card" },
],
},
{
id: "marketing_material",
label: "Marketing material",
docTypes: [
{ id: "brochure", label: "Brochure" },
{ id: "flyer", label: "Flyer" },
{ id: "case_study", label: "Case study" },
{ id: "white_paper", label: "White paper" },
{ id: "press_release", label: "Press release" },
],
},
{
id: "technical_document",
label: "Technical document",
docTypes: [
{ id: "user_manual", label: "User manual" },
{ id: "specification", label: "Specification" },
{ id: "api_documentation", label: "API documentation" },
{ id: "installation_guide", label: "Installation guide" },
{ id: "datasheet", label: "Datasheet" },
{ id: "release_notes", label: "Release notes" },
],
},
],
tags: [
"finance",
"legal",
"medical",
"hr",
"tax",
"insurance",
"marketing",
"technical",
"operations",
"academic",
"government",
"draft",
"final",
"signed",
"unsigned",
"executed",
"expired",
"amended",
"void",
"confidential",
"internal",
"public",
"pii",
"phi",
"certified",
"notarized",
"scanned",
"redacted",
"template",
"urgent",
],
};
@@ -16,6 +16,7 @@ import PublicIcon from "@mui/icons-material/Public";
import CloudIcon from "@mui/icons-material/Cloud";
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
import type {
PolicyCategory,
PolicyConfigDef,
@@ -42,6 +43,14 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
icon: <ShieldIcon sx={ICON_SX} />,
desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
},
{
id: "classification",
label: "Classification",
icon: <LabelOutlinedIcon sx={ICON_SX} />,
desc: "Identify each document's type on upload and tag its metadata for filing and search.",
// Needs the AI engine to classify; hidden from the policy list when it's off.
requiresAiEngine: true,
},
{
id: "compliance",
label: "Compliance",
@@ -204,6 +213,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
// output naming + retries are set in the wizard.
fields: [],
},
classification: {
summary:
"Classifies every uploaded document and writes the result to its metadata.",
rules: ["Classify", "Tag metadata"],
// Single backend step: classify the document via the AI engine and store the
// result in the document's StirlingPDFClassification metadata field.
defaultOperations: [{ operation: "classify", parameters: {} }],
scopeLabel: "All PDFs on this device",
fields: [],
},
compliance: {
summary:
"Validates documents against regulatory frameworks before they leave the system.",
@@ -0,0 +1,85 @@
/**
* Loads and persists the team's classification taxonomy. The backend
* (`/api/v1/classification/taxonomy`) is the source of truth and is shared by
* the whole team; a team with none falls back to the built-in default. Editing
* is gated to team leaders / admins by the backend — the caller passes
* `canConfigure` (the same policy gate) to keep read-only users out of the save
* path.
*/
import { useCallback, useEffect, useState } from "react";
import {
DEFAULT_CLASSIFICATION_TAXONOMY,
type ClassificationTaxonomy,
} from "@app/data/classificationTaxonomy";
import {
fetchTeamTaxonomy,
saveTeamTaxonomy,
} from "@app/services/taxonomyBackend";
export interface UseClassificationTaxonomy {
/** Server-truth taxonomy (or the built-in default when the team has none). */
taxonomy: ClassificationTaxonomy;
/** Whether the team has a stored taxonomy (vs. the built-in default). */
isCustom: boolean;
loading: boolean;
saving: boolean;
/** Last save failure, cleared on the next attempt. */
error: string | null;
/** Persist a taxonomy for the team; resolves once server state is updated. */
save: (next: ClassificationTaxonomy) => Promise<void>;
}
export function useClassificationTaxonomy(
enabled: boolean,
): UseClassificationTaxonomy {
const [taxonomy, setTaxonomy] = useState<ClassificationTaxonomy>(
DEFAULT_CLASSIFICATION_TAXONOMY,
);
const [isCustom, setIsCustom] = useState(false);
const [loading, setLoading] = useState(enabled);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setLoading(true);
void (async () => {
try {
const stored = await fetchTeamTaxonomy();
if (cancelled) return;
setTaxonomy(stored ?? DEFAULT_CLASSIFICATION_TAXONOMY);
setIsCustom(stored != null);
} catch {
// Backend down / not permitted — fall back to the default (read-only).
if (!cancelled) {
setTaxonomy(DEFAULT_CLASSIFICATION_TAXONOMY);
setIsCustom(false);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [enabled]);
const save = useCallback(async (next: ClassificationTaxonomy) => {
setSaving(true);
setError(null);
try {
const saved = await saveTeamTaxonomy(next);
setTaxonomy(saved);
setIsCustom(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Couldn't save the taxonomy.");
throw e;
} finally {
setSaving(false);
}
}, []);
return { taxonomy, isCustom, loading, saving, error, save };
}
@@ -14,6 +14,7 @@ import {
onPoliciesChange,
updatePolicy,
resetPolicy,
reorderPolicies as persistPolicyOrder,
} from "@app/services/policyStorage";
import { loadPolicyCatalog } from "@app/services/policyCatalog";
import {
@@ -90,7 +91,12 @@ export function usePolicies() {
const decoded = byCategory.get(cat.id);
reconciled[cat.id] = decoded
? decodedToState(decoded, local[cat.id]?.folderId)
: { ...local[cat.id], configured: false, status: "default" };
: {
...local[cat.id],
configured: false,
status: "default",
backendId: undefined,
};
}
for (const [id, state] of Object.entries(reconciled)) {
updatePolicy(id, state);
@@ -245,14 +251,42 @@ export function usePolicies() {
const pausePolicy = useCallback(async (id: string) => {
const current = loadPolicies()[id];
if (current?.backendId) await setPolicyEnabled(current.backendId, false);
if (current?.backendId) {
await setPolicyEnabled(current.backendId, false).catch((err: unknown) => {
if (
(err as { response?: { status?: number } })?.response?.status === 404
) {
updatePolicy(id, {
backendId: undefined,
configured: false,
status: "default",
});
return;
}
throw err;
});
}
if (current?.folderId) await setPolicyFolderPaused(current.folderId, true);
updatePolicy(id, { status: "paused" });
}, []);
const resumePolicy = useCallback(async (id: string) => {
const current = loadPolicies()[id];
if (current?.backendId) await setPolicyEnabled(current.backendId, true);
if (current?.backendId) {
await setPolicyEnabled(current.backendId, true).catch((err: unknown) => {
if (
(err as { response?: { status?: number } })?.response?.status === 404
) {
updatePolicy(id, {
backendId: undefined,
configured: false,
status: "default",
});
return;
}
throw err;
});
}
if (current?.folderId) await setPolicyFolderPaused(current.folderId, false);
updatePolicy(id, { status: "active" });
}, []);
@@ -264,6 +298,15 @@ export function usePolicies() {
resetPolicy(id);
}, []);
/**
* Persist a new execution order for the given categories (in the sequence
* provided). Local-only: order drives client-side chained dispatch, so there's
* no backend round-trip. The change event re-renders every policies consumer.
*/
const reorderPolicies = useCallback((orderedCategoryIds: string[]) => {
persistPolicyOrder(orderedCategoryIds);
}, []);
/**
* Ensure a configured policy has a *valid* backing folder (its editable
* pipeline) and return its id. Self-heals a stale `folderId` — one that no
@@ -315,6 +358,7 @@ export function usePolicies() {
pausePolicy,
resumePolicy,
deletePolicy,
reorderPolicies,
ensurePolicyFolder,
};
}
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled";
import {
loadPolicyCatalog,
type PolicyCatalog,
@@ -10,7 +11,20 @@ import {
* directly. Memoised; when the catalog becomes a backend fetch, this hook is
* where loading/error state would be introduced — its consumers already treat
* it as the single source of definitions.
*
* Categories flagged {@link PolicyCategory.requiresAiEngine} are hidden while the
* AI engine is off, so a policy only appears where it can actually run.
*/
export function usePolicyCatalog(): PolicyCatalog {
return useMemo(() => loadPolicyCatalog(), []);
const aiEngineEnabled = useAiEngineEnabled();
return useMemo(() => {
const catalog = loadPolicyCatalog();
if (aiEngineEnabled) return catalog;
return {
...catalog,
categories: catalog.categories.filter(
(category) => !category.requiresAiEngine,
),
};
}, [aiEngineEnabled]);
}
@@ -1,4 +1,10 @@
import { useMemo } from "react";
import {
useMemo,
cloneElement,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";
import { usePolicyRuns } from "@app/components/policies/policyRunStore";
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
import { useAllFiles } from "@app/contexts/FileContext";
@@ -18,8 +24,22 @@ const ACCENT_VAR: Record<string, string> = {
green: "var(--color-green)",
amber: "var(--color-amber)",
red: "var(--color-red)",
orange: "var(--color-orange)",
};
/** Glyph size for the file-sidebar policy badge. */
const BADGE_ICON_SIZE = "0.7rem";
/** Reuse a policy category's own icon at badge size,
* so each badge reflects its policy */
function toBadgeIcon(icon: ReactNode): ReactNode {
return isValidElement(icon)
? cloneElement(icon as ReactElement<{ sx?: object }>, {
sx: { fontSize: BADGE_ICON_SIZE },
})
: icon;
}
/** Minimal provenance shape needed to resolve a file's inherited badges. */
type LineageStub = {
id: string;
@@ -58,6 +78,7 @@ export function buildPolicyBadgeMap(
stubs: ReadonlyArray<LineageStub>,
labelById: ReadonlyMap<string, string>,
now: number,
iconById?: ReadonlyMap<string, ReactNode>,
): Map<string, FileItemPolicyRef[]> {
// Direct badges: a file that IS a policy run's output.
const directByFile = new Map<string, FileItemPolicyRef[]>();
@@ -71,6 +92,7 @@ export function buildPolicyBadgeMap(
list.push({
id: run.categoryId,
name,
icon: iconById?.get(run.categoryId),
accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"],
recent,
});
@@ -121,9 +143,17 @@ export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
const runs = usePolicyRuns();
const { fileStubs } = useAllFiles();
return useMemo(() => {
const labelById = new Map(
loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
const categories = loadPolicyCatalog().categories;
const labelById = new Map(categories.map((c) => [c.id, c.label]));
const iconById = new Map<string, ReactNode>(
categories.map((c) => [c.id, toBadgeIcon(c.icon)]),
);
return buildPolicyBadgeMap(
runs,
fileStubs,
labelById,
Date.now(),
iconById,
);
return buildPolicyBadgeMap(runs, fileStubs, labelById, Date.now());
}, [runs, fileStubs]);
}
@@ -12,6 +12,7 @@ import {
} from "@app/contexts/NavigationContext";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
import { slugify } from "@app/utils/slug";
// Inlined to avoid circular imports — must match WatchedFoldersRegistration.tsx
const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
@@ -20,13 +21,7 @@ const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder";
const WATCHED_FOLDERS_BASE = "/watch-folders";
export function slugifyFolderName(name: string): string {
return (
name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "folder"
);
return slugify(name) || "folder";
}
function parseWatchedFolderRoute(): {
@@ -101,6 +101,16 @@ export interface PolicyRunView {
createdAt: number;
}
/**
* Operations that run as policy pipeline steps but are NOT user-facing tools, so
* they have no tool-registry entry and never appear in the tool picker. Maps the
* operation id straight to its backend endpoint.
*/
const POLICY_OPERATION_ENDPOINTS: Record<string, string> = {
// Document classification — dispatched only by the Classification policy.
classify: "/api/v1/ai/tools/classify-and-tag",
};
/** Resolve a frontend operation id to its backend tool endpoint path. */
function resolveEndpoint(
operation: string,
@@ -109,10 +119,13 @@ function resolveEndpoint(
): string | null {
const config = toolRegistry[operation as keyof ToolRegistry]?.operationConfig;
const endpoint = config?.endpoint;
if (!endpoint) return null;
const resolved =
typeof endpoint === "function" ? endpoint(parameters) : endpoint;
return resolved ?? null;
if (endpoint) {
const resolved =
typeof endpoint === "function" ? endpoint(parameters) : endpoint;
if (resolved) return resolved;
}
// Policy-only operations have no registry entry; resolve them directly.
return POLICY_OPERATION_ENDPOINTS[operation] ?? null;
}
/**
@@ -53,14 +53,17 @@ export function loadPolicies(): PoliciesByCategory {
// Always reconcile against the current category list so a newly-added
// category gets a default rather than being undefined.
const out: PoliciesByCategory = {};
for (const cat of loadPolicyCatalog().categories) {
loadPolicyCatalog().categories.forEach((cat, index) => {
const merged = { ...defaultState(), ...(parsed[cat.id] ?? {}) };
// Migration: clear the obsolete persisted reviewer email so it re-defaults
// to the real signed-in user.
if (merged.reviewerEmail === STALE_REVIEWER_EMAIL)
merged.reviewerEmail = "";
// Default execution order to the catalog position until an admin reorders,
// so ordered dispatch is deterministic before any explicit order is set.
if (merged.order == null) merged.order = index;
out[cat.id] = merged;
}
});
return out;
}
@@ -97,6 +100,24 @@ export function updatePolicy(
return next;
}
/**
* Persist a new execution order. Assigns `order` 0..n-1 to the given categories in
* the sequence provided, so after any reorder every listed policy has an explicit,
* contiguous order (no reliance on the catalog-index default). Categories omitted
* from the list keep their current order.
*/
export function reorderPolicies(
orderedCategoryIds: string[],
): PoliciesByCategory {
const current = loadPolicies();
const next: PoliciesByCategory = { ...current };
orderedCategoryIds.forEach((id, index) => {
if (next[id]) next[id] = { ...next[id], order: index };
});
persist(next);
return next;
}
/** Reset a category to its unconfigured default (the "Delete policy" action). */
export function resetPolicy(categoryId: string): PoliciesByCategory {
return updatePolicy(categoryId, {
@@ -0,0 +1,31 @@
/**
* Backend layer for the team-scoped classification taxonomy
* (`/api/v1/classification/taxonomy`). The backend is the source of truth: the
* whole team shares one taxonomy, editable only by a team leader (SaaS) / admin
* (self-hosted). A team with no stored taxonomy reads as 204 → `null`, and
* callers fall back to the built-in {@link DEFAULT_CLASSIFICATION_TAXONOMY}.
*/
import apiClient from "@app/services/apiClient";
import type { ClassificationTaxonomy } from "@app/data/classificationTaxonomy";
const ENDPOINT = "/api/v1/classification/taxonomy";
/** The team's stored taxonomy, or `null` when it has none (use the default). */
export async function fetchTeamTaxonomy(): Promise<ClassificationTaxonomy | null> {
const res = await apiClient.get<ClassificationTaxonomy | "">(ENDPOINT, {
suppressErrorToast: true,
});
// 204 No Content (no stored taxonomy) comes back as an empty body. Only an
// explicit 204 / empty string means "none"; anything else is a real payload.
if (res.status === 204 || res.data === "") return null;
return res.data as ClassificationTaxonomy;
}
/** Persist the team's taxonomy; returns the stored value. */
export async function saveTeamTaxonomy(
taxonomy: ClassificationTaxonomy,
): Promise<ClassificationTaxonomy> {
const res = await apiClient.put<ClassificationTaxonomy>(ENDPOINT, taxonomy);
return res.data;
}
@@ -0,0 +1,145 @@
/**
* Client-side import/export + validation for a classification taxonomy JSON file.
* Sharing a taxonomy between teams is done by exporting the JSON here and
* importing it on another team. Validation mirrors the backend
* (`TaxonomyValidator`) so a malformed file is caught before it's uploaded — the
* backend re-validates as the authority.
*/
import { downloadJsonAsFile } from "@app/utils/downloadUtils";
import type {
ClassificationTaxonomy,
DocumentCategory,
DocumentType,
} from "@app/data/classificationTaxonomy";
// Kept in sync with the backend TaxonomyValidator (the authority); enforced here
// too so an oversized import is rejected before upload.
const MAX_CATEGORIES = 200;
const MAX_DOC_TYPES_PER_CATEGORY = 200;
const MAX_TAGS = 500;
const MAX_TEXT_LENGTH = 128;
/** Human-readable problems with a candidate taxonomy; empty means valid. */
export function validateTaxonomy(value: unknown): string[] {
const errors: string[] = [];
if (typeof value !== "object" || value === null) {
return ["File is not a taxonomy object."];
}
const taxonomy = value as Partial<ClassificationTaxonomy>;
if (!Array.isArray(taxonomy.categories) || taxonomy.categories.length === 0) {
errors.push("Taxonomy must have at least one category.");
return errors;
}
if (taxonomy.categories.length > MAX_CATEGORIES) {
errors.push(`Too many categories (max ${MAX_CATEGORIES}).`);
}
if (Array.isArray(taxonomy.tags) && taxonomy.tags.length > MAX_TAGS) {
errors.push(`Too many tags (max ${MAX_TAGS}).`);
}
const categoryIds = new Set<string>();
for (const category of taxonomy.categories) {
if (!isText(category?.id) || !isText(category?.label)) {
errors.push("Every category needs a non-empty id and label.");
continue;
}
if (!withinLength(category.id) || !withinLength(category.label)) {
errors.push(
`Category "${category.label}" has text over ${MAX_TEXT_LENGTH} characters.`,
);
}
if ((category.docTypes ?? []).length > MAX_DOC_TYPES_PER_CATEGORY) {
errors.push(
`Too many sub-categories in "${category.label}" (max ${MAX_DOC_TYPES_PER_CATEGORY}).`,
);
}
if (categoryIds.has(category.id)) {
errors.push(`Duplicate category id: ${category.id}`);
}
categoryIds.add(category.id);
const docTypeIds = new Set<string>();
for (const docType of category.docTypes ?? []) {
if (!isText(docType?.id) || !isText(docType?.label)) {
errors.push(
`Every sub-category in "${category.label}" needs an id and label.`,
);
continue;
}
if (!withinLength(docType.id) || !withinLength(docType.label)) {
errors.push(
`Sub-category "${docType.label}" has text over ${MAX_TEXT_LENGTH} characters.`,
);
}
if (docTypeIds.has(docType.id)) {
errors.push(
`Duplicate sub-category id "${docType.id}" in "${category.label}".`,
);
}
docTypeIds.add(docType.id);
}
}
if (taxonomy.tags !== undefined) {
if (!Array.isArray(taxonomy.tags)) {
errors.push("Tags must be a list.");
} else {
const tags = new Set<string>();
for (const tag of taxonomy.tags) {
if (!isText(tag)) errors.push("Tags must be non-empty text.");
else if (!withinLength(tag))
errors.push(`Tag "${tag}" is over ${MAX_TEXT_LENGTH} characters.`);
else if (tags.has(tag)) errors.push(`Duplicate tag: ${tag}`);
else tags.add(tag);
}
}
}
return errors;
}
/** Coerce a validated value into a normalized taxonomy (trims, drops extras). */
export function normalizeTaxonomy(
value: ClassificationTaxonomy,
): ClassificationTaxonomy {
return {
categories: value.categories.map(
(c): DocumentCategory => ({
id: c.id,
label: c.label,
docTypes: (c.docTypes ?? []).map(
(d): DocumentType => ({ id: d.id, label: d.label }),
),
}),
),
tags: value.tags ?? [],
};
}
/** Parse + validate a picked file, resolving to a normalized taxonomy. */
export async function parseTaxonomyFile(
file: File,
): Promise<ClassificationTaxonomy> {
let parsed: unknown;
try {
parsed = JSON.parse(await file.text());
} catch {
throw new Error("That file isn't valid JSON.");
}
const errors = validateTaxonomy(parsed);
if (errors.length > 0) throw new Error(errors[0]);
return normalizeTaxonomy(parsed as ClassificationTaxonomy);
}
/** Trigger a download of the taxonomy as a pretty-printed JSON file. */
export function downloadTaxonomy(
taxonomy: ClassificationTaxonomy,
fileName = "classification-taxonomy.json",
): void {
downloadJsonAsFile(taxonomy, fileName);
}
function isText(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function withinLength(value: string): boolean {
return value.length <= MAX_TEXT_LENGTH;
}
@@ -51,6 +51,11 @@ export interface PolicyCategory {
* or configured. Only Security is live today.
*/
comingSoon?: boolean;
/**
* Requires the AI engine to be enabled. Hidden from the catalog when the
* engine is off, so the policy only appears where it can actually run.
*/
requiresAiEngine?: boolean;
}
/**
@@ -136,6 +141,13 @@ export interface PolicyState {
outputNamePosition?: "prefix" | "suffix" | "auto-number";
/** When the policy runs: on "upload" or before "export". Defaults to "upload". */
runOn?: "upload" | "export";
/**
* Execution order among policies that share a trigger. When several policies run
* on the same event they fire in ascending `order`, each on the previous one's
* output (a cumulative chain). Defaults to the policy's position in the catalog
* until an admin reorders them, which persists an explicit value for every policy.
*/
order?: number;
/**
* The backing folder-trigger record (a Watched Folders `WatchedFolder`) that
* holds this policy's editable steps (its automation), output config and run
+3
View File
@@ -34,3 +34,6 @@
.sui-iconbadge--red {
--ib-base: var(--color-red);
}
.sui-iconbadge--orange {
--ib-base: var(--color-orange);
}
+7 -1
View File
@@ -1,7 +1,13 @@
import type { ReactNode } from "react";
import "@shared/components/IconBadge.css";
export type IconBadgeAccent = "blue" | "purple" | "green" | "amber" | "red";
export type IconBadgeAccent =
| "blue"
| "purple"
| "green"
| "amber"
| "red"
| "orange";
export interface IconBadgeProps {
children: ReactNode;
+4
View File
@@ -71,6 +71,10 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
border-radius: var(--radius-sm);
color: var(--color-text-4);
transition:
+8
View File
@@ -52,6 +52,10 @@
--color-amber-light: #fef3c7;
--color-amber-border: #fde68a;
--color-amber-dark: #92400e;
--color-orange: #f97316;
--color-orange-light: #fff7ed;
--color-orange-border: #fed7aa;
--color-orange-dark: #9a3412;
/* Category accents (theme-stable) */
--color-cat-insurance: #0ea5e9;
@@ -195,6 +199,10 @@
#fbbf24 — identical to the base — which left Mantine's amber hover a
no-op in dark mode. */
--color-amber-dark: #f59e0b;
--color-orange: #fb923c;
--color-orange-light: #2a1408;
--color-orange-border: #7c2d12;
--color-orange-dark: #fdba74;
--color-bg: #090c14;
--color-bg-alt: #0d1120;