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