added the classification taxonomy table in the db, team scoped. Allow admins to edit the taxonomy and set up the backend to query it and send it to python each time it's requested. No longer requires a backend restart to make it get the latest taxonomy. Also fixed up how the ordeering of policies is selected and how they chain together in that order. Also made a tiny fix to the my files section to keep it up to date with the indexdb
This commit is contained in:
@@ -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"
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
import stirling.software.proprietary.classification.model.TaxonomyValidator;
|
||||
import stirling.software.proprietary.classification.store.TaxonomyStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Read/write the caller's team classification taxonomy — the vocabulary the document classifier
|
||||
* runs against. Team-scoped exactly like policies: every user reads their own team's taxonomy, and
|
||||
* only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
|
||||
* {@link PolicyManagementAuthority}) may change it. Editing is gated only when login is enabled;
|
||||
* single-user deployments trust the local operator. A team with no stored taxonomy reads as {@code
|
||||
* 204} and the classifier falls back to the engine's built-in default.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/classification/taxonomy")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Classification", description = "Team-scoped document-classification taxonomy")
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class TaxonomyController {
|
||||
|
||||
private final TaxonomyStore taxonomyStore;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "Get the team's classification taxonomy",
|
||||
description =
|
||||
"Returns the caller's team taxonomy, or 204 when the team has none (the"
|
||||
+ " classifier then uses the built-in default).")
|
||||
public ResponseEntity<ClassificationTaxonomy> getTaxonomy() {
|
||||
return taxonomyStore
|
||||
.findByTeam(currentTeamId())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Save the team's classification taxonomy",
|
||||
description =
|
||||
"Validates and stores the taxonomy for the caller's team, shared by everyone on"
|
||||
+ " the team. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<ClassificationTaxonomy> saveTaxonomy(
|
||||
@RequestBody ClassificationTaxonomy taxonomy) {
|
||||
requireEditingAllowed();
|
||||
try {
|
||||
TaxonomyValidator.validate(taxonomy);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
ClassificationTaxonomy saved =
|
||||
taxonomyStore.save(currentTeamId(), taxonomy, currentUsername());
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Reset the team's classification taxonomy",
|
||||
description =
|
||||
"Removes the team's stored taxonomy so the classifier falls back to the built-in"
|
||||
+ " default. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<Void> resetTaxonomy() {
|
||||
requireEditingAllowed();
|
||||
taxonomyStore.deleteByTeam(currentTeamId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the taxonomy requires the editor role for the caller's team — the same gate policies
|
||||
* use (team leader on SaaS, global admin self-hosted). Single-user deployments (login disabled)
|
||||
* have no such role, so they trust the local operator.
|
||||
*/
|
||||
private void requireEditingAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"The classification taxonomy may only be changed by a team leader");
|
||||
}
|
||||
}
|
||||
|
||||
private Long currentTeamId() {
|
||||
return policyManagementAuthority.currentUserTeamId();
|
||||
}
|
||||
|
||||
private String currentUsername() {
|
||||
return userService == null ? null : userService.getCurrentUsername();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The vocabulary a document is classified against — team-scoped and admin-editable. Its shape
|
||||
* mirrors the engine's {@code ClassificationTaxonomy} contract (categories owning doc_types, plus
|
||||
* free-standing cross-cutting tags), so a stored taxonomy is passed to the engine verbatim as the
|
||||
* per-request override. When a team has no stored taxonomy the engine falls back to its built-in
|
||||
* default.
|
||||
*/
|
||||
public record ClassificationTaxonomy(List<TaxonomyCategory> categories, List<String> tags) {
|
||||
|
||||
public ClassificationTaxonomy {
|
||||
categories = categories == null ? List.of() : List.copyOf(categories);
|
||||
tags = tags == null ? List.of() : List.copyOf(tags);
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A structural family of documents, owning the doc_types shaped like it. {@code docTypes} is
|
||||
* serialized in the engine's camelCase shape (the engine's {@code ClassificationTaxonomy} model
|
||||
* aliases {@code doc_types} onto it), so a stored taxonomy passes straight through to the engine.
|
||||
*/
|
||||
public record TaxonomyCategory(String id, String label, List<TaxonomyDocumentType> docTypes) {
|
||||
|
||||
public TaxonomyCategory {
|
||||
docTypes = docTypes == null ? List.of() : List.copyOf(docTypes);
|
||||
}
|
||||
}
|
||||
+7
@@ -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) {}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Structural validation for an admin-supplied (or imported) taxonomy, run before it is stored so a
|
||||
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
|
||||
* at least one category, non-blank ids/labels everywhere, ids unique among categories and among the
|
||||
* doc_types within a category, and non-blank unique tags.
|
||||
*/
|
||||
public final class TaxonomyValidator {
|
||||
|
||||
private TaxonomyValidator() {}
|
||||
|
||||
// Generous upper bounds so a legitimate taxonomy is never blocked, but a single team can't
|
||||
// store an unbounded blob that would bloat the row, balloon the classifier prompt, or exhaust
|
||||
// memory on deserialize.
|
||||
static final int MAX_CATEGORIES = 200;
|
||||
static final int MAX_DOC_TYPES_PER_CATEGORY = 200;
|
||||
static final int MAX_TAGS = 500;
|
||||
static final int MAX_TEXT_LENGTH = 128;
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException with a human-readable message when the taxonomy is invalid.
|
||||
*/
|
||||
public static void validate(ClassificationTaxonomy taxonomy) {
|
||||
if (taxonomy == null) {
|
||||
throw new IllegalArgumentException("Taxonomy is required");
|
||||
}
|
||||
if (taxonomy.categories().isEmpty()) {
|
||||
throw new IllegalArgumentException("Taxonomy must have at least one category");
|
||||
}
|
||||
if (taxonomy.categories().size() > MAX_CATEGORIES) {
|
||||
throw new IllegalArgumentException("Too many categories (max " + MAX_CATEGORIES + ")");
|
||||
}
|
||||
if (taxonomy.tags().size() > MAX_TAGS) {
|
||||
throw new IllegalArgumentException("Too many tags (max " + MAX_TAGS + ")");
|
||||
}
|
||||
Set<String> categoryIds = new HashSet<>();
|
||||
for (TaxonomyCategory category : taxonomy.categories()) {
|
||||
requireText(category.id(), "Category id");
|
||||
requireText(category.label(), "Category label");
|
||||
if (category.docTypes().size() > MAX_DOC_TYPES_PER_CATEGORY) {
|
||||
throw new IllegalArgumentException(
|
||||
"Too many sub-categories in '"
|
||||
+ category.id()
|
||||
+ "' (max "
|
||||
+ MAX_DOC_TYPES_PER_CATEGORY
|
||||
+ ")");
|
||||
}
|
||||
if (!categoryIds.add(category.id())) {
|
||||
throw new IllegalArgumentException("Duplicate category id: " + category.id());
|
||||
}
|
||||
Set<String> docTypeIds = new HashSet<>();
|
||||
for (TaxonomyDocumentType docType : category.docTypes()) {
|
||||
requireText(docType.id(), "Doc type id");
|
||||
requireText(docType.label(), "Doc type label");
|
||||
if (!docTypeIds.add(docType.id())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Duplicate doc type id '"
|
||||
+ docType.id()
|
||||
+ "' in category '"
|
||||
+ category.id()
|
||||
+ "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<String> tags = new HashSet<>();
|
||||
for (String tag : taxonomy.tags()) {
|
||||
requireText(tag, "Tag");
|
||||
if (!tags.add(tag)) {
|
||||
throw new IllegalArgumentException("Duplicate tag: " + tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
if (value.length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
|
||||
/**
|
||||
* In-memory {@link TaxonomyStore} for tests and any future no-database mode. {@link
|
||||
* JpaTaxonomyStore} is the runtime bean.
|
||||
*/
|
||||
public class InProcessTaxonomyStore implements TaxonomyStore {
|
||||
|
||||
private final Map<Long, ClassificationTaxonomy> byTeam = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationTaxonomy> findByTeam(Long teamId) {
|
||||
return Optional.ofNullable(byTeam.get(key(teamId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationTaxonomy save(
|
||||
Long teamId, ClassificationTaxonomy taxonomy, String updatedBy) {
|
||||
byTeam.put(key(teamId), taxonomy);
|
||||
return taxonomy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
return byTeam.remove(key(teamId)) != null;
|
||||
}
|
||||
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TaxonomyEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Durable {@link TaxonomyStore} backed by JPA; the runtime store. Gated on {@code policies.enabled}
|
||||
* — a team taxonomy only matters when the Classification policy can run — so it shares the policy
|
||||
* subsystem's on/off switch. The taxonomy is persisted as JSON via {@link TaxonomyEntity}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaTaxonomyStore implements TaxonomyStore {
|
||||
|
||||
private final TaxonomyRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationTaxonomy> findByTeam(Long teamId) {
|
||||
Optional<TaxonomyEntity> entity = repository.findById(key(teamId));
|
||||
if (entity.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(
|
||||
objectMapper.readValue(
|
||||
entity.get().getTaxonomyJson(), ClassificationTaxonomy.class));
|
||||
} catch (JacksonException e) {
|
||||
// A stored taxonomy that no longer parses (corruption / manual DB edit) must not break
|
||||
// classification: drop it so the caller falls back to the built-in default rather than
|
||||
// surfacing a 500 on every upload for the team.
|
||||
log.warn(
|
||||
"Discarding unparseable stored taxonomy for team {}: {}",
|
||||
teamId,
|
||||
e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationTaxonomy save(
|
||||
Long teamId, ClassificationTaxonomy taxonomy, String updatedBy) {
|
||||
TaxonomyEntity entity = new TaxonomyEntity();
|
||||
entity.setTeamId(key(teamId));
|
||||
entity.setTaxonomyJson(objectMapper.writeValueAsString(taxonomy));
|
||||
entity.setUpdatedAt(Instant.now());
|
||||
entity.setUpdatedBy(updatedBy);
|
||||
repository.save(entity);
|
||||
return taxonomy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
long id = key(teamId);
|
||||
if (!repository.existsById(id)) {
|
||||
return false;
|
||||
}
|
||||
repository.deleteById(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TaxonomyEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface TaxonomyRepository extends JpaRepository<TaxonomyEntity, Long> {}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
|
||||
/**
|
||||
* Stores one {@link ClassificationTaxonomy} per team. A {@code null} teamId addresses the unteamed
|
||||
* taxonomy (login disabled / no resolvable team), mirroring how the policy store treats a null
|
||||
* team.
|
||||
*/
|
||||
public interface TaxonomyStore {
|
||||
|
||||
/** The team's stored taxonomy, or empty when it has none (callers fall back to the default). */
|
||||
Optional<ClassificationTaxonomy> findByTeam(Long teamId);
|
||||
|
||||
/** Create or replace the team's taxonomy. Returns the stored value. */
|
||||
ClassificationTaxonomy save(Long teamId, ClassificationTaxonomy taxonomy, String updatedBy);
|
||||
|
||||
/** Remove the team's taxonomy (reset to default). Returns whether one existed. */
|
||||
boolean deleteByTeam(Long teamId);
|
||||
}
|
||||
+32
-5
@@ -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. */
|
||||
|
||||
+7
@@ -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;
|
||||
}
|
||||
|
||||
+7
-3
@@ -8,7 +8,11 @@ import tools.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Result of a {@link PolicyExecutor} run. {@code files} are final temp files (not yet stored).
|
||||
* {@code report}/{@code reportTool} carry the last step's structured report and its operation, or
|
||||
* null if no step produced one.
|
||||
* {@code origins} is parallel to {@code files}: each entry is the index into the original pipeline
|
||||
* inputs that the output traces back to, or {@code null} when it has no single source (e.g. a merge
|
||||
* combining several inputs, or a generated file). Callers use it to map an output back onto the
|
||||
* file it came from. {@code report}/{@code reportTool} carry the last step's structured report and
|
||||
* its operation, or null if no step produced one.
|
||||
*/
|
||||
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
|
||||
public record PolicyExecutionResult(
|
||||
List<Resource> files, List<Integer> origins, JsonNode report, String reportTool) {}
|
||||
|
||||
+40
-9
@@ -58,6 +58,11 @@ public class PolicyExecutor {
|
||||
// payload the tool surfaced alongside or instead of a file.
|
||||
private record ToolResult(List<Resource> files, JsonNode report) {}
|
||||
|
||||
// A step's output files paired with each file's origin (the index into the original pipeline
|
||||
// inputs it traces back to, or null when it has no single source). Origins compose across steps
|
||||
// so the final result can be mapped back onto the files that entered the pipeline.
|
||||
private record StepOutput(List<Resource> files, List<Integer> origins, JsonNode report) {}
|
||||
|
||||
/**
|
||||
* Run every step in order, feeding each step's output into the next. Supporting files in {@code
|
||||
* inputs} bind to named file fields and never enter the document stream.
|
||||
@@ -75,6 +80,12 @@ public class PolicyExecutor {
|
||||
|
||||
List<Resource> currentFiles = inputs.primary();
|
||||
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
|
||||
// Seed each input with its own index as origin; steps carry these through so the final
|
||||
// outputs can be traced back to the files that entered the pipeline.
|
||||
List<Integer> currentOrigins = new ArrayList<>();
|
||||
for (int k = 0; k < currentFiles.size(); k++) {
|
||||
currentOrigins.add(k);
|
||||
}
|
||||
// Last non-null report wins: the terminal step defines the output.
|
||||
JsonNode lastReport = null;
|
||||
String lastReportTool = null;
|
||||
@@ -87,8 +98,10 @@ public class PolicyExecutor {
|
||||
"Pipeline step " + (i + 1) + " has no operation");
|
||||
}
|
||||
listener.onStepStart(i + 1, steps.size(), operation);
|
||||
ToolResult stepResult = executeStep(step, currentFiles, supportingFiles);
|
||||
StepOutput stepResult =
|
||||
executeStep(step, currentFiles, currentOrigins, supportingFiles);
|
||||
currentFiles = stepResult.files();
|
||||
currentOrigins = stepResult.origins();
|
||||
if (stepResult.report() != null) {
|
||||
lastReport = stepResult.report();
|
||||
lastReportTool = operation;
|
||||
@@ -96,7 +109,7 @@ public class PolicyExecutor {
|
||||
listener.onStepComplete(i + 1, steps.size(), operation);
|
||||
}
|
||||
|
||||
return new PolicyExecutionResult(currentFiles, lastReport, lastReportTool);
|
||||
return new PolicyExecutionResult(currentFiles, currentOrigins, lastReport, lastReportTool);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,32 +117,50 @@ public class PolicyExecutor {
|
||||
* responses are unpacked so each inner file is its own result (e.g. split). For per-file
|
||||
* dispatch the first non-null report wins.
|
||||
*/
|
||||
private ToolResult executeStep(
|
||||
private StepOutput executeStep(
|
||||
PipelineStep step,
|
||||
List<Resource> inputFiles,
|
||||
List<Integer> inputOrigins,
|
||||
Map<String, List<Resource>> supportingFiles)
|
||||
throws IOException {
|
||||
requireAcceptedTypes(step.operation(), inputFiles);
|
||||
List<Resource> files = new ArrayList<>();
|
||||
List<Integer> origins = new ArrayList<>();
|
||||
JsonNode report = null;
|
||||
if (toolMetadataService.isMultiInput(step.operation())) {
|
||||
// One call over all inputs. The outputs derive from a single input only when exactly
|
||||
// one entered; otherwise (a genuine merge) there is no single source.
|
||||
ToolResult r = callEndpoint(step, inputFiles, supportingFiles);
|
||||
files.addAll(r.files());
|
||||
Integer origin = inputOrigins.size() == 1 ? inputOrigins.get(0) : null;
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(origin);
|
||||
}
|
||||
report = r.report();
|
||||
} else if (inputFiles.isEmpty()) {
|
||||
ToolResult r = callEndpoint(step, List.of(), supportingFiles);
|
||||
files.addAll(r.files());
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(null);
|
||||
}
|
||||
report = r.report();
|
||||
} else {
|
||||
for (Resource file : inputFiles) {
|
||||
ToolResult r = callEndpoint(step, List.of(file), supportingFiles);
|
||||
files.addAll(r.files());
|
||||
// One call per file: every output of this call inherits that input's origin, so a 1:1
|
||||
// op keeps its chain and a split (one input, many outputs) tags each output with the
|
||||
// same source.
|
||||
for (int k = 0; k < inputFiles.size(); k++) {
|
||||
Integer origin = inputOrigins.get(k);
|
||||
ToolResult r = callEndpoint(step, List.of(inputFiles.get(k)), supportingFiles);
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(origin);
|
||||
}
|
||||
if (report == null) {
|
||||
report = r.report();
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ToolResult(files, report);
|
||||
return new StepOutput(files, origins, report);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-2
@@ -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 {
|
||||
|
||||
|
||||
+40
-9
@@ -352,6 +352,7 @@ public class AiWorkflowService {
|
||||
|
||||
try {
|
||||
List<Resource> resultFiles = new ArrayList<>();
|
||||
List<Integer> origins = new ArrayList<>();
|
||||
List<String> inputNames = new ArrayList<>();
|
||||
for (int i = 0; i < filesToConvert.size(); i++) {
|
||||
AiFile file = filesToConvert.get(i);
|
||||
@@ -376,11 +377,15 @@ public class AiWorkflowService {
|
||||
definition,
|
||||
PolicyInputs.of(List.of(input)),
|
||||
PolicyProgressListener.NOOP);
|
||||
resultFiles.addAll(result.files());
|
||||
// Each conversion runs on one input, so every output traces back to file i.
|
||||
for (Resource output : result.files()) {
|
||||
resultFiles.add(output);
|
||||
origins.add(i);
|
||||
}
|
||||
inputNames.add(multipartFile.getOriginalFilename());
|
||||
}
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(null, resultFiles, inputNames, null));
|
||||
buildCompletedResponse(null, resultFiles, origins, inputNames, null));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("PDF to Markdown conversion timed out: {}", e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -472,6 +477,7 @@ public class AiWorkflowService {
|
||||
buildCompletedResponse(
|
||||
response.getRationale(),
|
||||
result.files(),
|
||||
result.origins(),
|
||||
inputFileNames(filesById),
|
||||
result.report()));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
@@ -533,7 +539,8 @@ public class AiWorkflowService {
|
||||
}
|
||||
};
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(response.getSummary(), List.of(resource), List.of(), null));
|
||||
buildCompletedResponse(
|
||||
response.getSummary(), List.of(resource), null, List.of(), null));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -591,7 +598,11 @@ public class AiWorkflowService {
|
||||
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
summary, result.files(), inputFileNames(filesById), result.report()));
|
||||
summary,
|
||||
result.files(),
|
||||
result.origins(),
|
||||
inputFileNames(filesById),
|
||||
result.report()));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -680,19 +691,35 @@ public class AiWorkflowService {
|
||||
private AiWorkflowResponse buildCompletedResponse(
|
||||
String summary,
|
||||
List<Resource> resultFiles,
|
||||
List<Integer> origins,
|
||||
List<String> inputFileNames,
|
||||
JsonNode report)
|
||||
throws IOException {
|
||||
// Store every output file individually so each gets its own Stirling file ID and the
|
||||
// frontend can add them as independent variants without going through a zip.
|
||||
boolean preserveInputNames = inputFileNames.size() == resultFiles.size();
|
||||
// Count outputs per source so only a clean 1:1 transform (one output for a source) reuses
|
||||
// the input's name; a split (one input → many outputs) keeps each entry's own name.
|
||||
Map<Integer, Long> outputsPerOrigin =
|
||||
origins == null
|
||||
? Map.of()
|
||||
: origins.stream()
|
||||
.filter(o -> o != null)
|
||||
.collect(Collectors.groupingBy(o -> o, Collectors.counting()));
|
||||
List<AiWorkflowResultFile> descriptors = new ArrayList<>();
|
||||
for (int i = 0; i < resultFiles.size(); i++) {
|
||||
Resource resource = resultFiles.get(i);
|
||||
String responseName = resource.getFilename();
|
||||
String inputName = preserveInputNames ? inputFileNames.get(i) : null;
|
||||
// Prefer the input name only for 1:1 operations where the output keeps the same
|
||||
// extension (rotate, compress, etc.). For converters and other extension-changing
|
||||
// The output's source input (from the executor), used both to name it and to tell the
|
||||
// client which file to version in place.
|
||||
Integer origin = origins != null && i < origins.size() ? origins.get(i) : null;
|
||||
boolean uniqueOrigin =
|
||||
origin != null && outputsPerOrigin.getOrDefault(origin, 0L) == 1L;
|
||||
String inputName =
|
||||
uniqueOrigin && origin >= 0 && origin < inputFileNames.size()
|
||||
? inputFileNames.get(origin)
|
||||
: null;
|
||||
// Prefer the source input's name only for 1:1 operations where the output keeps the
|
||||
// same extension (rotate, compress, etc.). For converters and other extension-changing
|
||||
// tools, the response filename from Content-Disposition is authoritative.
|
||||
String name;
|
||||
if (inputName != null
|
||||
@@ -712,7 +739,11 @@ public class AiWorkflowService {
|
||||
try (java.io.InputStream is = resource.getInputStream()) {
|
||||
fileId = fileStorage.storeInputStream(is, name).fileId();
|
||||
}
|
||||
descriptors.add(new AiWorkflowResultFile(fileId, name, contentType));
|
||||
// Only expose the source when this is a clean 1:1 transform, so the client can treat a
|
||||
// present sourceIndex as "replace that input in place" without further disambiguation.
|
||||
descriptors.add(
|
||||
new AiWorkflowResultFile(
|
||||
fileId, name, contentType, uniqueOrigin ? origin : null));
|
||||
}
|
||||
|
||||
AiWorkflowResponse completed = new AiWorkflowResponse();
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
import stirling.software.proprietary.classification.model.TaxonomyCategory;
|
||||
import stirling.software.proprietary.classification.model.TaxonomyDocumentType;
|
||||
import stirling.software.proprietary.classification.store.InProcessTaxonomyStore;
|
||||
import stirling.software.proprietary.classification.store.TaxonomyStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("TaxonomyController")
|
||||
class TaxonomyControllerTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private TaxonomyStore store;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private TaxonomyController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
store = new InProcessTaxonomyStore();
|
||||
applicationProperties = new ApplicationProperties();
|
||||
controller =
|
||||
new TaxonomyController(
|
||||
store, policyManagementAuthority, applicationProperties, userService);
|
||||
}
|
||||
|
||||
private static ClassificationTaxonomy sample() {
|
||||
return new ClassificationTaxonomy(
|
||||
List.of(
|
||||
new TaxonomyCategory(
|
||||
"invoice",
|
||||
"Invoice",
|
||||
List.of(new TaxonomyDocumentType("receipt", "Receipt")))),
|
||||
List.of("finance"));
|
||||
}
|
||||
|
||||
private void loginEnabled(boolean enabled) {
|
||||
applicationProperties.getSecurity().setEnableLogin(enabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET returns 204 when the team has no taxonomy")
|
||||
void getEmpty() {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
ResponseEntity<ClassificationTaxonomy> response = controller.getTaxonomy();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT then GET round-trips the team's taxonomy (login disabled)")
|
||||
void saveThenGet() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
|
||||
controller.saveTaxonomy(sample());
|
||||
ResponseEntity<ClassificationTaxonomy> got = controller.getTaxonomy();
|
||||
|
||||
assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(got.getBody()).isNotNull();
|
||||
assertThat(got.getBody().categories()).hasSize(1);
|
||||
assertThat(got.getBody().categories().getFirst().id()).isEqualTo("invoice");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT is scoped per team")
|
||||
void perTeam() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
controller.saveTaxonomy(sample());
|
||||
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L);
|
||||
assertThat(controller.getTaxonomy().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT is rejected for a non-editor when login is enabled")
|
||||
void putForbiddenForNonEditor() {
|
||||
loginEnabled(true);
|
||||
when(policyManagementAuthority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.saveTaxonomy(sample()))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT rejects an invalid taxonomy with 400")
|
||||
void putInvalid() {
|
||||
loginEnabled(false);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
controller.saveTaxonomy(
|
||||
new ClassificationTaxonomy(List.of(), List.of())))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DELETE resets the team back to no stored taxonomy")
|
||||
void deleteResets() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
controller.saveTaxonomy(sample());
|
||||
|
||||
ResponseEntity<Void> response = controller.resetTaxonomy();
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
assertThat(controller.getTaxonomy().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@DisplayName("TaxonomyValidator")
|
||||
class TaxonomyValidatorTest {
|
||||
|
||||
private static TaxonomyCategory category(String id, TaxonomyDocumentType... docTypes) {
|
||||
return new TaxonomyCategory(id, id + " label", List.of(docTypes));
|
||||
}
|
||||
|
||||
private static TaxonomyDocumentType docType(String id) {
|
||||
return new TaxonomyDocumentType(id, id + " label");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts a well-formed taxonomy")
|
||||
void acceptsValid() {
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(category("invoice", docType("receipt")), category("contract")),
|
||||
List.of("finance", "legal"));
|
||||
assertThatCode(() -> TaxonomyValidator.validate(taxonomy)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a taxonomy with no categories")
|
||||
void rejectsEmpty() {
|
||||
ClassificationTaxonomy taxonomy = new ClassificationTaxonomy(List.of(), List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("at least one category");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate category ids")
|
||||
void rejectsDuplicateCategory() {
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(category("invoice"), category("invoice")), List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate category id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate doc type ids within a category")
|
||||
void rejectsDuplicateDocType() {
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(category("invoice", docType("receipt"), docType("receipt"))),
|
||||
List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate doc type id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects blank ids and labels")
|
||||
void rejectsBlank() {
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(new TaxonomyCategory(" ", "label", List.of())), List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("must not be blank");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate tags")
|
||||
void rejectsDuplicateTags() {
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(category("invoice")), List.of("finance", "finance"));
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate tag");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects more categories than the cap")
|
||||
void rejectsTooManyCategories() {
|
||||
List<TaxonomyCategory> categories =
|
||||
java.util.stream.IntStream.rangeClosed(0, TaxonomyValidator.MAX_CATEGORIES)
|
||||
.mapToObj(i -> category("cat" + i))
|
||||
.toList();
|
||||
ClassificationTaxonomy taxonomy = new ClassificationTaxonomy(categories, List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Too many categories");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long label")
|
||||
void rejectsOverLongLabel() {
|
||||
String longLabel = "x".repeat(TaxonomyValidator.MAX_TEXT_LENGTH + 1);
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(new TaxonomyCategory("invoice", longLabel, List.of())), List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("too long");
|
||||
}
|
||||
}
|
||||
+2
@@ -56,6 +56,8 @@ class ClassifyTagControllerTest {
|
||||
pdfMetadataService,
|
||||
aiEngineClient,
|
||||
objectMapper,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
|
||||
+52
@@ -2,6 +2,7 @@ package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
@@ -229,6 +230,57 @@ class AiWorkflowServiceTest {
|
||||
// 1:1 mapping preserves each input's filename.
|
||||
assertEquals("a.pdf", result.getResultFiles().get(0).getFileName());
|
||||
assertEquals("b.pdf", result.getResultFiles().get(1).getFileName());
|
||||
// Each output points back at the input it came from so the client versions it in place.
|
||||
assertEquals(0, result.getResultFiles().get(0).getSourceIndex());
|
||||
assertEquals(1, result.getResultFiles().get(1).getSourceIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeOutputHasNoSourceIndex() throws IOException {
|
||||
MockMultipartFile a = pdf("a.pdf", "a-bytes");
|
||||
MockMultipartFile b = pdf("b.pdf", "b-bytes");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Merging"}
|
||||
"""
|
||||
.formatted(MERGE_ENDPOINT));
|
||||
when(toolMetadataService.isMultiInput(MERGE_ENDPOINT)).thenReturn(true);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(MERGE_ENDPOINT)).thenReturn(false);
|
||||
stubEndpoint(MERGE_ENDPOINT, pdfResource("merged-bytes", "merged.pdf"));
|
||||
stubFileStorage();
|
||||
|
||||
AiWorkflowResponse result =
|
||||
service.orchestrate(requestFor(new MockMultipartFile[] {a, b}, "merge these"));
|
||||
|
||||
// A merge draws on several inputs, so there is no single source to version in place.
|
||||
assertNull(result.getResultFiles().get(0).getSourceIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitOutputsHaveNoSourceIndex() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf", "original");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Splitting"}
|
||||
"""
|
||||
.formatted(SPLIT_ENDPOINT));
|
||||
when(toolMetadataService.isMultiInput(SPLIT_ENDPOINT)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(SPLIT_ENDPOINT)).thenReturn(true);
|
||||
stubEndpoint(
|
||||
SPLIT_ENDPOINT,
|
||||
zipResource(
|
||||
"doc.zip",
|
||||
List.of(
|
||||
new ZipEntryBytes("page-1.pdf", "page-one"),
|
||||
new ZipEntryBytes("page-2.pdf", "page-two"))));
|
||||
stubFileStorage();
|
||||
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(input, "split"));
|
||||
|
||||
// One input fanned out to many outputs, so none is a clean 1:1 version — the client adds
|
||||
// them as fresh files and leaves the original in place.
|
||||
assertNull(result.getResultFiles().get(0).getSourceIndex());
|
||||
assertNull(result.getResultFiles().get(1).getSourceIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Per-team classification taxonomy (gated by policies.enabled): the admin-editable vocabulary the
|
||||
-- document classifier runs against. One row per team; the whole taxonomy lives as JSON in
|
||||
-- taxonomy_json (authoritative on read). team_id is the natural key and a plain value (not a foreign
|
||||
-- key) to stay decoupled from the security entities, so classification can be enabled or disabled
|
||||
-- without touching them; the sentinel 0 holds the unteamed (login-disabled) taxonomy. Hibernate
|
||||
-- ddl-auto would also create this, but this keeps the schema explicit for the Flyway-managed
|
||||
-- deployments.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_taxonomies (
|
||||
team_id BIGINT PRIMARY KEY,
|
||||
taxonomy_json TEXT,
|
||||
updated_at TIMESTAMP,
|
||||
updated_by VARCHAR(255)
|
||||
);
|
||||
+1
-1
@@ -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",
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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?"
|
||||
|
||||
+7
-7
@@ -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;
|
||||
@@ -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 && (
|
||||
<DetailField
|
||||
label={t("filesPage.field.category", "Category")}
|
||||
value={prettyLabel(classification.category)}
|
||||
value={classification.categoryLabel}
|
||||
/>
|
||||
)}
|
||||
{classification.docType && (
|
||||
<DetailField
|
||||
label={t("filesPage.field.type", "Type")}
|
||||
value={prettyLabel(classification.docType)}
|
||||
value={classification.docTypeLabel}
|
||||
/>
|
||||
)}
|
||||
{classification.typeConfidence != null && (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Turn a human label into a url/id-safe slug: lowercased, with every run of
|
||||
* non-alphanumerics collapsed to a single hyphen and leading/trailing hyphens
|
||||
* trimmed. May return an empty string (e.g. an all-symbol input); callers that
|
||||
* need a non-empty id should supply their own fallback.
|
||||
*/
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
@@ -162,6 +162,11 @@ interface AiWorkflowResultFile {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
/**
|
||||
* Index into the files we sent that this output was derived from, or null/undefined when it has
|
||||
* no single source (merge, generated file). Used to replace that input in place as a new version.
|
||||
*/
|
||||
sourceIndex?: number | null;
|
||||
}
|
||||
|
||||
interface AiWorkflowResponse {
|
||||
@@ -432,9 +437,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Import the files produced by an AI workflow result into FileContext.
|
||||
//
|
||||
// If the workflow produced the same number of outputs as inputs, map each output to its
|
||||
// corresponding input as a new version in the same chain. Otherwise (merge, split, etc.)
|
||||
// add the outputs as new root files.
|
||||
// Each output carries a sourceIndex telling us which input it came from. An input that produced
|
||||
// exactly one output is replaced in place as a new version of that file; everything else (merge,
|
||||
// split, generated files, or an input that produced nothing) is added as a fresh root and leaves
|
||||
// the original files untouched — we never remove or deselect a file the workflow didn't clearly
|
||||
// transform 1:1.
|
||||
const importResultFile = useCallback(
|
||||
async (
|
||||
result: AiWorkflowResponse,
|
||||
@@ -456,27 +463,43 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const files = await Promise.all(descriptors.map(downloadFile));
|
||||
|
||||
if (sourceStubs.length > 0) {
|
||||
// Always consume the inputs so merge/split inputs are removed from the workbench.
|
||||
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
|
||||
// merge/split they're fresh roots.
|
||||
const operation: ToolOperation = {
|
||||
toolId: "ai-workflow",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const isVersionMapping = files.length === sourceStubs.length;
|
||||
const stubs = files.map((file, i) =>
|
||||
isVersionMapping
|
||||
? createChildStub(sourceStubs[i], operation, file)
|
||||
: createNewStirlingFileStub(file),
|
||||
);
|
||||
// Resolve each output to the input it came from (sourceIndex, from the backend).
|
||||
const sourceForOutput = descriptors.map((descriptor) => {
|
||||
const idx = descriptor.sourceIndex;
|
||||
return typeof idx === "number" && idx >= 0 && idx < sourceStubs.length
|
||||
? sourceStubs[idx]
|
||||
: null;
|
||||
});
|
||||
// Only replace a source in place when it maps to exactly one output (a clean 1:1 transform).
|
||||
// A split (one input → many outputs) or a source shared by several outputs stays a set of
|
||||
// fresh roots so we don't collapse them onto one version chain.
|
||||
const outputsPerSource = new Map<StirlingFileStub["id"], number>();
|
||||
for (const source of sourceForOutput) {
|
||||
if (source) {
|
||||
outputsPerSource.set(
|
||||
source.id,
|
||||
(outputsPerSource.get(source.id) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
const consumedIds: StirlingFileStub["id"][] = [];
|
||||
const stubs = files.map((file, i) => {
|
||||
const source = sourceForOutput[i];
|
||||
if (source && outputsPerSource.get(source.id) === 1) {
|
||||
consumedIds.push(source.id);
|
||||
return createChildStub(source, operation, file);
|
||||
}
|
||||
return createNewStirlingFileStub(file);
|
||||
});
|
||||
const stirlingFiles = files.map((file, i) =>
|
||||
createStirlingFile(file, stubs[i].id),
|
||||
);
|
||||
await fileActions.consumeFiles(
|
||||
sourceStubs.map((s) => s.id),
|
||||
stirlingFiles,
|
||||
stubs,
|
||||
);
|
||||
// Consume only the inputs we actually versioned; unrelated files are left in place.
|
||||
await fileActions.consumeFiles(consumedIds, stirlingFiles, stubs);
|
||||
} else {
|
||||
// No inputs: pass raw files so addFiles assigns consistent IDs. Pre-assigning stub IDs
|
||||
// here would cause a fileId mismatch in filesRef, making getFiles() clone the file
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The Classification policy's taxonomy control, shown in its Edit-Settings view.
|
||||
* Renders a compact summary (counts + category chips) with an Expand button that
|
||||
* opens the fat {@link TaxonomyEditorModal}. Owns the editable draft and the
|
||||
* load/save/reset/import/export wiring via {@link useClassificationTaxonomy}. The
|
||||
* taxonomy is team-shared; only users who can configure policies may edit it.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import { Card } from "@shared/components/Card";
|
||||
import { Button } from "@shared/components/Button";
|
||||
import { Chip } from "@shared/components/Chip";
|
||||
import { Banner } from "@shared/components/Banner";
|
||||
import { useClassificationTaxonomy } from "@app/hooks/useClassificationTaxonomy";
|
||||
import { TaxonomyEditorModal } from "@app/components/policies/TaxonomyEditorModal";
|
||||
import {
|
||||
downloadTaxonomy,
|
||||
parseTaxonomyFile,
|
||||
validateTaxonomy,
|
||||
} from "@app/services/taxonomyFile";
|
||||
import {
|
||||
DEFAULT_CLASSIFICATION_TAXONOMY,
|
||||
type ClassificationTaxonomy,
|
||||
} from "@app/data/classificationTaxonomy";
|
||||
import "@app/components/policies/TaxonomyEditor.css";
|
||||
|
||||
interface ClassificationTaxonomySectionProps {
|
||||
canConfigure: boolean;
|
||||
}
|
||||
|
||||
export function ClassificationTaxonomySection({
|
||||
canConfigure,
|
||||
}: ClassificationTaxonomySectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { taxonomy, isCustom, loading, saving, error, save } =
|
||||
useClassificationTaxonomy(true);
|
||||
|
||||
const [draft, setDraft] = useState<ClassificationTaxonomy>(taxonomy);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
// Sync the draft to server truth whenever it changes (load / save / reset).
|
||||
// Local edits don't change `taxonomy`, so this never clobbers them mid-edit.
|
||||
useEffect(() => setDraft(taxonomy), [taxonomy]);
|
||||
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(taxonomy),
|
||||
[draft, taxonomy],
|
||||
);
|
||||
|
||||
const subCount = useMemo(
|
||||
() => taxonomy.categories.reduce((n, c) => n + c.docTypes.length, 0),
|
||||
[taxonomy],
|
||||
);
|
||||
|
||||
const close = () => {
|
||||
setDraft(taxonomy);
|
||||
setLocalError(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onImportFile = (file: File) => {
|
||||
setLocalError(null);
|
||||
void parseTaxonomyFile(file)
|
||||
.then(setDraft)
|
||||
.catch((e: unknown) =>
|
||||
setLocalError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("policies.taxonomy.importError", "Couldn't import that file."),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const errors = validateTaxonomy(draft);
|
||||
if (errors.length > 0) {
|
||||
setLocalError(errors[0]);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
void save(draft).then(() => setOpen(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tax-summary">
|
||||
<p className="pol-section-label">
|
||||
{t("policies.taxonomy.sectionLabel", "Classification taxonomy")}
|
||||
</p>
|
||||
<Card>
|
||||
{loading ? (
|
||||
<span className="tax-empty">{t("loading", "Loading…")}</span>
|
||||
) : (
|
||||
<div className="tax-summary">
|
||||
<div className="tax-summary-stats">
|
||||
<span>
|
||||
<strong>{taxonomy.categories.length}</strong>{" "}
|
||||
{t("policies.taxonomy.categories", "categories")}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{subCount}</strong>{" "}
|
||||
{t("policies.taxonomy.subCategories", "sub-categories")}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{taxonomy.tags.length}</strong>{" "}
|
||||
{t("policies.taxonomy.tags", "tags")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tax-summary-cats">
|
||||
{taxonomy.categories.map((c) => (
|
||||
<Chip key={c.id} tone="neutral" size="sm">
|
||||
{c.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<span className="tax-empty">
|
||||
{isCustom
|
||||
? t("policies.taxonomy.customNote", "Customized for your team.")
|
||||
: t(
|
||||
"policies.taxonomy.defaultNote",
|
||||
"Using the built-in default, shared with your team.",
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leadingIcon={<OpenInFullIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => setOpen(true)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{canConfigure
|
||||
? t("policies.taxonomy.edit", "Edit taxonomy")
|
||||
: t("policies.taxonomy.view", "View taxonomy")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!canConfigure && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
icon={<LockOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={t(
|
||||
"policies.taxonomy.managedNote",
|
||||
"The taxonomy is managed by your team leader.",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TaxonomyEditorModal
|
||||
open={open}
|
||||
onClose={close}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onImportFile={onImportFile}
|
||||
onExport={() => downloadTaxonomy(draft)}
|
||||
onReset={() => {
|
||||
// Stage the built-in default into the draft — reversible via Cancel,
|
||||
// only persisted on Save (no immediate destructive server delete).
|
||||
setLocalError(null);
|
||||
setDraft(DEFAULT_CLASSIFICATION_TAXONOMY);
|
||||
}}
|
||||
onClear={() => {
|
||||
// Stage an empty taxonomy to build from scratch — also reversible until Save.
|
||||
setLocalError(null);
|
||||
setDraft({ categories: [], tags: [] });
|
||||
}}
|
||||
onSave={onSave}
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
readOnly={!canConfigure}
|
||||
error={localError ?? error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -128,6 +128,81 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Policy settings: per-trigger run-order lists ── */
|
||||
.pol-reorder-section {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.pol-reorder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
/* A reorder row: leading grip + tinted icon + label. Square (no radius) so the
|
||||
drop line reads as one straight rule across the list. */
|
||||
.pol-reorder-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1_5) var(--space-2);
|
||||
}
|
||||
.pol-reorder-row[data-dragging] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Straight, full-width blue insertion line at the drop position (no curves). */
|
||||
.pol-reorder-row[data-drop="above"]::before,
|
||||
.pol-reorder-row[data-drop="below"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--color-blue);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pol-reorder-row[data-drop="above"]::before {
|
||||
top: -1px;
|
||||
}
|
||||
.pol-reorder-row[data-drop="below"]::after {
|
||||
bottom: -1px;
|
||||
}
|
||||
.pol-reorder-grip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
color: var(--color-text-4);
|
||||
cursor: grab;
|
||||
}
|
||||
.pol-reorder-grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.pol-reorder-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
/* The drag ghost (a cloned row): the whole row with a full blue outline, kept
|
||||
translucent so the list shows through as it moves. */
|
||||
.pol-reorder-row--ghost {
|
||||
border-radius: var(--radius-lg);
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: -2px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* Empty-state line for a trigger with no policies. */
|
||||
.pol-reorder-empty {
|
||||
margin: var(--space-1) 0 0;
|
||||
padding: var(--space-1_5) var(--space-2);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* Retry button on a failed activity row. */
|
||||
/* Expandable error text in the activity feed — long backend errors are clamped
|
||||
and collapsed by default so they don't blow up the row. */
|
||||
|
||||
@@ -12,10 +12,20 @@
|
||||
* collapsed; clicking an icon selects the policy and expands the rail.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type DragEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu } from "@mantine/core";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import DragIndicatorRounded from "@mui/icons-material/DragIndicatorRounded";
|
||||
import MoreHorizRounded from "@mui/icons-material/MoreHorizRounded";
|
||||
import TuneRounded from "@mui/icons-material/TuneRounded";
|
||||
import InfoOutlined from "@mui/icons-material/InfoOutlined";
|
||||
import { usePolicies } from "@app/hooks/usePolicies";
|
||||
import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
@@ -44,12 +54,15 @@ import { SectionHeader } from "@shared/components/SectionHeader";
|
||||
import { PolicySetupWizard } from "@app/components/policies/PolicySetupWizard";
|
||||
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
|
||||
import { PolicyDeleteConfirmModal } from "@app/components/policies/PolicyDeleteConfirmModal";
|
||||
import type { PolicyConfigResult } from "@app/types/policies";
|
||||
import type { PolicyCategory, PolicyConfigResult } from "@app/types/policies";
|
||||
import { PanelHeader } from "@shared/components/PanelHeader";
|
||||
import {
|
||||
usePolicySelection,
|
||||
selectPolicy,
|
||||
setPolicyDetailView,
|
||||
closePolicy,
|
||||
openPolicySettings,
|
||||
closePolicySettings,
|
||||
} from "@app/components/policies/policySelectionStore";
|
||||
import "@app/components/policies/Policies.css";
|
||||
|
||||
@@ -95,8 +108,8 @@ function promptGuestSignup(): void {
|
||||
* place of the tool list. False when the feature is off or nothing is selected.
|
||||
*/
|
||||
export function usePolicyDetailActive(): boolean {
|
||||
const { selectedId } = usePolicySelection();
|
||||
return POLICIES_ENABLED && selectedId != null;
|
||||
const { selectedId, settingsOpen } = usePolicySelection();
|
||||
return POLICIES_ENABLED && (selectedId != null || settingsOpen);
|
||||
}
|
||||
|
||||
/** The collapsible policy list, rendered above the Tools section. */
|
||||
@@ -146,6 +159,13 @@ export function PoliciesSection({
|
||||
(c) => pol.policies[c.id]?.configured,
|
||||
).length;
|
||||
|
||||
// Rows render in execution order (defaults to catalog order until reordered
|
||||
// on the Policy settings page).
|
||||
const displayCategories = [...visibleCategories].sort(
|
||||
(a, b) =>
|
||||
(pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pol-list">
|
||||
<div className="pol-list-head">
|
||||
@@ -159,36 +179,51 @@ export function PoliciesSection({
|
||||
expanded={expanded}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
<AppTooltip
|
||||
content={t(
|
||||
"policies.sidebar.infoTooltip",
|
||||
"A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps.",
|
||||
)}
|
||||
sidebarTooltip
|
||||
pinOnClick
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pol-info-btn"
|
||||
aria-label={t(
|
||||
"policies.sidebar.infoAriaLabel",
|
||||
"What is a policy?",
|
||||
<Menu position="bottom-end" width="13rem" withinPortal>
|
||||
<Menu.Target>
|
||||
<button
|
||||
type="button"
|
||||
className="pol-info-btn"
|
||||
aria-label={t(
|
||||
"policies.sidebar.optionsAriaLabel",
|
||||
"Policy options",
|
||||
)}
|
||||
>
|
||||
<MoreHorizRounded sx={{ fontSize: "1.25rem" }} />
|
||||
</button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{/* Hovering surfaces the same explanation the info tooltip used to show. */}
|
||||
<AppTooltip
|
||||
content={t(
|
||||
"policies.sidebar.infoTooltip",
|
||||
"A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps.",
|
||||
)}
|
||||
position="left"
|
||||
maxWidth="16rem"
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<InfoOutlined sx={{ fontSize: "1rem" }} />}
|
||||
>
|
||||
{t("policies.sidebar.whatIsPolicy", "What is a policy?")}
|
||||
</Menu.Item>
|
||||
</AppTooltip>
|
||||
{pol.canConfigure && (
|
||||
<Menu.Item
|
||||
leftSection={<TuneRounded sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => openPolicySettings()}
|
||||
>
|
||||
{t("policies.sidebar.policySettings", "Policy settings")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="info-outline-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--icon-files-color)" }}
|
||||
/>
|
||||
</button>
|
||||
</AppTooltip>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<div className="pol-list-rows">
|
||||
{visibleCategories.map((cat) => {
|
||||
{displayCategories.map((cat) => {
|
||||
if (cat.comingSoon) {
|
||||
return (
|
||||
<div key={cat.id} className="pol-row pol-row--soon">
|
||||
@@ -258,11 +293,25 @@ export function PoliciesSection({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takeover dispatcher: shows the policy-settings page (execution order), an open
|
||||
* policy's detail, or nothing — whichever the selection store currently holds.
|
||||
* The heavy per-policy hooks live in {@link PolicyOpenDetail}, so the settings
|
||||
* page doesn't pay for (or trip over) them.
|
||||
*/
|
||||
export function PolicyDetailTakeover() {
|
||||
const { selectedId, settingsOpen } = usePolicySelection();
|
||||
if (!POLICIES_ENABLED) return null;
|
||||
if (settingsOpen && selectedId == null) return <PolicySettingsPanel />;
|
||||
if (selectedId == null) return null;
|
||||
return <PolicyOpenDetail />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-policy view — narrative detail, setup wizard, or edit-settings —
|
||||
* which replaces the Tools area while a policy is selected.
|
||||
*/
|
||||
export function PolicyDetailTakeover() {
|
||||
function PolicyOpenDetail() {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories, configs, sources, docTypes } = usePolicyCatalog();
|
||||
@@ -459,6 +508,232 @@ export function PolicyDetailTakeover() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Policy settings takeover — reached from the section header's "…" menu.
|
||||
* Each trigger (upload / export) gets its own run-order list, since a chain only
|
||||
* spans policies that fire on the same trigger — reordering one never affects the
|
||||
* other. Admin-only (the menu entry is gated on canConfigure).
|
||||
*/
|
||||
function PolicySettingsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
|
||||
// Configured, live policies for a trigger, in execution order.
|
||||
const inOrder = (trigger: "upload" | "export") =>
|
||||
categories
|
||||
.filter(
|
||||
(c) =>
|
||||
pol.policies[c.id]?.configured &&
|
||||
!c.comingSoon &&
|
||||
(pol.policies[c.id]?.runOn ?? "upload") === trigger,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0),
|
||||
);
|
||||
|
||||
const uploadCats = inOrder("upload");
|
||||
const exportCats = inOrder("export");
|
||||
|
||||
// Order is one global sort key, so persist both groups together (upload first)
|
||||
// to keep each group's members contiguous — the auto-run chain reads relative
|
||||
// order within a trigger.
|
||||
const persist = (uploadIds: string[], exportIds: string[]) =>
|
||||
pol.reorderPolicies([...uploadIds, ...exportIds]);
|
||||
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={<TuneRounded sx={{ fontSize: "1.1rem" }} />}
|
||||
title={t("policies.settings.title", "Policy settings")}
|
||||
onClose={() => closePolicySettings()}
|
||||
closeLabel={t("policies.detail.close", "Close")}
|
||||
/>
|
||||
<div className="pol-scroll">
|
||||
<p className="pol-desc">
|
||||
{t(
|
||||
"policies.settings.runOrderDesc",
|
||||
"When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder.",
|
||||
)}
|
||||
</p>
|
||||
{/* Both triggers are always shown so the run order for each is explicit,
|
||||
with an empty note when a trigger has no policies. */}
|
||||
<PolicyReorderSection
|
||||
title={t("policies.settings.onUpload", "On upload")}
|
||||
cats={uploadCats}
|
||||
emptyText={t(
|
||||
"policies.settings.noneUpload",
|
||||
"No policies currently run on upload.",
|
||||
)}
|
||||
onReorder={(ids) =>
|
||||
persist(
|
||||
ids,
|
||||
exportCats.map((c) => c.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PolicyReorderSection
|
||||
title={t("policies.settings.onExport", "On export")}
|
||||
cats={exportCats}
|
||||
emptyText={t(
|
||||
"policies.settings.noneExport",
|
||||
"No policies currently run on export.",
|
||||
)}
|
||||
onReorder={(ids) =>
|
||||
persist(
|
||||
uploadCats.map((c) => c.id),
|
||||
ids,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger's run-order list. Rows drag to reorder (only when there's more than
|
||||
* one to order); the drag ghost is the whole row with a blue outline, and a
|
||||
* straight blue line marks where the policy will land. Reorders in isolation and
|
||||
* hands the new id order back to the parent to persist.
|
||||
*/
|
||||
function PolicyReorderSection({
|
||||
title,
|
||||
cats,
|
||||
emptyText,
|
||||
onReorder,
|
||||
}: {
|
||||
title: string;
|
||||
cats: PolicyCategory[];
|
||||
emptyText: string;
|
||||
onReorder: (orderedIds: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
// Whether the drop would land after (vs before) the hovered row.
|
||||
const [overBelow, setOverBelow] = useState(false);
|
||||
const draggable = cats.length >= 2;
|
||||
|
||||
const clear = () => {
|
||||
setDragId(null);
|
||||
setOverId(null);
|
||||
};
|
||||
|
||||
const handleDrop = (targetId: string) => {
|
||||
if (!dragId || dragId === targetId) return clear();
|
||||
const ids = cats.map((c) => c.id);
|
||||
const from = ids.indexOf(dragId);
|
||||
let to = ids.indexOf(targetId) + (overBelow ? 1 : 0);
|
||||
if (from < 0 || to < 0) return clear();
|
||||
ids.splice(from, 1);
|
||||
if (from < to) to -= 1;
|
||||
ids.splice(to, 0, dragId);
|
||||
onReorder(ids);
|
||||
clear();
|
||||
};
|
||||
|
||||
// The native drag image would be just the grip under the cursor; instead snapshot
|
||||
// the whole row (a styled clone) so the ghost that follows the mouse is the full
|
||||
// row with a blue outline.
|
||||
const startDrag = (e: DragEvent<HTMLSpanElement>, catId: string) => {
|
||||
setDragId(catId);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
const row = (e.currentTarget as HTMLElement).closest(".pol-reorder-row");
|
||||
if (row instanceof HTMLElement) {
|
||||
const clone = row.cloneNode(true) as HTMLElement;
|
||||
clone.classList.add("pol-reorder-row--ghost");
|
||||
clone.style.width = `${row.offsetWidth}px`;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.top = "-1000px";
|
||||
clone.style.left = "-1000px";
|
||||
clone.style.pointerEvents = "none";
|
||||
document.body.appendChild(clone);
|
||||
e.dataTransfer.setDragImage(clone, 24, row.offsetHeight / 2);
|
||||
window.setTimeout(() => clone.remove(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
if (cats.length === 0) {
|
||||
return (
|
||||
<section className="pol-reorder-section">
|
||||
<p className="pol-section-label">{title}</p>
|
||||
<p className="pol-reorder-empty">{emptyText}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="pol-reorder-section">
|
||||
<p className="pol-section-label">{title}</p>
|
||||
<div className="pol-reorder-list">
|
||||
{cats.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="pol-reorder-row"
|
||||
data-dragging={dragId === cat.id || undefined}
|
||||
data-drop={
|
||||
draggable && overId === cat.id && dragId !== cat.id
|
||||
? overBelow
|
||||
? "below"
|
||||
: "above"
|
||||
: undefined
|
||||
}
|
||||
onDragOver={
|
||||
draggable
|
||||
? (e) => {
|
||||
if (!dragId) return;
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setOverId(cat.id);
|
||||
setOverBelow(e.clientY > rect.top + rect.height / 2);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragLeave={
|
||||
draggable
|
||||
? () => setOverId((id) => (id === cat.id ? null : id))
|
||||
: undefined
|
||||
}
|
||||
onDrop={
|
||||
draggable
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
handleDrop(cat.id);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{draggable && (
|
||||
<span
|
||||
className="pol-reorder-grip"
|
||||
draggable
|
||||
onDragStart={(e) => startDrag(e, cat.id)}
|
||||
onDragEnd={clear}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={t(
|
||||
"policies.settings.reorderHandle",
|
||||
"Drag to reorder",
|
||||
)}
|
||||
>
|
||||
<DragIndicatorRounded sx={{ fontSize: "1rem" }} />
|
||||
</span>
|
||||
)}
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
<span className="pol-reorder-label">
|
||||
{t(`policies.catalog.${cat.id}`, cat.label)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed-rail policy icons. Each tints blue when active and carries a small
|
||||
* status dot (green active / amber paused). Clicking selects the policy and
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@app/components/policies/PolicyWorkflowStep";
|
||||
import { PolicyToolConfigStep } from "@app/components/policies/PolicyToolConfigStep";
|
||||
import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
|
||||
import { ClassificationTaxonomySection } from "@app/components/policies/ClassificationTaxonomySection";
|
||||
|
||||
// Sources are always "editor" for this release, so the Sources step is dropped
|
||||
// from the flow (its panel code is kept below for when other sources return).
|
||||
@@ -103,6 +104,9 @@ export function PolicySetupWizard({
|
||||
// Preset (tool-chain) policies render the locked tool config as their Workflow
|
||||
// step instead of the add/remove builder.
|
||||
const toolChain = getPolicyToolChain(category.id);
|
||||
// A single-tool chain has nothing to toggle/configure, so its config UI is
|
||||
// hidden (kept mounted so the submit trigger still emits that one tool).
|
||||
const singleToolChain = toolChain != null && toolChain.length === 1;
|
||||
const { user } = useAuth();
|
||||
const [step, setStep] = useState(1);
|
||||
const [fieldValues, setFieldValues] = useState(() =>
|
||||
@@ -337,22 +341,29 @@ export function PolicySetupWizard({
|
||||
<div style={{ display: step === 1 ? undefined : "none" }}>
|
||||
{toolChain ? (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
{t(
|
||||
"policies.wizard.toolChainDesc",
|
||||
"Configure the tools this policy runs on each document.",
|
||||
)}
|
||||
</p>
|
||||
<PolicyToolConfigStep
|
||||
chainIds={toolChain}
|
||||
initialOperations={
|
||||
existingAutomation?.operations ?? config.defaultOperations
|
||||
}
|
||||
presetOperations={config.defaultOperations}
|
||||
categoryLabel={category.label}
|
||||
saveTriggerRef={workflowSave}
|
||||
onComplete={handleToolConfigSaved}
|
||||
/>
|
||||
{/* Single-tool chains have nothing to configure — hide the prompt
|
||||
and the toggle, but keep the step mounted (display:none) so the
|
||||
final submit still emits that one tool. */}
|
||||
{!singleToolChain && (
|
||||
<p className="pol-desc">
|
||||
{t(
|
||||
"policies.wizard.toolChainDesc",
|
||||
"Configure the tools this policy runs on each document.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: singleToolChain ? "none" : undefined }}>
|
||||
<PolicyToolConfigStep
|
||||
chainIds={toolChain}
|
||||
initialOperations={
|
||||
existingAutomation?.operations ?? config.defaultOperations
|
||||
}
|
||||
presetOperations={config.defaultOperations}
|
||||
categoryLabel={category.label}
|
||||
saveTriggerRef={workflowSave}
|
||||
onComplete={handleToolConfigSaved}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -371,6 +382,12 @@ export function PolicySetupWizard({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* The Classification policy owns the editable, team-shared taxonomy
|
||||
(categories → sub-categories → tags) the classifier runs against.
|
||||
Kept on the first step alongside the tool so it's not buried. */}
|
||||
{category.id === "classification" && (
|
||||
<ClassificationTaxonomySection canConfigure={canConfigure} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === 2 && (
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
.tax-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* --- Category / sub-category table (grid rows, not a <table>). --- */
|
||||
.tax-table {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.tax-head,
|
||||
.tax-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5rem minmax(0, 1fr) minmax(3rem, 9rem) 1.75rem;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tax-head {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-bg-muted);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tax-empty-row {
|
||||
padding: var(--space-4, 1rem) var(--space-3);
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.tax-head-label,
|
||||
.tax-head-id {
|
||||
grid-column: auto;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.tax-head-label {
|
||||
grid-column: 2;
|
||||
}
|
||||
.tax-head-id {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.tax-group + .tax-group {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tax-row {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
|
||||
.tax-row-category {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.tax-row-category:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.tax-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tax-name .sui-input,
|
||||
.tax-name input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tax-subcount {
|
||||
flex: none;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tax-label-text {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.tax-id-text {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Indented, guide-lined sub-category block. */
|
||||
.tax-subs {
|
||||
margin: 0 var(--space-3) var(--space-2) 1.6rem;
|
||||
padding: var(--space-1) 0;
|
||||
border-left: 2px solid var(--border-default);
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.tax-row-doctype {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(3rem, 9rem) 1.75rem;
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
.tax-row-doctype:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.tax-toggle,
|
||||
.tax-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.tax-toggle:hover,
|
||||
.tax-icon-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.tax-icon-btn-danger:hover {
|
||||
background: var(--color-red-light);
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
/* Sub-category delete buttons are quiet until you hover (or keyboard-focus) the
|
||||
row; the parent category's delete stays visible without hover. */
|
||||
.tax-row-doctype .tax-icon-btn {
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--motion-fast),
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.tax-row-doctype:hover .tax-icon-btn,
|
||||
.tax-row-doctype:focus-within .tax-icon-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tax-add-sub {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin: var(--space-1) 0 var(--space-1) var(--space-3);
|
||||
padding: 0.3rem 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-blue);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tax-add-sub:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* --- Tags --- */
|
||||
.tax-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tax-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1_5, 0.375rem);
|
||||
}
|
||||
|
||||
.tax-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.15rem 0.3rem 0.15rem 0.6rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.tax-tag-label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tax-tag-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-4);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.tax-tag-remove:hover {
|
||||
background: var(--color-red-light);
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.tax-tag-add {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tax-empty {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* --- Fat modal: give the editor room beyond the sidebar's width. --- */
|
||||
.tax-modal {
|
||||
width: min(1100px, 94vw);
|
||||
max-width: min(1100px, 94vw);
|
||||
}
|
||||
|
||||
.tax-modal-body {
|
||||
max-height: min(70vh, 640px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* --- Compact summary shown inline in the settings step. --- */
|
||||
.tax-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tax-summary-stats {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.tax-summary-stats strong {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.tax-summary-cats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
max-height: 6.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tax-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tax-toolbar-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* --- Modal footer: destructive actions left, Save/Cancel right. --- */
|
||||
.tax-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tax-footer-left,
|
||||
.tax-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Editable view of a classification taxonomy: a table of categories, each with a
|
||||
* collapsible, indented list of sub-categories (doc types), plus the
|
||||
* free-standing tags. Purely presentational — it renders a draft and emits a new
|
||||
* draft on every edit; the owner decides when to persist. Ids are never edited
|
||||
* directly: they're derived from the label (lowercased, spaces → hyphens) and
|
||||
* shown read-only. `readOnly` renders the same layout without edit affordances.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import { Input } from "@shared/components/Input";
|
||||
import { Button } from "@shared/components/Button";
|
||||
import { slugify } from "@app/utils/slug";
|
||||
import type {
|
||||
ClassificationTaxonomy,
|
||||
DocumentCategory,
|
||||
} from "@app/data/classificationTaxonomy";
|
||||
import "@app/components/policies/TaxonomyEditor.css";
|
||||
|
||||
interface TaxonomyEditorProps {
|
||||
value: ClassificationTaxonomy;
|
||||
onChange: (next: ClassificationTaxonomy) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function TaxonomyEditor({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: TaxonomyEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
// Track expansion by category index (stable while editing, since ids change
|
||||
// as labels are typed and rows aren't reordered).
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set([0]));
|
||||
const [newTag, setNewTag] = useState("");
|
||||
|
||||
const toggle = (index: number) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
|
||||
const setCategories = (categories: DocumentCategory[]) =>
|
||||
onChange({ ...value, categories });
|
||||
|
||||
const updateCategory = (index: number, patch: Partial<DocumentCategory>) =>
|
||||
setCategories(
|
||||
value.categories.map((c, i) => (i === index ? { ...c, ...patch } : c)),
|
||||
);
|
||||
|
||||
const renameCategory = (index: number, label: string) =>
|
||||
updateCategory(index, { label, id: slugify(label) });
|
||||
|
||||
const addCategory = () => {
|
||||
setExpanded((prev) => new Set(prev).add(value.categories.length));
|
||||
setCategories([...value.categories, { id: "", label: "", docTypes: [] }]);
|
||||
};
|
||||
|
||||
const removeCategory = (index: number) =>
|
||||
setCategories(value.categories.filter((_, i) => i !== index));
|
||||
|
||||
const renameDocType = (catIndex: number, docIndex: number, label: string) =>
|
||||
updateCategory(catIndex, {
|
||||
docTypes: value.categories[catIndex].docTypes.map((d, i) =>
|
||||
i === docIndex ? { id: slugify(label), label } : d,
|
||||
),
|
||||
});
|
||||
|
||||
const addDocType = (catIndex: number) => {
|
||||
setExpanded((prev) => new Set(prev).add(catIndex));
|
||||
updateCategory(catIndex, {
|
||||
docTypes: [...value.categories[catIndex].docTypes, { id: "", label: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
const removeDocType = (catIndex: number, docIndex: number) =>
|
||||
updateCategory(catIndex, {
|
||||
docTypes: value.categories[catIndex].docTypes.filter(
|
||||
(_, i) => i !== docIndex,
|
||||
),
|
||||
});
|
||||
|
||||
const addTag = () => {
|
||||
const tag = slugify(newTag);
|
||||
if (tag === "" || value.tags.includes(tag)) return;
|
||||
onChange({ ...value, tags: [...value.tags, tag] });
|
||||
setNewTag("");
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) =>
|
||||
onChange({ ...value, tags: value.tags.filter((tg) => tg !== tag) });
|
||||
|
||||
return (
|
||||
<div className="tax-editor">
|
||||
<div className="tax-table" role="table">
|
||||
<div className="tax-head" role="row">
|
||||
<span className="tax-head-label">
|
||||
{t("policies.taxonomy.categoryLabel", "Category")}
|
||||
</span>
|
||||
<span className="tax-head-id">{t("policies.taxonomy.id", "ID")}</span>
|
||||
{!readOnly && <span className="tax-head-actions" aria-hidden />}
|
||||
</div>
|
||||
|
||||
{value.categories.length === 0 && (
|
||||
<div className="tax-empty-row">
|
||||
{t(
|
||||
"policies.taxonomy.emptyCategories",
|
||||
"No categories yet — add one to get started.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.categories.map((category, catIndex) => {
|
||||
const isOpen = expanded.has(catIndex);
|
||||
return (
|
||||
<div className="tax-group" key={catIndex}>
|
||||
<div className="tax-row tax-row-category" role="row">
|
||||
<button
|
||||
type="button"
|
||||
className="tax-toggle"
|
||||
onClick={() => toggle(catIndex)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={
|
||||
isOpen
|
||||
? t("policies.taxonomy.collapse", "Collapse")
|
||||
: t("policies.taxonomy.expand", "Expand")
|
||||
}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ExpandMoreIcon sx={{ fontSize: "1.15rem" }} />
|
||||
) : (
|
||||
<ChevronRightIcon sx={{ fontSize: "1.15rem" }} />
|
||||
)}
|
||||
</button>
|
||||
<div className="tax-name">
|
||||
{readOnly ? (
|
||||
<span className="tax-label-text">{category.label}</span>
|
||||
) : (
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={category.label}
|
||||
onChange={(e) => renameCategory(catIndex, e.target.value)}
|
||||
placeholder={t(
|
||||
"policies.taxonomy.categoryLabel",
|
||||
"Category",
|
||||
)}
|
||||
aria-label={t(
|
||||
"policies.taxonomy.categoryLabel",
|
||||
"Category",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="tax-subcount">
|
||||
{t("policies.taxonomy.subCount", "{{count}} sub", {
|
||||
count: category.docTypes.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<code className="tax-id-text">{category.id}</code>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="tax-icon-btn tax-icon-btn-danger"
|
||||
onClick={() => removeCategory(catIndex)}
|
||||
aria-label={t(
|
||||
"policies.taxonomy.removeCategory",
|
||||
"Remove category",
|
||||
)}
|
||||
>
|
||||
<DeleteOutlineIcon sx={{ fontSize: "1.15rem" }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="tax-subs">
|
||||
{category.docTypes.map((docType, docIndex) => (
|
||||
<div
|
||||
className="tax-row tax-row-doctype"
|
||||
role="row"
|
||||
key={docIndex}
|
||||
>
|
||||
<div className="tax-name">
|
||||
{readOnly ? (
|
||||
<span className="tax-label-text">
|
||||
{docType.label}
|
||||
</span>
|
||||
) : (
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={docType.label}
|
||||
onChange={(e) =>
|
||||
renameDocType(catIndex, docIndex, e.target.value)
|
||||
}
|
||||
placeholder={t(
|
||||
"policies.taxonomy.subLabel",
|
||||
"Sub-category",
|
||||
)}
|
||||
aria-label={t(
|
||||
"policies.taxonomy.subLabel",
|
||||
"Sub-category",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<code className="tax-id-text">{docType.id}</code>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="tax-icon-btn tax-icon-btn-danger"
|
||||
onClick={() => removeDocType(catIndex, docIndex)}
|
||||
aria-label={t(
|
||||
"policies.taxonomy.removeSub",
|
||||
"Remove sub-category",
|
||||
)}
|
||||
>
|
||||
<DeleteOutlineIcon sx={{ fontSize: "1.05rem" }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="tax-add-sub"
|
||||
onClick={() => addDocType(catIndex)}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: "1rem" }} />
|
||||
{t("policies.taxonomy.addSub", "Add sub-category")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leadingIcon={<AddIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={addCategory}
|
||||
>
|
||||
{t("policies.taxonomy.addCategory", "Add category")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="tax-tags">
|
||||
<p className="pol-section-label">
|
||||
{t("policies.taxonomy.tags", "Tags")}
|
||||
</p>
|
||||
<div className="tax-tag-list">
|
||||
{value.tags.length === 0 && (
|
||||
<span className="tax-empty">
|
||||
{t("policies.taxonomy.noTags", "No tags yet.")}
|
||||
</span>
|
||||
)}
|
||||
{value.tags.map((tag) => (
|
||||
<span className="tax-tag" key={tag}>
|
||||
<span className="tax-tag-label">{tag}</span>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="tax-tag-remove"
|
||||
onClick={() => removeTag(tag)}
|
||||
aria-label={t(
|
||||
"policies.taxonomy.removeTag",
|
||||
"Remove {{tag}}",
|
||||
{ tag },
|
||||
)}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "0.85rem" }} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="tax-tag-add">
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"policies.taxonomy.addTagPlaceholder",
|
||||
"Add a tag",
|
||||
)}
|
||||
aria-label={t("policies.taxonomy.addTag", "Add a tag")}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={addTag}>
|
||||
{t("policies.taxonomy.add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Full-screen ("fat") editor for the classification taxonomy — the roomy view the
|
||||
* sidebar's Expand button opens. Hosts the {@link TaxonomyEditor} table plus an
|
||||
* Import/Export toolbar and a footer holding the destructive actions (reset /
|
||||
* start-from-scratch) and the Save/Cancel buttons. Editing is staged: nothing is
|
||||
* persisted until Save, which stays disabled until the draft actually changes.
|
||||
* The draft is owned by the caller so the sidebar summary reflects saved changes.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
||||
import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import DeleteSweepOutlinedIcon from "@mui/icons-material/DeleteSweepOutlined";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { Modal } from "@shared/components/Modal";
|
||||
import { Button } from "@shared/components/Button";
|
||||
import { Banner } from "@shared/components/Banner";
|
||||
import { TaxonomyEditor } from "@app/components/policies/TaxonomyEditor";
|
||||
import type { ClassificationTaxonomy } from "@app/data/classificationTaxonomy";
|
||||
|
||||
interface TaxonomyEditorModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
draft: ClassificationTaxonomy;
|
||||
onDraftChange: (next: ClassificationTaxonomy) => void;
|
||||
onImportFile: (file: File) => void;
|
||||
onExport: () => void;
|
||||
/** Stage the built-in default into the draft. */
|
||||
onReset: () => void;
|
||||
/** Stage an empty taxonomy into the draft (build from scratch). */
|
||||
onClear: () => void;
|
||||
onSave: () => void;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
readOnly: boolean;
|
||||
/** Save/reset (server) or import (file) failure to surface, if any. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function TaxonomyEditorModal({
|
||||
open,
|
||||
onClose,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onImportFile,
|
||||
onExport,
|
||||
onReset,
|
||||
onClear,
|
||||
onSave,
|
||||
dirty,
|
||||
saving,
|
||||
readOnly,
|
||||
error,
|
||||
}: TaxonomyEditorModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset the input so picking the same file twice still fires onChange.
|
||||
e.target.value = "";
|
||||
if (file) onImportFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="xl"
|
||||
className="tax-modal"
|
||||
title={t("policies.taxonomy.modalTitle", "Classification taxonomy")}
|
||||
subtitle={t(
|
||||
"policies.taxonomy.modalSubtitle",
|
||||
"Shared with your whole team. Categories, their sub-categories, and tags the classifier uses.",
|
||||
)}
|
||||
footer={
|
||||
<div className="tax-footer">
|
||||
<div className="tax-footer-left">
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
accent="red"
|
||||
size="sm"
|
||||
leadingIcon={
|
||||
<DeleteSweepOutlinedIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
onClick={onClear}
|
||||
disabled={saving}
|
||||
>
|
||||
{t(
|
||||
"policies.taxonomy.startFromScratch",
|
||||
"Start from scratch",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
accent="red"
|
||||
size="sm"
|
||||
leadingIcon={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={onReset}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("policies.taxonomy.resetToDefault", "Reset to default")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="tax-footer-right">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{readOnly ? t("close", "Close") : t("cancel", "Cancel")}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving
|
||||
? t("policies.taxonomy.saving", "Saving…")
|
||||
: t("policies.taxonomy.saveForTeam", "Save for team")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="tax-modal-body">
|
||||
{error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={error}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div className="tax-toolbar">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leadingIcon={<FileUploadOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
{t("policies.taxonomy.import", "Import JSON")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leadingIcon={
|
||||
<FileDownloadOutlinedIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
onClick={onExport}
|
||||
>
|
||||
{t("policies.taxonomy.export", "Export JSON")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<TaxonomyEditor
|
||||
value={draft}
|
||||
onChange={onDraftChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,16 @@ import type { PolicyDetailView } from "@app/types/policies";
|
||||
interface PolicySelection {
|
||||
selectedId: string | null;
|
||||
detailView: PolicyDetailView;
|
||||
/** The policy-settings page (execution order) takes over the rail. Independent
|
||||
* of {@link selectedId} — it's a section-level view, not tied to one policy. */
|
||||
settingsOpen: boolean;
|
||||
}
|
||||
|
||||
let state: PolicySelection = { selectedId: null, detailView: "detail" };
|
||||
let state: PolicySelection = {
|
||||
selectedId: null,
|
||||
detailView: "detail",
|
||||
settingsOpen: false,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
@@ -37,6 +44,7 @@ function getSnapshot(): PolicySelection {
|
||||
const SERVER_SNAPSHOT: PolicySelection = {
|
||||
selectedId: null,
|
||||
detailView: "detail",
|
||||
settingsOpen: false,
|
||||
};
|
||||
function getServerSnapshot(): PolicySelection {
|
||||
return SERVER_SNAPSHOT;
|
||||
@@ -44,7 +52,7 @@ function getServerSnapshot(): PolicySelection {
|
||||
|
||||
/** Open a policy's detail (resets the sub-view to the narrative). */
|
||||
export function selectPolicy(id: string | null) {
|
||||
state = { selectedId: id, detailView: "detail" };
|
||||
state = { selectedId: id, detailView: "detail", settingsOpen: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -55,6 +63,19 @@ export function setPolicyDetailView(view: PolicyDetailView) {
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Open the policy-settings page (execution order). Clears any open policy. */
|
||||
export function openPolicySettings() {
|
||||
state = { selectedId: null, detailView: "detail", settingsOpen: true };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Close the policy-settings page and return to the list. */
|
||||
export function closePolicySettings() {
|
||||
if (!state.settingsOpen) return;
|
||||
state = { ...state, settingsOpen: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Close the open policy and return to the list. */
|
||||
export function closePolicy() {
|
||||
selectPolicy(null);
|
||||
@@ -62,7 +83,7 @@ export function closePolicy() {
|
||||
|
||||
/** Reset to the initial state — used by tests to isolate the module store. */
|
||||
export function resetPolicySelection() {
|
||||
state = { selectedId: null, detailView: "detail" };
|
||||
state = { selectedId: null, detailView: "detail", settingsOpen: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// Two active upload policies, so the auto-run should CHAIN them: fire the first on
|
||||
// the upload, then the second on the first's output. Stub the contexts + network so
|
||||
// we can drive the dispatch against the REAL run store.
|
||||
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
|
||||
const fileStubs: { id: string; name: string; derivedFromTool?: boolean }[] = [];
|
||||
vi.mock("@app/contexts/FileContext", () => ({
|
||||
useAllFiles: () => ({ fileStubs }),
|
||||
useFileManagement: () => ({ addFiles: vi.fn() }),
|
||||
useFileContext: () => ({ consumeFiles: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@app/hooks/usePolicies", () => ({
|
||||
usePolicies: () => ({
|
||||
policies: {
|
||||
security: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
backendId: "backend-sec",
|
||||
runOn: "upload",
|
||||
order: 0,
|
||||
},
|
||||
classification: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
backendId: "backend-cls",
|
||||
runOn: "upload",
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@app/services/policyApi", () => ({
|
||||
runStoredPolicy: vi.fn(),
|
||||
getPolicyRun: vi.fn(),
|
||||
downloadPolicyOutput: vi.fn(),
|
||||
resolvePolicyRunTarget: () => "saas",
|
||||
}));
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() },
|
||||
}));
|
||||
vi.mock("@app/contexts/IndexedDBContext", () => ({
|
||||
useIndexedDB: () => ({ bumpRevision: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
|
||||
import {
|
||||
recordRunStart,
|
||||
updateRun,
|
||||
resetPolicyRuns,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import { runStoredPolicy } from "@app/services/policyApi";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
|
||||
const runStored = vi.mocked(runStoredPolicy);
|
||||
const getFile = vi.mocked(fileStorage.getStirlingFile);
|
||||
|
||||
/** Reset the shared file list between tests without swapping the array identity. */
|
||||
function setFileStubs(next: typeof fileStubs) {
|
||||
fileStubs.length = 0;
|
||||
fileStubs.push(...next);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
resetPolicyRuns();
|
||||
setFileStubs([]);
|
||||
runStored.mockReset();
|
||||
getFile.mockReset();
|
||||
getFile.mockResolvedValue({ size: 100 } as never);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("auto-run ordered chaining", () => {
|
||||
it("dispatches only the FIRST ordered policy on upload, not the whole set", async () => {
|
||||
setFileStubs([{ id: "file-1", name: "doc.pdf" }]);
|
||||
runStored.mockResolvedValue("run-sec");
|
||||
|
||||
renderHook(() => usePolicyAutoRun());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
// The first policy (order 0) runs on the upload; the second waits for the chain.
|
||||
expect(runStored).toHaveBeenCalledTimes(1);
|
||||
expect(runStored).toHaveBeenCalledWith("backend-sec", [{ size: 100 }]);
|
||||
});
|
||||
|
||||
it("chains the next policy onto a completed run's output", async () => {
|
||||
// A first-policy run that has completed and imported its output as file-1-v2.
|
||||
recordRunStart({
|
||||
runId: "run-sec",
|
||||
categoryId: "security",
|
||||
fileId: "file-1",
|
||||
fileName: "doc.pdf",
|
||||
fileSize: 100,
|
||||
target: "saas",
|
||||
status: "PENDING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
startedAt: 0,
|
||||
});
|
||||
updateRun("run-sec", {
|
||||
status: "COMPLETED",
|
||||
imported: true,
|
||||
outputFileIds: ["file-1-v2"],
|
||||
});
|
||||
runStored.mockResolvedValue("run-cls");
|
||||
|
||||
renderHook(() => usePolicyAutoRun());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
});
|
||||
|
||||
// The next policy (order 1) fires on the first policy's output, not the original.
|
||||
expect(runStored).toHaveBeenCalledWith("backend-cls", [{ size: 100 }]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* Auto-run controller: every enabled policy enforces on every uploaded
|
||||
* file. Watches the session's files and, for each (active policy × not-yet-run
|
||||
* file), fires a real backend run (`POST /api/v1/policies/{id}/run`) and polls it
|
||||
* to completion, recording progress in {@link policyRunStore} for the activity
|
||||
* feed.
|
||||
* Auto-run controller: every enabled policy enforces on every uploaded file.
|
||||
* Watches the session's files and fires a real backend run
|
||||
* (`POST /api/v1/policies/{id}/run`) per file, polling it to completion and
|
||||
* recording progress in {@link policyRunStore} for the activity feed.
|
||||
*
|
||||
* When several policies enforce on the same trigger they run as an ordered chain:
|
||||
* the first fires on the upload, and each subsequent policy fires on the previous
|
||||
* one's output once it lands — so their effects accumulate in the admin-defined
|
||||
* order rather than racing to fork the same version.
|
||||
*
|
||||
* Headless — call it from {@link PolicyAutoRunController}, which is mounted once
|
||||
* wherever the editor is open so enforcement happens regardless of whether the
|
||||
@@ -11,7 +15,7 @@
|
||||
* in the run store), so re-renders and remounts don't re-fire.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
useAllFiles,
|
||||
useFileManagement,
|
||||
@@ -141,6 +145,29 @@ export function usePolicyAutoRun(): void {
|
||||
// run so a folder-watch burst opens the modal once, not once per file.
|
||||
const firedLimitModal = useRef<Set<string>>(new Set());
|
||||
|
||||
// Active upload policies in execution order. When several enforce on upload they
|
||||
// run as a chain — the first fires on the upload, each subsequent one on the
|
||||
// previous policy's output — so their effects accumulate in a defined order
|
||||
// instead of racing to fork the same version.
|
||||
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<Set<string>>(new Set());
|
||||
|
||||
// Latest policies, read from inside the stable retry callback (which has no deps).
|
||||
const policiesRef = useRef(policies);
|
||||
policiesRef.current = policies;
|
||||
@@ -204,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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Loads and persists the team's classification taxonomy. The backend
|
||||
* (`/api/v1/classification/taxonomy`) is the source of truth and is shared by
|
||||
* the whole team; a team with none falls back to the built-in default. Editing
|
||||
* is gated to team leaders / admins by the backend — the caller passes
|
||||
* `canConfigure` (the same policy gate) to keep read-only users out of the save
|
||||
* path.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
DEFAULT_CLASSIFICATION_TAXONOMY,
|
||||
type ClassificationTaxonomy,
|
||||
} from "@app/data/classificationTaxonomy";
|
||||
import {
|
||||
fetchTeamTaxonomy,
|
||||
saveTeamTaxonomy,
|
||||
} from "@app/services/taxonomyBackend";
|
||||
|
||||
export interface UseClassificationTaxonomy {
|
||||
/** Server-truth taxonomy (or the built-in default when the team has none). */
|
||||
taxonomy: ClassificationTaxonomy;
|
||||
/** Whether the team has a stored taxonomy (vs. the built-in default). */
|
||||
isCustom: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
/** Last save failure, cleared on the next attempt. */
|
||||
error: string | null;
|
||||
/** Persist a taxonomy for the team; resolves once server state is updated. */
|
||||
save: (next: ClassificationTaxonomy) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useClassificationTaxonomy(
|
||||
enabled: boolean,
|
||||
): UseClassificationTaxonomy {
|
||||
const [taxonomy, setTaxonomy] = useState<ClassificationTaxonomy>(
|
||||
DEFAULT_CLASSIFICATION_TAXONOMY,
|
||||
);
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const stored = await fetchTeamTaxonomy();
|
||||
if (cancelled) return;
|
||||
setTaxonomy(stored ?? DEFAULT_CLASSIFICATION_TAXONOMY);
|
||||
setIsCustom(stored != null);
|
||||
} catch {
|
||||
// Backend down / not permitted — fall back to the default (read-only).
|
||||
if (!cancelled) {
|
||||
setTaxonomy(DEFAULT_CLASSIFICATION_TAXONOMY);
|
||||
setIsCustom(false);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const save = useCallback(async (next: ClassificationTaxonomy) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await saveTeamTaxonomy(next);
|
||||
setTaxonomy(saved);
|
||||
setIsCustom(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't save the taxonomy.");
|
||||
throw e;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { taxonomy, isCustom, loading, saving, error, save };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
onPoliciesChange,
|
||||
updatePolicy,
|
||||
resetPolicy,
|
||||
reorderPolicies as persistPolicyOrder,
|
||||
} from "@app/services/policyStorage";
|
||||
import { loadPolicyCatalog } from "@app/services/policyCatalog";
|
||||
import {
|
||||
@@ -90,7 +91,12 @@ export function usePolicies() {
|
||||
const decoded = byCategory.get(cat.id);
|
||||
reconciled[cat.id] = decoded
|
||||
? decodedToState(decoded, local[cat.id]?.folderId)
|
||||
: { ...local[cat.id], configured: false, status: "default" };
|
||||
: {
|
||||
...local[cat.id],
|
||||
configured: false,
|
||||
status: "default",
|
||||
backendId: undefined,
|
||||
};
|
||||
}
|
||||
for (const [id, state] of Object.entries(reconciled)) {
|
||||
updatePolicy(id, state);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(): {
|
||||
|
||||
@@ -53,14 +53,17 @@ export function loadPolicies(): PoliciesByCategory {
|
||||
// Always reconcile against the current category list so a newly-added
|
||||
// category gets a default rather than being undefined.
|
||||
const out: PoliciesByCategory = {};
|
||||
for (const cat of loadPolicyCatalog().categories) {
|
||||
loadPolicyCatalog().categories.forEach((cat, index) => {
|
||||
const merged = { ...defaultState(), ...(parsed[cat.id] ?? {}) };
|
||||
// Migration: clear the obsolete persisted reviewer email so it re-defaults
|
||||
// to the real signed-in user.
|
||||
if (merged.reviewerEmail === STALE_REVIEWER_EMAIL)
|
||||
merged.reviewerEmail = "";
|
||||
// Default execution order to the catalog position until an admin reorders,
|
||||
// so ordered dispatch is deterministic before any explicit order is set.
|
||||
if (merged.order == null) merged.order = index;
|
||||
out[cat.id] = merged;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -97,6 +100,24 @@ export function updatePolicy(
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a new execution order. Assigns `order` 0..n-1 to the given categories in
|
||||
* the sequence provided, so after any reorder every listed policy has an explicit,
|
||||
* contiguous order (no reliance on the catalog-index default). Categories omitted
|
||||
* from the list keep their current order.
|
||||
*/
|
||||
export function reorderPolicies(
|
||||
orderedCategoryIds: string[],
|
||||
): PoliciesByCategory {
|
||||
const current = loadPolicies();
|
||||
const next: PoliciesByCategory = { ...current };
|
||||
orderedCategoryIds.forEach((id, index) => {
|
||||
if (next[id]) next[id] = { ...next[id], order: index };
|
||||
});
|
||||
persist(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Reset a category to its unconfigured default (the "Delete policy" action). */
|
||||
export function resetPolicy(categoryId: string): PoliciesByCategory {
|
||||
return updatePolicy(categoryId, {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Backend layer for the team-scoped classification taxonomy
|
||||
* (`/api/v1/classification/taxonomy`). The backend is the source of truth: the
|
||||
* whole team shares one taxonomy, editable only by a team leader (SaaS) / admin
|
||||
* (self-hosted). A team with no stored taxonomy reads as 204 → `null`, and
|
||||
* callers fall back to the built-in {@link DEFAULT_CLASSIFICATION_TAXONOMY}.
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import type { ClassificationTaxonomy } from "@app/data/classificationTaxonomy";
|
||||
|
||||
const ENDPOINT = "/api/v1/classification/taxonomy";
|
||||
|
||||
/** The team's stored taxonomy, or `null` when it has none (use the default). */
|
||||
export async function fetchTeamTaxonomy(): Promise<ClassificationTaxonomy | null> {
|
||||
const res = await apiClient.get<ClassificationTaxonomy | "">(ENDPOINT, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
// 204 No Content (no stored taxonomy) comes back as an empty body. Only an
|
||||
// explicit 204 / empty string means "none"; anything else is a real payload.
|
||||
if (res.status === 204 || res.data === "") return null;
|
||||
return res.data as ClassificationTaxonomy;
|
||||
}
|
||||
|
||||
/** Persist the team's taxonomy; returns the stored value. */
|
||||
export async function saveTeamTaxonomy(
|
||||
taxonomy: ClassificationTaxonomy,
|
||||
): Promise<ClassificationTaxonomy> {
|
||||
const res = await apiClient.put<ClassificationTaxonomy>(ENDPOINT, taxonomy);
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Client-side import/export + validation for a classification taxonomy JSON file.
|
||||
* Sharing a taxonomy between teams is done by exporting the JSON here and
|
||||
* importing it on another team. Validation mirrors the backend
|
||||
* (`TaxonomyValidator`) so a malformed file is caught before it's uploaded — the
|
||||
* backend re-validates as the authority.
|
||||
*/
|
||||
|
||||
import { downloadJsonAsFile } from "@app/utils/downloadUtils";
|
||||
import type {
|
||||
ClassificationTaxonomy,
|
||||
DocumentCategory,
|
||||
DocumentType,
|
||||
} from "@app/data/classificationTaxonomy";
|
||||
|
||||
// Kept in sync with the backend TaxonomyValidator (the authority); enforced here
|
||||
// too so an oversized import is rejected before upload.
|
||||
const MAX_CATEGORIES = 200;
|
||||
const MAX_DOC_TYPES_PER_CATEGORY = 200;
|
||||
const MAX_TAGS = 500;
|
||||
const MAX_TEXT_LENGTH = 128;
|
||||
|
||||
/** Human-readable problems with a candidate taxonomy; empty means valid. */
|
||||
export function validateTaxonomy(value: unknown): string[] {
|
||||
const errors: string[] = [];
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return ["File is not a taxonomy object."];
|
||||
}
|
||||
const taxonomy = value as Partial<ClassificationTaxonomy>;
|
||||
if (!Array.isArray(taxonomy.categories) || taxonomy.categories.length === 0) {
|
||||
errors.push("Taxonomy must have at least one category.");
|
||||
return errors;
|
||||
}
|
||||
if (taxonomy.categories.length > MAX_CATEGORIES) {
|
||||
errors.push(`Too many categories (max ${MAX_CATEGORIES}).`);
|
||||
}
|
||||
if (Array.isArray(taxonomy.tags) && taxonomy.tags.length > MAX_TAGS) {
|
||||
errors.push(`Too many tags (max ${MAX_TAGS}).`);
|
||||
}
|
||||
const categoryIds = new Set<string>();
|
||||
for (const category of taxonomy.categories) {
|
||||
if (!isText(category?.id) || !isText(category?.label)) {
|
||||
errors.push("Every category needs a non-empty id and label.");
|
||||
continue;
|
||||
}
|
||||
if (!withinLength(category.id) || !withinLength(category.label)) {
|
||||
errors.push(
|
||||
`Category "${category.label}" has text over ${MAX_TEXT_LENGTH} characters.`,
|
||||
);
|
||||
}
|
||||
if ((category.docTypes ?? []).length > MAX_DOC_TYPES_PER_CATEGORY) {
|
||||
errors.push(
|
||||
`Too many sub-categories in "${category.label}" (max ${MAX_DOC_TYPES_PER_CATEGORY}).`,
|
||||
);
|
||||
}
|
||||
if (categoryIds.has(category.id)) {
|
||||
errors.push(`Duplicate category id: ${category.id}`);
|
||||
}
|
||||
categoryIds.add(category.id);
|
||||
const docTypeIds = new Set<string>();
|
||||
for (const docType of category.docTypes ?? []) {
|
||||
if (!isText(docType?.id) || !isText(docType?.label)) {
|
||||
errors.push(
|
||||
`Every sub-category in "${category.label}" needs an id and label.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!withinLength(docType.id) || !withinLength(docType.label)) {
|
||||
errors.push(
|
||||
`Sub-category "${docType.label}" has text over ${MAX_TEXT_LENGTH} characters.`,
|
||||
);
|
||||
}
|
||||
if (docTypeIds.has(docType.id)) {
|
||||
errors.push(
|
||||
`Duplicate sub-category id "${docType.id}" in "${category.label}".`,
|
||||
);
|
||||
}
|
||||
docTypeIds.add(docType.id);
|
||||
}
|
||||
}
|
||||
if (taxonomy.tags !== undefined) {
|
||||
if (!Array.isArray(taxonomy.tags)) {
|
||||
errors.push("Tags must be a list.");
|
||||
} else {
|
||||
const tags = new Set<string>();
|
||||
for (const tag of taxonomy.tags) {
|
||||
if (!isText(tag)) errors.push("Tags must be non-empty text.");
|
||||
else if (!withinLength(tag))
|
||||
errors.push(`Tag "${tag}" is over ${MAX_TEXT_LENGTH} characters.`);
|
||||
else if (tags.has(tag)) errors.push(`Duplicate tag: ${tag}`);
|
||||
else tags.add(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Coerce a validated value into a normalized taxonomy (trims, drops extras). */
|
||||
export function normalizeTaxonomy(
|
||||
value: ClassificationTaxonomy,
|
||||
): ClassificationTaxonomy {
|
||||
return {
|
||||
categories: value.categories.map(
|
||||
(c): DocumentCategory => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
docTypes: (c.docTypes ?? []).map(
|
||||
(d): DocumentType => ({ id: d.id, label: d.label }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
tags: value.tags ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse + validate a picked file, resolving to a normalized taxonomy. */
|
||||
export async function parseTaxonomyFile(
|
||||
file: File,
|
||||
): Promise<ClassificationTaxonomy> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await file.text());
|
||||
} catch {
|
||||
throw new Error("That file isn't valid JSON.");
|
||||
}
|
||||
const errors = validateTaxonomy(parsed);
|
||||
if (errors.length > 0) throw new Error(errors[0]);
|
||||
return normalizeTaxonomy(parsed as ClassificationTaxonomy);
|
||||
}
|
||||
|
||||
/** Trigger a download of the taxonomy as a pretty-printed JSON file. */
|
||||
export function downloadTaxonomy(
|
||||
taxonomy: ClassificationTaxonomy,
|
||||
fileName = "classification-taxonomy.json",
|
||||
): void {
|
||||
downloadJsonAsFile(taxonomy, fileName);
|
||||
}
|
||||
|
||||
function isText(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function withinLength(value: string): boolean {
|
||||
return value.length <= MAX_TEXT_LENGTH;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user