diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 6d7b697aa1..29cae7d273 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -60,7 +60,7 @@ tasks: run: when_changed desc: "Regenerate the engine classifier categories JSON from the TS source of truth" cmds: - - npx tsx editor/scripts/generate-taxonomy.mts + - npx tsx editor/scripts/generate-classification-taxonomy.mts sources: - editor/src/proprietary/data/classificationTaxonomy.ts @@ -414,12 +414,12 @@ tasks: classifier-categories: desc: "Regenerate the engine classifier categories JSON from the TS source" cmds: - - npx tsx editor/scripts/generate-taxonomy.mts + - 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-taxonomy.mts --check + - npx tsx editor/scripts/generate-classification-taxonomy.mts --check check:all: desc: "Full CI quality gate" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/TaxonomyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/TaxonomyController.java new file mode 100644 index 0000000000..9bdb668c7d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/TaxonomyController.java @@ -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 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 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 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(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationTaxonomy.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationTaxonomy.java new file mode 100644 index 0000000000..1b329bb0fc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationTaxonomy.java @@ -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 categories, List tags) { + + public ClassificationTaxonomy { + categories = categories == null ? List.of() : List.copyOf(categories); + tags = tags == null ? List.of() : List.copyOf(tags); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/TaxonomyCategory.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/TaxonomyCategory.java new file mode 100644 index 0000000000..461dda7f77 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/TaxonomyCategory.java @@ -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 docTypes) { + + public TaxonomyCategory { + docTypes = docTypes == null ? List.of() : List.copyOf(docTypes); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/TaxonomyDocumentType.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/TaxonomyDocumentType.java new file mode 100644 index 0000000000..47a641c91c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/TaxonomyDocumentType.java @@ -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) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/TaxonomyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/TaxonomyValidator.java new file mode 100644 index 0000000000..647ea80972 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/TaxonomyValidator.java @@ -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 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 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 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)"); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessTaxonomyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessTaxonomyStore.java new file mode 100644 index 0000000000..80fff4e63c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessTaxonomyStore.java @@ -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 byTeam = new ConcurrentHashMap<>(); + + @Override + public Optional 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaTaxonomyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaTaxonomyStore.java new file mode 100644 index 0000000000..afb5035b81 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaTaxonomyStore.java @@ -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 findByTeam(Long teamId) { + Optional 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TaxonomyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TaxonomyEntity.java new file mode 100644 index 0000000000..208f9cd736 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TaxonomyEntity.java @@ -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; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TaxonomyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TaxonomyRepository.java new file mode 100644 index 0000000000..64af0769c8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TaxonomyRepository.java @@ -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 {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TaxonomyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TaxonomyStore.java new file mode 100644 index 0000000000..b8af2bbc86 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TaxonomyStore.java @@ -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 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); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyTagController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyTagController.java index f7391505a1..5ecae56d0f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyTagController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyTagController.java @@ -31,7 +31,9 @@ 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; @@ -68,6 +70,15 @@ public class ClassifyTagController { 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, @@ -75,7 +86,9 @@ public class ClassifyTagController { PdfMetadataService pdfMetadataService, AiEngineClient aiEngineClient, ObjectMapper objectMapper, - @Autowired(required = false) UserServiceInterface userService) { + @Autowired(required = false) UserServiceInterface userService, + @Autowired(required = false) TaxonomyStore taxonomyStore, + @Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) { this.pdfDocumentFactory = pdfDocumentFactory; this.tempFileManager = tempFileManager; this.pdfContentExtractor = pdfContentExtractor; @@ -83,6 +96,8 @@ public class ClassifyTagController { 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) @@ -151,12 +166,24 @@ public class ClassifyTagController { } /** - * Override point for a future per-org / DB-configured taxonomy: resolve the caller's taxonomy - * here and return it (engine shape) to classify against; {@code null} falls back to the - * engine's generated default. Always null today. + * 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() { - return null; + 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. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResultFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResultFile.java index 57207d2bc9..c80615f03e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResultFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowResultFile.java @@ -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; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java index 8bfb78410b..2d5552517c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutionResult.java @@ -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 files, JsonNode report, String reportTool) {} +public record PolicyExecutionResult( + List files, List origins, JsonNode report, String reportTool) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java index fe88b40391..d6202b520e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java @@ -58,6 +58,11 @@ public class PolicyExecutor { // payload the tool surfaced alongside or instead of a file. private record ToolResult(List 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 files, List 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 currentFiles = inputs.primary(); Map> 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 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 inputFiles, + List inputOrigins, Map> supportingFiles) throws IOException { requireAcceptedTypes(step.operation(), inputFiles); List files = new ArrayList<>(); + List 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); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index ee449b31f2..90d4a7d230 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -34,7 +34,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.workflow.repository", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.accountlink", - "stirling.software.proprietary.policy.source" + "stirling.software.proprietary.policy.source", + "stirling.software.proprietary.classification.store" }) @EntityScan({ "stirling.software.proprietary.security.model", @@ -43,7 +44,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.workflow.model", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.accountlink", - "stirling.software.proprietary.policy.source" + "stirling.software.proprietary.policy.source", + "stirling.software.proprietary.classification.store" }) public class DatabaseConfig { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 4e6c515318..40c82ca870 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -352,6 +352,7 @@ public class AiWorkflowService { try { List resultFiles = new ArrayList<>(); + List origins = new ArrayList<>(); List 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 resultFiles, + List origins, List 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 outputsPerOrigin = + origins == null + ? Map.of() + : origins.stream() + .filter(o -> o != null) + .collect(Collectors.groupingBy(o -> o, Collectors.counting())); List 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(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/classification/TaxonomyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/classification/TaxonomyControllerTest.java new file mode 100644 index 0000000000..82174d848d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/classification/TaxonomyControllerTest.java @@ -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 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 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 response = controller.resetTaxonomy(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(controller.getTaxonomy().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/TaxonomyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/TaxonomyValidatorTest.java new file mode 100644 index 0000000000..4cbd0fd909 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/TaxonomyValidatorTest.java @@ -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 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"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyTagControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyTagControllerTest.java index 0ad76283de..0f0a026eac 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyTagControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyTagControllerTest.java @@ -56,6 +56,8 @@ class ClassifyTagControllerTest { pdfMetadataService, aiEngineClient, objectMapper, + null, + null, null); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java index 9e611cf32e..588c659d43 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java @@ -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 diff --git a/app/saas/src/main/resources/db/migration/saas/V25__classification_taxonomy.sql b/app/saas/src/main/resources/db/migration/saas/V25__classification_taxonomy.sql new file mode 100644 index 0000000000..3788b404ed --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V25__classification_taxonomy.sql @@ -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) +); diff --git a/engine/src/stirling/agents/default_taxonomy.generated.json b/engine/src/stirling/agents/default_classification_taxonomy.generated.json similarity index 98% rename from engine/src/stirling/agents/default_taxonomy.generated.json rename to engine/src/stirling/agents/default_classification_taxonomy.generated.json index 0f543993d8..a85a76860b 100644 --- a/engine/src/stirling/agents/default_taxonomy.generated.json +++ b/engine/src/stirling/agents/default_classification_taxonomy.generated.json @@ -1,5 +1,5 @@ { - "_generated": "AUTO-GENERATED from frontend/editor/src/proprietary/data/classificationTaxonomy.ts by editor/scripts/generate-taxonomy.mts — do NOT edit by hand; run `task frontend:classifier-categories`.", + "_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", diff --git a/engine/src/stirling/agents/document_classifier.py b/engine/src/stirling/agents/document_classifier.py index 0a653d60f8..da25ce9805 100644 --- a/engine/src/stirling/agents/document_classifier.py +++ b/engine/src/stirling/agents/document_classifier.py @@ -20,8 +20,10 @@ from stirling.services import AppRuntime logger = logging.getLogger(__name__) -# Sentinel for a label that fell outside the supplied vocabulary. +# 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. @@ -36,7 +38,7 @@ WINDOW_PAGES = 2 # (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_taxonomy.generated.json") +_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")) @@ -126,7 +128,9 @@ def validate_against_taxonomy( 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, ) @@ -136,14 +140,18 @@ def validate_against_taxonomy( 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, ) diff --git a/engine/src/stirling/contracts/document_classifier.py b/engine/src/stirling/contracts/document_classifier.py index 13598ae96b..d74c0a0801 100644 --- a/engine/src/stirling/contracts/document_classifier.py +++ b/engine/src/stirling/contracts/document_classifier.py @@ -50,15 +50,20 @@ class ClassifyDocumentRequest(ApiModel): class DocumentClassificationResponse(ApiModel): """Terminal classification result. - ``category`` and ``doc_type`` are ids drawn from the taxonomy, or the - sentinel ``"unknown"`` when the model's answer fell outside it. ``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`` 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) diff --git a/engine/tests/test_document_classifier_routes.py b/engine/tests/test_document_classifier_routes.py index ec5468f322..02b2e928d0 100644 --- a/engine/tests/test_document_classifier_routes.py +++ b/engine/tests/test_document_classifier_routes.py @@ -28,7 +28,12 @@ class StubClassifierAgent: def classification_client() -> Iterator[TestClient]: app.dependency_overrides[get_document_classifier_agent] = lambda: StubClassifierAgent( DocumentClassificationResponse( - category="contract", doc_type="nda", type_confidence=0.96, tags=["legal", "signed"] + category="contract", + category_label="Contract", + doc_type="nda", + doc_type_label="Non-disclosure agreement", + type_confidence=0.96, + tags=["legal", "signed"], ) ) try: @@ -45,7 +50,9 @@ def test_classify_returns_camel_cased_result(classification_client: TestClient) 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"] diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d85af5b3bb..3e11e9e96d 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -5982,21 +5982,70 @@ 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" +categoryIdAria = "Category id" +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" +subIdAria = "Sub-category id" +subLabel = "Sub-category" +tags = "Tags" +view = "View taxonomy" + [policies.toolConfig] enableAriaLabel = "Enable {{tool}}" infoAriaLabel = "What does {{tool}} do?" diff --git a/frontend/editor/scripts/generate-taxonomy.mts b/frontend/editor/scripts/generate-classification-taxonomy.mts similarity index 75% rename from frontend/editor/scripts/generate-taxonomy.mts rename to frontend/editor/scripts/generate-classification-taxonomy.mts index 10e8c8c2e2..5df7782a64 100644 --- a/frontend/editor/scripts/generate-taxonomy.mts +++ b/frontend/editor/scripts/generate-classification-taxonomy.mts @@ -6,8 +6,8 @@ * 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-taxonomy.mts` (writes the JSON) - * `npx tsx editor/scripts/generate-taxonomy.mts --check` (CI drift guard) + * 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. @@ -31,12 +31,12 @@ const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, "../../.."); const outPath = resolve( repoRoot, - "engine/src/stirling/agents/default_taxonomy.generated.json", + "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-taxonomy.mts — do NOT edit by hand; run `task frontend:classifier-categories`."; + "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 }, @@ -48,12 +48,12 @@ if (process.argv.includes("--check")) { const current = existsSync(outPath) ? readFileSync(outPath, "utf8") : ""; if (current !== json) { console.error( - "default_taxonomy.generated.json is stale. Run `task frontend:classifier-categories` " + - "(npx tsx editor/scripts/generate-taxonomy.mts).", + "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_taxonomy.generated.json is up to date."); + console.log("default_classification_taxonomy.generated.json is up to date."); } else { writeFileSync(outPath, json); const categories = DEFAULT_CLASSIFICATION_TAXONOMY.categories.length; diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx index eff1e44257..4b3adca337 100644 --- a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx +++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx @@ -40,7 +40,9 @@ const MAX_CLASSIFICATION_READ_BYTES = 25 * 1024 * 1024; interface DocumentClassification { category: string; + categoryLabel: string; docType: string; + docTypeLabel: string; typeConfidence?: number; tags: string[]; } @@ -52,9 +54,21 @@ function parseClassification(value: string): DocumentClassification | null { 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) @@ -66,10 +80,10 @@ function parseClassification(value: string): DocumentClassification | null { } } -/** "lab_result" → "Lab result" for display. */ +/** "lab_result" / "lab-result" → "Lab result" — fallback for pre-label files. */ function prettyLabel(id: string): string { return id - .split(/[_\s]+/) + .split(/[_\-\s]+/) .filter(Boolean) .map((word) => word[0].toUpperCase() + word.slice(1)) .join(" "); @@ -334,13 +348,13 @@ export function FileDetailsPanel({ {classification.category && ( )} {classification.docType && ( )} {classification.typeConfidence != null && ( diff --git a/frontend/editor/src/core/contexts/FileContext.tsx b/frontend/editor/src/core/contexts/FileContext.tsx index 0840d49a72..97a165473c 100644 --- a/frontend/editor/src/core/contexts/FileContext.tsx +++ b/frontend/editor/src/core/contexts/FileContext.tsx @@ -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( diff --git a/frontend/editor/src/core/utils/slug.ts b/frontend/editor/src/core/utils/slug.ts new file mode 100644 index 0000000000..1213cb6556 --- /dev/null +++ b/frontend/editor/src/core/utils/slug.ts @@ -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, ""); +} diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx index b61296765c..595961365f 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -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(); + 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 diff --git a/frontend/editor/src/proprietary/components/policies/ClassificationTaxonomySection.tsx b/frontend/editor/src/proprietary/components/policies/ClassificationTaxonomySection.tsx new file mode 100644 index 0000000000..77fec02790 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/ClassificationTaxonomySection.tsx @@ -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(taxonomy); + const [open, setOpen] = useState(false); + const [localError, setLocalError] = useState(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 ( +
+

+ {t("policies.taxonomy.sectionLabel", "Classification taxonomy")} +

+ + {loading ? ( + {t("loading", "Loading…")} + ) : ( +
+
+ + {taxonomy.categories.length}{" "} + {t("policies.taxonomy.categories", "categories")} + + + {subCount}{" "} + {t("policies.taxonomy.subCategories", "sub-categories")} + + + {taxonomy.tags.length}{" "} + {t("policies.taxonomy.tags", "tags")} + +
+
+ {taxonomy.categories.map((c) => ( + + {c.label} + + ))} +
+ + {isCustom + ? t("policies.taxonomy.customNote", "Customized for your team.") + : t( + "policies.taxonomy.defaultNote", + "Using the built-in default, shared with your team.", + )} + + +
+ )} +
+ + {!canConfigure && ( + } + description={t( + "policies.taxonomy.managedNote", + "The taxonomy is managed by your team leader.", + )} + /> + )} + + 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} + /> +
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css index 86d93f0095..bd85fcd909 100644 --- a/frontend/editor/src/proprietary/components/policies/Policies.css +++ b/frontend/editor/src/proprietary/components/policies/Policies.css @@ -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. */ diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx index 0e87d9f982..422e316e87 100644 --- a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx @@ -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 (
@@ -159,36 +179,51 @@ export function PoliciesSection({ expanded={expanded} onToggle={toggleExpanded} /> - - + + + {/* Hovering surfaces the same explanation the info tooltip used to show. */} + + } + > + {t("policies.sidebar.whatIsPolicy", "What is a policy?")} + + + {pol.canConfigure && ( + } + onClick={() => openPolicySettings()} + > + {t("policies.sidebar.policySettings", "Policy settings")} + )} - > - - - + +
{expanded && ( <>
- {visibleCategories.map((cat) => { + {displayCategories.map((cat) => { if (cat.comingSoon) { return (
@@ -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 ; + if (selectedId == null) return null; + return ; +} + /** * 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 ( +
+ } + title={t("policies.settings.title", "Policy settings")} + onClose={() => closePolicySettings()} + closeLabel={t("policies.detail.close", "Close")} + /> +
+

+ {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.", + )} +

+ {/* Both triggers are always shown so the run order for each is explicit, + with an empty note when a trigger has no policies. */} + + persist( + ids, + exportCats.map((c) => c.id), + ) + } + /> + + persist( + uploadCats.map((c) => c.id), + ids, + ) + } + /> +
+
+ ); +} + +/** + * 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(null); + const [overId, setOverId] = useState(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, 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 ( +
+

{title}

+

{emptyText}

+
+ ); + } + + return ( +
+

{title}

+
+ {cats.map((cat) => ( +
{ + 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 && ( + startDrag(e, cat.id)} + onDragEnd={clear} + role="button" + tabIndex={-1} + aria-label={t( + "policies.settings.reorderHandle", + "Drag to reorder", + )} + > + + + )} + + {cat.icon} + + + {t(`policies.catalog.${cat.id}`, cat.label)} + +
+ ))} +
+
+ ); +} + /** * Collapsed-rail policy icons. Each tints blue when active and carries a small * status dot (green active / amber paused). Clicking selects the policy and diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx index e2548f373f..1905192ca3 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -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({
{toolChain ? ( <> -

- {t( - "policies.wizard.toolChainDesc", - "Configure the tools this policy runs on each document.", - )} -

- + {/* 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 && ( +

+ {t( + "policies.wizard.toolChainDesc", + "Configure the tools this policy runs on each document.", + )} +

+ )} +
+ +
) : ( <> @@ -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" && ( + + )}
{step === 2 && ( diff --git a/frontend/editor/src/proprietary/components/policies/TaxonomyEditor.css b/frontend/editor/src/proprietary/components/policies/TaxonomyEditor.css new file mode 100644 index 0000000000..b3d687ac4a --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/TaxonomyEditor.css @@ -0,0 +1,303 @@ +.tax-editor { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* --- Category / sub-category table (grid rows, not a ). --- */ +.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); +} diff --git a/frontend/editor/src/proprietary/components/policies/TaxonomyEditor.tsx b/frontend/editor/src/proprietary/components/policies/TaxonomyEditor.tsx new file mode 100644 index 0000000000..79268b3d67 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/TaxonomyEditor.tsx @@ -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>(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) => + 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 ( +
+
+
+ + {t("policies.taxonomy.categoryLabel", "Category")} + + {t("policies.taxonomy.id", "ID")} + {!readOnly && } +
+ + {value.categories.length === 0 && ( +
+ {t( + "policies.taxonomy.emptyCategories", + "No categories yet — add one to get started.", + )} +
+ )} + + {value.categories.map((category, catIndex) => { + const isOpen = expanded.has(catIndex); + return ( +
+
+ +
+ {readOnly ? ( + {category.label} + ) : ( + renameCategory(catIndex, e.target.value)} + placeholder={t( + "policies.taxonomy.categoryLabel", + "Category", + )} + aria-label={t( + "policies.taxonomy.categoryLabel", + "Category", + )} + /> + )} + + {t("policies.taxonomy.subCount", "{{count}} sub", { + count: category.docTypes.length, + })} + +
+ {category.id} + {!readOnly && ( + + )} +
+ + {isOpen && ( +
+ {category.docTypes.map((docType, docIndex) => ( +
+
+ {readOnly ? ( + + {docType.label} + + ) : ( + + renameDocType(catIndex, docIndex, e.target.value) + } + placeholder={t( + "policies.taxonomy.subLabel", + "Sub-category", + )} + aria-label={t( + "policies.taxonomy.subLabel", + "Sub-category", + )} + /> + )} +
+ {docType.id} + {!readOnly && ( + + )} +
+ ))} + {!readOnly && ( + + )} +
+ )} +
+ ); + })} +
+ + {!readOnly && ( + + )} + +
+

+ {t("policies.taxonomy.tags", "Tags")} +

+
+ {value.tags.length === 0 && ( + + {t("policies.taxonomy.noTags", "No tags yet.")} + + )} + {value.tags.map((tag) => ( + + {tag} + {!readOnly && ( + + )} + + ))} +
+ {!readOnly && ( +
+ 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")} + /> + +
+ )} +
+
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/TaxonomyEditorModal.tsx b/frontend/editor/src/proprietary/components/policies/TaxonomyEditorModal.tsx new file mode 100644 index 0000000000..8eb5ec2c65 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/TaxonomyEditorModal.tsx @@ -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(null); + + const handleFile = (e: React.ChangeEvent) => { + 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 ( + +
+ {!readOnly && ( + <> + + + + )} +
+
+ + {!readOnly && ( + + )} +
+ + } + > +
+ {error && ( + } + description={error} + /> + )} + {!readOnly && ( +
+ + + +
+ )} + +
+
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts b/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts index 451582b90e..e8b80c06c8 100644 --- a/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts @@ -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(); } diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx new file mode 100644 index 0000000000..638c821669 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx @@ -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 }]); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 19f88c9304..83748a66c4 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -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,29 @@ export function usePolicyAutoRun(): void { // run so a folder-watch burst opens the modal once, not once per file. const firedLimitModal = useRef>(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. + const orderedUploadCategories = useMemo( + () => + Object.entries(policies) + .filter( + ([, s]) => + s.configured && + s.status === "active" && + s.backendId && + (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>(new Set()); + // Latest policies, read from inside the stable retry callback (which has no deps). const policiesRef = useRef(policies); policiesRef.current = policies; @@ -204,46 +231,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 auto-run on upload when the policy is set to run on upload - // (export-triggered policies enforce at export time instead). - (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(() => { @@ -368,6 +420,17 @@ async function reconcileServerRuns( } } +/** 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]; +} + /** The category whose configured policy produced this run, if any. */ function categoryForPolicy( policyId: string | null, diff --git a/frontend/editor/src/proprietary/data/classificationTaxonomy.ts b/frontend/editor/src/proprietary/data/classificationTaxonomy.ts index 66999b4b9d..6a99fc7e5a 100644 --- a/frontend/editor/src/proprietary/data/classificationTaxonomy.ts +++ b/frontend/editor/src/proprietary/data/classificationTaxonomy.ts @@ -2,10 +2,11 @@ * 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_taxonomy.generated.json` by - * `editor/scripts/generate-taxonomy.mts` (`task frontend:classifier-categories`, - * drift-guarded by `task frontend:classifier-categories:check`). Edit THIS file, - * never the generated JSON. + * `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. diff --git a/frontend/editor/src/proprietary/hooks/useClassificationTaxonomy.ts b/frontend/editor/src/proprietary/hooks/useClassificationTaxonomy.ts new file mode 100644 index 0000000000..fcb585ad25 --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/useClassificationTaxonomy.ts @@ -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; +} + +export function useClassificationTaxonomy( + enabled: boolean, +): UseClassificationTaxonomy { + const [taxonomy, setTaxonomy] = useState( + DEFAULT_CLASSIFICATION_TAXONOMY, + ); + const [isCustom, setIsCustom] = useState(false); + const [loading, setLoading] = useState(enabled); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 }; +} diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 292cb9d0bc..5527d72fcc 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -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); @@ -242,14 +248,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" }); }, []); @@ -261,6 +295,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 @@ -312,6 +355,7 @@ export function usePolicies() { pausePolicy, resumePolicy, deletePolicy, + reorderPolicies, ensurePolicyFolder, }; } diff --git a/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts b/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts index 6c6a8029ce..18b028ab8e 100644 --- a/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts +++ b/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts @@ -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(): { diff --git a/frontend/editor/src/proprietary/services/policyStorage.ts b/frontend/editor/src/proprietary/services/policyStorage.ts index deed307945..82e90b0d40 100644 --- a/frontend/editor/src/proprietary/services/policyStorage.ts +++ b/frontend/editor/src/proprietary/services/policyStorage.ts @@ -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, { diff --git a/frontend/editor/src/proprietary/services/taxonomyBackend.ts b/frontend/editor/src/proprietary/services/taxonomyBackend.ts new file mode 100644 index 0000000000..a195d70b50 --- /dev/null +++ b/frontend/editor/src/proprietary/services/taxonomyBackend.ts @@ -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 { + const res = await apiClient.get(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 { + const res = await apiClient.put(ENDPOINT, taxonomy); + return res.data; +} diff --git a/frontend/editor/src/proprietary/services/taxonomyFile.ts b/frontend/editor/src/proprietary/services/taxonomyFile.ts new file mode 100644 index 0000000000..d4f58ba3c7 --- /dev/null +++ b/frontend/editor/src/proprietary/services/taxonomyFile.ts @@ -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; + 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(); + 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(); + 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(); + 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 { + 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; +} diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts index 7cab109a2c..b9583ba3e1 100644 --- a/frontend/editor/src/proprietary/types/policies.ts +++ b/frontend/editor/src/proprietary/types/policies.ts @@ -138,6 +138,13 @@ export interface PolicyState { outputName?: string; /** 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 diff --git a/frontend/shared/components/Modal.css b/frontend/shared/components/Modal.css index 235f2f4ccd..b78aaf8168 100644 --- a/frontend/shared/components/Modal.css +++ b/frontend/shared/components/Modal.css @@ -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: