From 7f01bcdc44526cbf1aa0f79a66ecf1021919be51 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:04:30 +0100 Subject: [PATCH] Classifier setup as a processor policy (#7012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview Adds a **Classification policy** to the processor's policy catalogue, set up the same way as the Security policy. This moves classifier configuration out of the editor (where the labels UI landed in #6898 and was then removed with the rest of the editor's policy-management surface in #6932) and into the processor, which is now the single place policies are configured. ## What it does - **Classification card** in the processor policy catalogue. Always shown, but **setup is locked until the backend reports the AI engine is on** — so admins can see the capability they're missing rather than it being hidden entirely. - **Setup wizard** mirrors Security: the workflow step shows the team's **classification label editor** (reused `LabelsEditor`/`LabelsEditorModal` — add box, chip grid, per-label icon picker, import/export, reset) instead of tool toggles, since classify is a single non-configurable step. - On enable, the team's label vocabulary is **seeded with the 268 built-in defaults** (clobber-safe: only when the team has none). On upload the document is classified against the team's labels and tagged; on SaaS with the engine on, files group by category in the editor sidebar. ## Reuse & consolidation - Reuses the existing labels table, `labelsFile` helpers, and default vocabulary. Labels read/write through the processor's own `apiClient.local` (not the editor's axios client) so auth/base routing stays explicit; the wire shape is shared. - Consolidates policy-category icons into a shared, **id-keyed** `policyCategoryIcon` util (outline glyphs) used by both the editor and the processor, replacing the processor's emoji-glyph map (and the stray `schedule` key that rendered a bare dot). ## Testing - `task frontend:typecheck:{core,proprietary,portal}`, `frontend:lint:eslint`, `frontend:test` (156 files / 1305 tests) — all green. - Verified in Storybook: the Classification card renders, the setup wizard shows the label editor (268 defaults), and the full labels editor opens with icons/import/export/reset. Added an MSW handler for the app-config + labels endpoints and a `Classification` wizard story. ## Notes for reviewers - The AI-engine gate reads the public `/api/v1/config/app-config`; classification labels use `/api/v1/classification/labels` (team-scoped, team-lead/admin-gated, `policies.enabled`); the classify step hits `/api/v1/ai/tools/classify-and-label` — all pre-existing backend from #6898. - Known parity behavior (matches the editor hook): a transient failure loading team labels falls back to showing the defaults; not changed here to avoid diverging the two hooks. --- .../ClassificationLabelProvider.java | 61 + .../ClassificationLabelsController.java | 136 -- .../model/ClassificationLabels.java | 8 +- .../classification/model/LabelsValidator.java | 71 - .../store/ClassificationLabelStore.java | 22 - .../InProcessClassificationLabelStore.java | 36 - .../store/JpaClassificationLabelStore.java | 76 - .../store/TeamLabelsEntity.java | 47 - .../store/TeamLabelsRepository.java | 7 - .../api/ClassifyLabelController.java | 46 +- .../configuration/DatabaseConfig.java | 6 +- .../classification/classification-labels.json | 1344 +++++++++++++++++ .../ClassificationLabelsControllerTest.java | 133 -- .../model/LabelsValidatorTest.java | 146 -- .../api/ClassifyLabelControllerTest.java | 49 +- .../saas/V39__drop_classification_labels.sql | 10 + .../stirling/agents/document_classifier.py | 4 +- .../public/locales/en-US/translation.toml | 66 +- .../policies/policyCategoryIcon.tsx | 36 + frontend/editor/src/core/ui/LabelChip.css | 57 - .../editor/src/core/ui/LabelChip.stories.tsx | 52 - frontend/editor/src/core/ui/LabelChip.tsx | 60 - frontend/editor/src/portal/api/policies.ts | 36 +- .../src/portal/components/PolicySummary.tsx | 4 +- .../policies/ClassificationLabelsSection.css | 53 + .../policies/ClassificationLabelsSection.tsx | 97 ++ .../policies/PolicyCategoryCard.tsx | 23 +- .../policies/PolicySetupWizard.stories.tsx | 14 + .../components/policies/PolicySetupWizard.tsx | 145 +- .../portal/components/policies/policyIcons.ts | 25 - .../src/portal/hooks/useAiEngineEnabled.ts | 26 + .../portal/mocks/handlers/classification.ts | 10 + .../editor/src/portal/mocks/handlers/index.ts | 2 + frontend/editor/src/portal/views/Policies.css | 107 +- frontend/editor/src/portal/views/Policies.tsx | 20 + .../ClassificationCategoryManager.css | 37 + .../ClassificationCategoryManager.stories.tsx | 46 + .../ClassificationCategoryManager.tsx | 68 + .../policies/ClassificationLabelsSection.tsx | 176 --- .../components/policies/LabelIconPicker.tsx | 92 -- .../components/policies/LabelsEditor.css | 183 --- .../components/policies/LabelsEditor.tsx | 258 ---- .../components/policies/LabelsEditorModal.tsx | 175 --- .../components/policies/policyToolChains.ts | 19 - .../data/classificationLabels.drift.test.ts | 54 + .../proprietary/data/classificationLabels.ts | 20 +- .../editor/src/proprietary/data/labelIcons.ts | 28 +- .../proprietary/data/policyDefinitions.tsx | 19 +- .../hooks/useClassificationLabels.ts | 85 -- .../src/proprietary/hooks/usePolicies.ts | 15 - .../proprietary/policies/operations.test.ts | 3 +- .../src/proprietary/policies/operations.ts | 69 +- .../services/fileSidebarCategories.ts | 191 +-- .../services/labelsBackend.test.ts | 108 -- .../src/proprietary/services/labelsBackend.ts | 99 -- .../src/proprietary/services/labelsFile.ts | 107 -- .../shared/FileSidebarGroupControls.css | 121 +- .../shared/FileSidebarGroupControls.tsx | 340 +---- .../shared/fileSidebarGrouping.test.ts | 28 +- .../components/shared/fileSidebarGrouping.tsx | 8 +- .../shared/fileSidebarGroupingLogic.ts | 41 +- 61 files changed, 2269 insertions(+), 3156 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelProvider.java delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelsController.java delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/classification/model/LabelsValidator.java delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/classification/store/ClassificationLabelStore.java delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessClassificationLabelStore.java delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaClassificationLabelStore.java delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsEntity.java delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsRepository.java create mode 100644 app/proprietary/src/main/resources/classification/classification-labels.json delete mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/classification/ClassificationLabelsControllerTest.java delete mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/classification/model/LabelsValidatorTest.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V39__drop_classification_labels.sql create mode 100644 frontend/editor/src/core/components/policies/policyCategoryIcon.tsx delete mode 100644 frontend/editor/src/core/ui/LabelChip.css delete mode 100644 frontend/editor/src/core/ui/LabelChip.stories.tsx delete mode 100644 frontend/editor/src/core/ui/LabelChip.tsx create mode 100644 frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css create mode 100644 frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx delete mode 100644 frontend/editor/src/portal/components/policies/policyIcons.ts create mode 100644 frontend/editor/src/portal/hooks/useAiEngineEnabled.ts create mode 100644 frontend/editor/src/portal/mocks/handlers/classification.ts create mode 100644 frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.css create mode 100644 frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx delete mode 100644 frontend/editor/src/proprietary/components/policies/ClassificationLabelsSection.tsx delete mode 100644 frontend/editor/src/proprietary/components/policies/LabelIconPicker.tsx delete mode 100644 frontend/editor/src/proprietary/components/policies/LabelsEditor.css delete mode 100644 frontend/editor/src/proprietary/components/policies/LabelsEditor.tsx delete mode 100644 frontend/editor/src/proprietary/components/policies/LabelsEditorModal.tsx delete mode 100644 frontend/editor/src/proprietary/components/policies/policyToolChains.ts create mode 100644 frontend/editor/src/proprietary/data/classificationLabels.drift.test.ts delete mode 100644 frontend/editor/src/proprietary/hooks/useClassificationLabels.ts delete mode 100644 frontend/editor/src/proprietary/services/labelsBackend.test.ts delete mode 100644 frontend/editor/src/proprietary/services/labelsBackend.ts delete mode 100644 frontend/editor/src/proprietary/services/labelsFile.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelProvider.java new file mode 100644 index 0000000000..480eaecf76 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelProvider.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.classification; + +import java.io.InputStream; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.classification.model.ClassificationLabel; +import stirling.software.proprietary.classification.model.ClassificationLabels; + +import tools.jackson.databind.ObjectMapper; + +/** + * Supplies the classification vocabulary the classify tool sends to the AI engine. The set is a + * fixed, built-in list bundled with the application ({@code + * classification/classification-labels.json}) and shared by everyone — there is no per-team + * customization or database. Loaded once at startup. + */ +@Slf4j +@Component +public class ClassificationLabelProvider { + + private static final String RESOURCE = "classification/classification-labels.json"; + + private final List labels; + + // Explicit @Autowired: the class has a second (private) constructor for tests, so Spring + // can't infer which to use without it. + @Autowired + public ClassificationLabelProvider(ObjectMapper objectMapper) { + this(load(objectMapper)); + } + + private ClassificationLabelProvider(List labels) { + this.labels = List.copyOf(labels); + } + + /** Build a provider with an explicit label set (tests). */ + public static ClassificationLabelProvider withLabels(List labels) { + return new ClassificationLabelProvider(labels); + } + + /** The built-in vocabulary, in file order. */ + public List labels() { + return labels; + } + + private static List load(ObjectMapper objectMapper) { + try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) { + ClassificationLabels parsed = objectMapper.readValue(in, ClassificationLabels.class); + return parsed.labels(); + } catch (Exception e) { + log.error("Failed to load classification labels from {}", RESOURCE, e); + return List.of(); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelsController.java deleted file mode 100644 index 6dcd19e724..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/ClassificationLabelsController.java +++ /dev/null @@ -1,136 +0,0 @@ -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.ClassificationLabels; -import stirling.software.proprietary.classification.model.LabelsValidator; -import stirling.software.proprietary.classification.store.ClassificationLabelStore; -import stirling.software.proprietary.classification.store.TeamLabelsEntity; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; - -/** - * Read/write the team's classification label set — the flat vocabulary the document classifier runs - * against. Shared and team-scoped exactly like policies: every user reads their own team's labels, - * and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see - * {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since - * single-user deployments trust the local operator. A team with no stored labels reads as {@code - * 204}; that team has no vocabulary, so its documents are not classified (there is no built-in - * default on the backend or the engine — the label data lives only in the frontend). - */ -@RestController -@RequestMapping("/api/v1/classification/labels") -@Hidden -@RequiredArgsConstructor -@Tag(name = "Classification", description = "Team-scoped document-classification labels") -@ConditionalOnBooleanProperty(name = "policies.enabled") -public class ClassificationLabelsController { - - private final ClassificationLabelStore labelStore; - private final PolicyManagementAuthority policyManagementAuthority; - private final ApplicationProperties applicationProperties; - private final UserServiceInterface userService; - - @GetMapping - @Operation( - summary = "Get the team's classification labels", - description = - "Returns the caller's team label set, or 204 when the team has none (its" - + " documents are then not classified).") - public ResponseEntity getTeamLabels() { - return labelStore - .findByTeam(currentTeamId()) - .map(ResponseEntity::ok) - .orElseGet(() -> ResponseEntity.noContent().build()); - } - - @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) - @Operation( - summary = "Save the team's classification labels", - description = - "Validates and stores the label set for the caller's team, shared by everyone" - + " on the team. Requires the policy-editor role for the team.") - public ResponseEntity saveTeamLabels( - @RequestBody ClassificationLabels labels) { - requireEditingAllowed(); - validate(labels); - ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername()); - return ResponseEntity.ok(saved); - } - - @DeleteMapping - @Operation( - summary = "Reset the team's classification labels", - description = - "Removes the team's stored label set; its documents are then not classified" - + " until labels are saved again. Requires the policy-editor role for the" - + " team.") - public ResponseEntity resetTeamLabels() { - requireEditingAllowed(); - labelStore.deleteByTeam(currentTeamId()); - return ResponseEntity.noContent().build(); - } - - private static void validate(ClassificationLabels labels) { - try { - LabelsValidator.validate(labels); - } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); - } - } - - /** - * Editing the team labels 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 team classification labels may only be changed by a team leader"); - } - } - - /** - * The caller's team key. With login disabled the single operator owns the {@link - * TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team - * is an error rather than being dropped into the shared sentinel bucket (which would let - * unteamed users read and overwrite each other's "team" labels). - */ - private Long currentTeamId() { - Long teamId = policyManagementAuthority.currentUserTeamId(); - if (teamId != null) { - return teamId; - } - if (!applicationProperties.getSecurity().isEnableLogin()) { - return TeamLabelsEntity.NO_TEAM; - } - throw new ResponseStatusException( - HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team"); - } - - private String currentUsername() { - return userService == null ? null : userService.getCurrentUsername(); - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java index ea7a1269f0..7ac73c92de 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/ClassificationLabels.java @@ -3,10 +3,10 @@ package stirling.software.proprietary.classification.model; import java.util.List; /** - * A flat multi-label classification vocabulary — the set of labels a document may be assigned. - * Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these - * label names. A team with no stored set has no vocabulary, so its documents are not classified — - * neither the backend nor the engine holds a default of its own. + * A flat multi-label classification vocabulary — the set of labels a document may be assigned. The + * classifier runs against these label names. The vocabulary is a fixed, built-in set shared by + * everyone (see {@link stirling.software.proprietary.classification.ClassificationLabelProvider}); + * this record is the JSON parse target for that bundled resource. */ public record ClassificationLabels(List labels) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/LabelsValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/LabelsValidator.java deleted file mode 100644 index 722e8b417b..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/model/LabelsValidator.java +++ /dev/null @@ -1,71 +0,0 @@ -package stirling.software.proprietary.classification.model; - -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; -import java.util.regex.Pattern; - -/** - * Structural validation for a user- or admin-supplied label set, run before it is stored so a - * malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on: - * non-blank ids and names, each unique within the set (ids exactly, names case-insensitively). - */ -public final class LabelsValidator { - - private LabelsValidator() {} - - // Generous upper bounds so a legitimate label set is never blocked, but a single team or user - // can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or - // exhaust memory on deserialize. - static final int MAX_LABELS = 500; - static final int MAX_TEXT_LENGTH = 128; - - // Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side — - // the exact allowlist lives in the frontend — so a client bypassing the UI can't store - // arbitrary - // text that would render as garbage (or worse) in every teammate's sidebar. - private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$"); - - /** - * @throws IllegalArgumentException with a human-readable message when the label set is invalid. - */ - public static void validate(ClassificationLabels labels) { - if (labels == null || labels.labels() == null) { - throw new IllegalArgumentException("Labels are required"); - } - if (labels.labels().size() > MAX_LABELS) { - throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")"); - } - Set ids = new HashSet<>(); - Set names = new HashSet<>(); - for (ClassificationLabel label : labels.labels()) { - requireText(label.id(), "Label id"); - requireText(label.name(), "Label name"); - if (label.icon() != null && !label.icon().isEmpty()) { - if (label.icon().length() > MAX_TEXT_LENGTH) { - throw new IllegalArgumentException( - "Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)"); - } - if (!ICON_KEY.matcher(label.icon()).matches()) { - throw new IllegalArgumentException("Invalid label icon: " + label.icon()); - } - } - if (!ids.add(label.id().trim())) { - throw new IllegalArgumentException("Duplicate label id: " + label.id()); - } - if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) { - throw new IllegalArgumentException("Duplicate label name: " + label.name()); - } - } - } - - private static void requireText(String value, String field) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException(field + " must not be blank"); - } - if (value.trim().length() > MAX_TEXT_LENGTH) { - throw new IllegalArgumentException( - field + " is too long (max " + MAX_TEXT_LENGTH + " characters)"); - } - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/ClassificationLabelStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/ClassificationLabelStore.java deleted file mode 100644 index a604854c60..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/ClassificationLabelStore.java +++ /dev/null @@ -1,22 +0,0 @@ -package stirling.software.proprietary.classification.store; - -import java.util.Optional; - -import stirling.software.proprietary.classification.model.ClassificationLabels; - -/** - * Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the - * unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null - * team. - */ -public interface ClassificationLabelStore { - - /** The team's stored labels, or empty when it has none (callers then skip classification). */ - Optional findByTeam(Long teamId); - - /** Create or replace the team's labels. Returns the stored value. */ - ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy); - - /** Remove the team's labels (reset to default). Returns whether a set existed. */ - boolean deleteByTeam(Long teamId); -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessClassificationLabelStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessClassificationLabelStore.java deleted file mode 100644 index 6846902eab..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/InProcessClassificationLabelStore.java +++ /dev/null @@ -1,36 +0,0 @@ -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.ClassificationLabels; - -/** - * In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link - * JpaClassificationLabelStore} is the runtime bean. - */ -public class InProcessClassificationLabelStore implements ClassificationLabelStore { - - private final Map byTeam = new ConcurrentHashMap<>(); - - @Override - public Optional findByTeam(Long teamId) { - return Optional.ofNullable(byTeam.get(key(teamId))); - } - - @Override - public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) { - byTeam.put(key(teamId), labels); - return labels; - } - - @Override - public boolean deleteByTeam(Long teamId) { - return byTeam.remove(key(teamId)) != null; - } - - private static long key(Long teamId) { - return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId; - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaClassificationLabelStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaClassificationLabelStore.java deleted file mode 100644 index 724e335fc4..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/JpaClassificationLabelStore.java +++ /dev/null @@ -1,76 +0,0 @@ -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.ClassificationLabels; - -import tools.jackson.core.JacksonException; -import tools.jackson.databind.ObjectMapper; - -/** - * Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code - * policies.enabled} — stored labels only matter when the Classification policy can run — so it - * shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link - * TeamLabelsEntity}. - */ -@Slf4j -@Service -@RequiredArgsConstructor -@ConditionalOnBooleanProperty(name = "policies.enabled") -public class JpaClassificationLabelStore implements ClassificationLabelStore { - - private final TeamLabelsRepository teamRepository; - private final ObjectMapper objectMapper; - - @Override - public Optional findByTeam(Long teamId) { - return teamRepository - .findById(key(teamId)) - .flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId)); - } - - @Override - public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) { - TeamLabelsEntity entity = new TeamLabelsEntity(); - entity.setTeamId(key(teamId)); - entity.setLabelsJson(objectMapper.writeValueAsString(labels)); - entity.setUpdatedAt(Instant.now()); - entity.setUpdatedBy(updatedBy); - teamRepository.save(entity); - return labels; - } - - @Override - public boolean deleteByTeam(Long teamId) { - long id = key(teamId); - if (!teamRepository.existsById(id)) { - return false; - } - teamRepository.deleteById(id); - return true; - } - - private Optional parse(String json, String owner) { - try { - return Optional.of(objectMapper.readValue(json, ClassificationLabels.class)); - } catch (JacksonException e) { - // A stored label set that no longer parses (corruption / manual DB edit) must not break - // classification: drop it so the caller treats the team as having no labels (and skips - // classification) rather than surfacing a 500 on every upload. - log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage()); - return Optional.empty(); - } - } - - /** 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 ? TeamLabelsEntity.NO_TEAM : teamId; - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsEntity.java deleted file mode 100644 index 25f2e5b259..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsEntity.java +++ /dev/null @@ -1,47 +0,0 @@ -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 labels — one row per team. The label set lives as JSON in - * {@code labelsJson} (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_labels") -@NoArgsConstructor -@Getter -@Setter -public class TeamLabelsEntity implements Serializable { - - private static final long serialVersionUID = 1L; - - /** Sentinel key for the unteamed label set (login disabled / no resolvable team). */ - public static final long NO_TEAM = 0L; - - @Id - @Column(name = "team_id") - private long teamId; - - @Column(name = "labels_json", columnDefinition = "text") - private String labelsJson; - - @Column(name = "updated_at") - private Instant updatedAt; - - @Column(name = "updated_by") - private String updatedBy; -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsRepository.java deleted file mode 100644 index 7fc9589c39..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/classification/store/TeamLabelsRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package stirling.software.proprietary.classification.store; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface TeamLabelsRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java index 629a377cf3..3b7f36037b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java @@ -31,10 +31,9 @@ import stirling.software.common.service.PdfMetadataService; import stirling.software.common.service.UserServiceInterface; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.classification.ClassificationLabelProvider; import stirling.software.proprietary.classification.model.ClassificationLabel; -import stirling.software.proprietary.classification.store.ClassificationLabelStore; 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; @@ -46,7 +45,7 @@ import tools.jackson.databind.node.ObjectNode; * Dispatchable tool that classifies a PDF and writes the result into its metadata. * *

Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI - * engine to classify the document against the caller's team label set, and stores the engine's JSON + * engine to classify the document against the built-in label set, 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 labelled PDF. Not intended for direct * client use. @@ -72,13 +71,9 @@ public class ClassifyLabelController { 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 there are no team labels to - * classify against and the document is passed through unlabelled. + * The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}. */ - private final ClassificationLabelStore labelStore; - - private final PolicyManagementAuthority policyManagementAuthority; + private final ClassificationLabelProvider labelProvider; public ClassifyLabelController( CustomPDFDocumentFactory pdfDocumentFactory, @@ -87,18 +82,16 @@ public class ClassifyLabelController { PdfMetadataService pdfMetadataService, AiEngineClient aiEngineClient, ObjectMapper objectMapper, - @Autowired(required = false) UserServiceInterface userService, - @Autowired(required = false) ClassificationLabelStore labelStore, - @Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) { + ClassificationLabelProvider labelProvider, + @Autowired(required = false) UserServiceInterface userService) { this.pdfDocumentFactory = pdfDocumentFactory; this.tempFileManager = tempFileManager; this.pdfContentExtractor = pdfContentExtractor; this.pdfMetadataService = pdfMetadataService; this.aiEngineClient = aiEngineClient; this.objectMapper = objectMapper; + this.labelProvider = labelProvider; this.userService = userService; - this.labelStore = labelStore; - this.policyManagementAuthority = policyManagementAuthority; } @PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -116,9 +109,9 @@ public class ClassifyLabelController { List allowed = resolveAllowedLabels(); if (allowed.isEmpty()) { - // No vocabulary to classify against (the team stored no labels): pass the file - // through unlabelled rather than ask the engine to classify against nothing. - log.debug("[classify-and-label] {} has no team labels; skipping", fileName); + // No vocabulary to classify against: pass the file through unlabelled rather than + // ask the engine to classify against nothing. + log.debug("[classify-and-label] {} has no labels; skipping", fileName); return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager); } @@ -175,24 +168,13 @@ public class ClassifyLabelController { } /** - * The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id. - * The engine shows the model the names and returns the ids (icons are presentational and never - * sent). Returns an empty list — the caller then skips classification — when the policy - * subsystem is disabled (no store) or the team has no stored labels. The engine holds no - * default vocabulary of its own, so a team's stored labels are the only source. + * The built-in vocabulary as {@code {id, name}} pairs, de-duplicated by id. The engine shows + * the model the names and returns the ids (icons are presentational and never sent). The engine + * holds no default vocabulary of its own, so this bundled set is the only source. */ private List resolveAllowedLabels() { - if (labelStore == null) { - return List.of(); - } - Long teamId = - policyManagementAuthority == null - ? null - : policyManagementAuthority.currentUserTeamId(); - Map byId = new LinkedHashMap<>(); - labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId)); - + collectLabels(labelProvider.labels(), byId); return List.copyOf(byId.values()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 2e9c4d3e5b..a9c567885f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -37,8 +37,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.policy.ledger", "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.repository", - "stirling.software.proprietary.integration.repository", - "stirling.software.proprietary.classification.store" + "stirling.software.proprietary.integration.repository" }) @EntityScan({ "stirling.software.proprietary.security.model", @@ -50,8 +49,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.policy.ledger", "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.model", - "stirling.software.proprietary.integration.model", - "stirling.software.proprietary.classification.store" + "stirling.software.proprietary.integration.model" }) public class DatabaseConfig { diff --git a/app/proprietary/src/main/resources/classification/classification-labels.json b/app/proprietary/src/main/resources/classification/classification-labels.json new file mode 100644 index 0000000000..72f09c3aea --- /dev/null +++ b/app/proprietary/src/main/resources/classification/classification-labels.json @@ -0,0 +1,1344 @@ +{ + "labels": [ + { + "id": "invoice", + "name": "Invoice", + "icon": "receipt-long" + }, + { + "id": "receipt", + "name": "Receipt", + "icon": "receipt" + }, + { + "id": "credit-note", + "name": "Credit note", + "icon": "currency-exchange" + }, + { + "id": "debit-note", + "name": "Debit note", + "icon": "attach-money" + }, + { + "id": "purchase-order", + "name": "Purchase order", + "icon": "shopping-cart" + }, + { + "id": "order-confirmation", + "name": "Order confirmation", + "icon": "shopping-cart" + }, + { + "id": "quote", + "name": "Quote", + "icon": "request-quote" + }, + { + "id": "estimate", + "name": "Estimate", + "icon": "price-check" + }, + { + "id": "proforma-invoice", + "name": "Proforma invoice", + "icon": "paid" + }, + { + "id": "bank-statement", + "name": "Bank statement", + "icon": "account-balance" + }, + { + "id": "financial-statement", + "name": "Financial statement", + "icon": "account-balance-wallet" + }, + { + "id": "balance-sheet", + "name": "Balance sheet", + "icon": "table-chart" + }, + { + "id": "income-statement", + "name": "Income statement", + "icon": "functions" + }, + { + "id": "cash-flow-statement", + "name": "Cash flow statement", + "icon": "query-stats" + }, + { + "id": "expense-report", + "name": "Expense report", + "icon": "credit-card" + }, + { + "id": "budget", + "name": "Budget", + "icon": "savings" + }, + { + "id": "financial-forecast", + "name": "Financial forecast", + "icon": "insights" + }, + { + "id": "payslip", + "name": "Payslip", + "icon": "payments" + }, + { + "id": "payroll-document", + "name": "Payroll document", + "icon": "wallet" + }, + { + "id": "tax-form", + "name": "Tax form", + "icon": "calculate" + }, + { + "id": "tax-return", + "name": "Tax return", + "icon": "percent" + }, + { + "id": "tax-statement", + "name": "Tax statement", + "icon": "calculate" + }, + { + "id": "remittance-advice", + "name": "Remittance advice", + "icon": "send" + }, + { + "id": "payment-reminder", + "name": "Payment reminder", + "icon": "schedule" + }, + { + "id": "statement-of-account", + "name": "Statement of account", + "icon": "inbox" + }, + { + "id": "dunning-letter", + "name": "Dunning letter", + "icon": "mail" + }, + { + "id": "audit-report", + "name": "Audit report", + "icon": "fact-check" + }, + { + "id": "annual-report", + "name": "Annual report", + "icon": "leaderboard" + }, + { + "id": "quarterly-report", + "name": "Quarterly report", + "icon": "bar-chart" + }, + { + "id": "pricing-sheet", + "name": "Pricing sheet", + "icon": "sell" + }, + { + "id": "price-list", + "name": "Price list", + "icon": "format-list-bulleted" + }, + { + "id": "loan-document", + "name": "Loan document", + "icon": "assured-workload" + }, + { + "id": "mortgage-document", + "name": "Mortgage document", + "icon": "home" + }, + { + "id": "investment-summary", + "name": "Investment summary", + "icon": "trending-up" + }, + { + "id": "donation-receipt", + "name": "Donation receipt", + "icon": "volunteer-activism" + }, + { + "id": "contract", + "name": "Contract", + "icon": "handshake" + }, + { + "id": "nda", + "name": "NDA", + "icon": "lock" + }, + { + "id": "service-agreement", + "name": "Service agreement", + "icon": "handshake" + }, + { + "id": "employment-contract", + "name": "Employment contract", + "icon": "work" + }, + { + "id": "lease-agreement", + "name": "Lease agreement", + "icon": "home-work" + }, + { + "id": "rental-agreement", + "name": "Rental agreement", + "icon": "home-work" + }, + { + "id": "license-agreement", + "name": "License agreement", + "icon": "verified" + }, + { + "id": "purchase-agreement", + "name": "Purchase agreement", + "icon": "shopping-cart" + }, + { + "id": "partnership-agreement", + "name": "Partnership agreement", + "icon": "diversity-3" + }, + { + "id": "loan-agreement", + "name": "Loan agreement", + "icon": "account-balance" + }, + { + "id": "settlement-agreement", + "name": "Settlement agreement", + "icon": "balance" + }, + { + "id": "vendor-agreement", + "name": "Vendor agreement", + "icon": "storefront" + }, + { + "id": "franchise-agreement", + "name": "Franchise agreement", + "icon": "business-center" + }, + { + "id": "non-compete-agreement", + "name": "Non-compete agreement", + "icon": "gavel" + }, + { + "id": "amendment", + "name": "Amendment", + "icon": "edit-document" + }, + { + "id": "addendum", + "name": "Addendum", + "icon": "edit-document" + }, + { + "id": "terms-and-conditions", + "name": "Terms and conditions", + "icon": "gavel" + }, + { + "id": "terms-of-service", + "name": "Terms of service", + "icon": "gavel" + }, + { + "id": "privacy-policy", + "name": "Privacy policy", + "icon": "privacy-tip" + }, + { + "id": "power-of-attorney", + "name": "Power of attorney", + "icon": "gavel" + }, + { + "id": "affidavit", + "name": "Affidavit", + "icon": "gavel" + }, + { + "id": "will", + "name": "Will", + "icon": "history-edu" + }, + { + "id": "trust-document", + "name": "Trust document", + "icon": "shield" + }, + { + "id": "deed", + "name": "Deed", + "icon": "home-work" + }, + { + "id": "court-filing", + "name": "Court filing", + "icon": "balance" + }, + { + "id": "legal-brief", + "name": "Legal brief", + "icon": "balance" + }, + { + "id": "legal-opinion", + "name": "Legal opinion", + "icon": "balance" + }, + { + "id": "legal-notice", + "name": "Legal notice", + "icon": "gavel" + }, + { + "id": "subpoena", + "name": "Subpoena", + "icon": "gavel" + }, + { + "id": "cease-and-desist", + "name": "Cease and desist", + "icon": "gavel" + }, + { + "id": "compliance-document", + "name": "Compliance document", + "icon": "rule" + }, + { + "id": "regulatory-filing", + "name": "Regulatory filing", + "icon": "rule" + }, + { + "id": "consent-form", + "name": "Consent form", + "icon": "fact-check" + }, + { + "id": "waiver", + "name": "Waiver", + "icon": "fact-check" + }, + { + "id": "memorandum-of-understanding", + "name": "Memorandum of understanding", + "icon": "handshake" + }, + { + "id": "letter-of-intent", + "name": "Letter of intent", + "icon": "draft" + }, + { + "id": "articles-of-incorporation", + "name": "Articles of incorporation", + "icon": "business-center" + }, + { + "id": "bylaws", + "name": "Bylaws", + "icon": "rule" + }, + { + "id": "shareholder-agreement", + "name": "Shareholder agreement", + "icon": "handshake" + }, + { + "id": "board-resolution", + "name": "Board resolution", + "icon": "groups" + }, + { + "id": "resume", + "name": "Resume", + "icon": "person" + }, + { + "id": "cv", + "name": "CV", + "icon": "person" + }, + { + "id": "cover-letter", + "name": "Cover letter", + "icon": "mail" + }, + { + "id": "job-description", + "name": "Job description", + "icon": "topic" + }, + { + "id": "job-application", + "name": "Job application", + "icon": "note-add" + }, + { + "id": "offer-letter", + "name": "Offer letter", + "icon": "work" + }, + { + "id": "onboarding-document", + "name": "Onboarding document", + "icon": "badge" + }, + { + "id": "employee-handbook", + "name": "Employee handbook", + "icon": "menu-book" + }, + { + "id": "hr-policy", + "name": "HR policy", + "icon": "policy" + }, + { + "id": "performance-review", + "name": "Performance review", + "icon": "monitoring" + }, + { + "id": "timesheet", + "name": "Timesheet", + "icon": "schedule" + }, + { + "id": "leave-request", + "name": "Leave request", + "icon": "event" + }, + { + "id": "resignation-letter", + "name": "Resignation letter", + "icon": "mail" + }, + { + "id": "termination-letter", + "name": "Termination letter", + "icon": "send" + }, + { + "id": "reference-letter", + "name": "Reference letter", + "icon": "contact-page" + }, + { + "id": "recommendation-letter", + "name": "Recommendation letter", + "icon": "how-to-reg" + }, + { + "id": "training-material", + "name": "Training material", + "icon": "school" + }, + { + "id": "organization-chart", + "name": "Organization chart", + "icon": "supervisor-account" + }, + { + "id": "benefits-summary", + "name": "Benefits summary", + "icon": "health-and-safety" + }, + { + "id": "hr-memo", + "name": "HR memo", + "icon": "sticky-note-2" + }, + { + "id": "letter", + "name": "Letter", + "icon": "mail" + }, + { + "id": "email-thread", + "name": "Email thread", + "icon": "alternate-email" + }, + { + "id": "memo", + "name": "Memo", + "icon": "sticky-note-2" + }, + { + "id": "meeting-minutes", + "name": "Meeting minutes", + "icon": "groups" + }, + { + "id": "meeting-agenda", + "name": "Meeting agenda", + "icon": "checklist" + }, + { + "id": "newsletter", + "name": "Newsletter", + "icon": "newspaper" + }, + { + "id": "announcement", + "name": "Announcement", + "icon": "campaign" + }, + { + "id": "notice", + "name": "Notice", + "icon": "campaign" + }, + { + "id": "public-notice", + "name": "Public notice", + "icon": "public" + }, + { + "id": "press-release", + "name": "Press release", + "icon": "campaign" + }, + { + "id": "complaint-letter", + "name": "Complaint letter", + "icon": "forum" + }, + { + "id": "demand-letter", + "name": "Demand letter", + "icon": "mail" + }, + { + "id": "confirmation-letter", + "name": "Confirmation letter", + "icon": "mail" + }, + { + "id": "report", + "name": "Report", + "icon": "bar-chart" + }, + { + "id": "progress-report", + "name": "Progress report", + "icon": "timeline" + }, + { + "id": "status-report", + "name": "Status report", + "icon": "monitoring" + }, + { + "id": "incident-report", + "name": "Incident report", + "icon": "emergency" + }, + { + "id": "inspection-report", + "name": "Inspection report", + "icon": "fact-check" + }, + { + "id": "survey-results", + "name": "Survey results", + "icon": "pie-chart" + }, + { + "id": "market-research", + "name": "Market research", + "icon": "insights" + }, + { + "id": "case-study", + "name": "Case study", + "icon": "article" + }, + { + "id": "white-paper", + "name": "White paper", + "icon": "library-books" + }, + { + "id": "research-paper", + "name": "Research paper", + "icon": "science" + }, + { + "id": "research-abstract", + "name": "Research abstract", + "icon": "text-snippet" + }, + { + "id": "feasibility-study", + "name": "Feasibility study", + "icon": "architecture" + }, + { + "id": "risk-assessment", + "name": "Risk assessment", + "icon": "security" + }, + { + "id": "analytics-report", + "name": "Analytics report", + "icon": "analytics" + }, + { + "id": "sales-report", + "name": "Sales report", + "icon": "trending-up" + }, + { + "id": "expense-summary", + "name": "Expense summary", + "icon": "pie-chart" + }, + { + "id": "board-report", + "name": "Board report", + "icon": "groups" + }, + { + "id": "sustainability-report", + "name": "Sustainability report", + "icon": "eco" + }, + { + "id": "standard-operating-procedure", + "name": "Standard operating procedure", + "icon": "rule" + }, + { + "id": "work-instruction", + "name": "Work instruction", + "icon": "checklist" + }, + { + "id": "manual", + "name": "Manual", + "icon": "menu-book" + }, + { + "id": "user-guide", + "name": "User guide", + "icon": "auto-stories" + }, + { + "id": "quick-start-guide", + "name": "Quick start guide", + "icon": "menu-book" + }, + { + "id": "checklist", + "name": "Checklist", + "icon": "checklist" + }, + { + "id": "inventory-list", + "name": "Inventory list", + "icon": "inventory-2" + }, + { + "id": "stock-report", + "name": "Stock report", + "icon": "warehouse" + }, + { + "id": "packing-slip", + "name": "Packing slip", + "icon": "package-2" + }, + { + "id": "delivery-note", + "name": "Delivery note", + "icon": "local-shipping" + }, + { + "id": "bill-of-lading", + "name": "Bill of lading", + "icon": "pallet" + }, + { + "id": "waybill", + "name": "Waybill", + "icon": "map" + }, + { + "id": "customs-declaration", + "name": "Customs declaration", + "icon": "flight" + }, + { + "id": "customs-form", + "name": "Customs form", + "icon": "assignment" + }, + { + "id": "freight-document", + "name": "Freight document", + "icon": "forklift" + }, + { + "id": "shipping-confirmation", + "name": "Shipping confirmation", + "icon": "send" + }, + { + "id": "supply-order", + "name": "Supply order", + "icon": "shopping-cart" + }, + { + "id": "work-order", + "name": "Work order", + "icon": "task" + }, + { + "id": "maintenance-log", + "name": "Maintenance log", + "icon": "build" + }, + { + "id": "service-report", + "name": "Service report", + "icon": "engineering" + }, + { + "id": "quality-report", + "name": "Quality report", + "icon": "verified" + }, + { + "id": "safety-data-sheet", + "name": "Safety data sheet", + "icon": "health-and-safety" + }, + { + "id": "safety-procedure", + "name": "Safety procedure", + "icon": "health-and-safety" + }, + { + "id": "warehouse-receipt", + "name": "Warehouse receipt", + "icon": "inventory-2" + }, + { + "id": "return-authorization", + "name": "Return authorization", + "icon": "redeem" + }, + { + "id": "proposal", + "name": "Proposal", + "icon": "slideshow" + }, + { + "id": "business-proposal", + "name": "Business proposal", + "icon": "business-center" + }, + { + "id": "sales-proposal", + "name": "Sales proposal", + "icon": "trending-up" + }, + { + "id": "pitch-deck", + "name": "Pitch deck", + "icon": "leaderboard" + }, + { + "id": "presentation", + "name": "Presentation", + "icon": "slideshow" + }, + { + "id": "brochure", + "name": "Brochure", + "icon": "image" + }, + { + "id": "flyer", + "name": "Flyer", + "icon": "palette" + }, + { + "id": "catalog", + "name": "Catalog", + "icon": "menu-book" + }, + { + "id": "product-sheet", + "name": "Product sheet", + "icon": "description" + }, + { + "id": "marketing-plan", + "name": "Marketing plan", + "icon": "campaign" + }, + { + "id": "campaign-brief", + "name": "Campaign brief", + "icon": "campaign" + }, + { + "id": "media-kit", + "name": "Media kit", + "icon": "photo-camera" + }, + { + "id": "promotional-material", + "name": "Promotional material", + "icon": "celebration" + }, + { + "id": "advertisement", + "name": "Advertisement", + "icon": "campaign" + }, + { + "id": "request-for-proposal", + "name": "Request for proposal", + "icon": "assignment" + }, + { + "id": "request-for-quotation", + "name": "Request for quotation", + "icon": "request-quote" + }, + { + "id": "tender-document", + "name": "Tender document", + "icon": "assignment" + }, + { + "id": "statement-of-work", + "name": "Statement of work", + "icon": "task" + }, + { + "id": "scope-of-work", + "name": "Scope of work", + "icon": "task" + }, + { + "id": "specification", + "name": "Specification", + "icon": "engineering" + }, + { + "id": "technical-specification", + "name": "Technical specification", + "icon": "code" + }, + { + "id": "requirements-document", + "name": "Requirements document", + "icon": "checklist" + }, + { + "id": "design-document", + "name": "Design document", + "icon": "design-services" + }, + { + "id": "architecture-document", + "name": "Architecture document", + "icon": "architecture" + }, + { + "id": "datasheet", + "name": "Datasheet", + "icon": "table-chart" + }, + { + "id": "schematic", + "name": "Schematic", + "icon": "bolt" + }, + { + "id": "blueprint", + "name": "Blueprint", + "icon": "construction" + }, + { + "id": "technical-drawing", + "name": "Technical drawing", + "icon": "engineering" + }, + { + "id": "floor-plan", + "name": "Floor plan", + "icon": "apartment" + }, + { + "id": "patent", + "name": "Patent", + "icon": "copyright" + }, + { + "id": "test-plan", + "name": "Test plan", + "icon": "checklist" + }, + { + "id": "test-report", + "name": "Test report", + "icon": "fact-check" + }, + { + "id": "release-notes", + "name": "Release notes", + "icon": "article" + }, + { + "id": "change-log", + "name": "Change log", + "icon": "edit-document" + }, + { + "id": "api-documentation", + "name": "API documentation", + "icon": "api" + }, + { + "id": "bill-of-materials", + "name": "Bill of materials", + "icon": "inventory-2" + }, + { + "id": "business-plan", + "name": "Business plan", + "icon": "business-center" + }, + { + "id": "project-plan", + "name": "Project plan", + "icon": "task" + }, + { + "id": "project-charter", + "name": "Project charter", + "icon": "task" + }, + { + "id": "roadmap", + "name": "Roadmap", + "icon": "trending-up" + }, + { + "id": "timeline", + "name": "Timeline", + "icon": "timeline" + }, + { + "id": "meeting-notes", + "name": "Meeting notes", + "icon": "sticky-note-2" + }, + { + "id": "action-plan", + "name": "Action plan", + "icon": "checklist" + }, + { + "id": "retrospective", + "name": "Retrospective", + "icon": "psychology" + }, + { + "id": "transcript", + "name": "Transcript", + "icon": "school" + }, + { + "id": "diploma", + "name": "Diploma", + "icon": "history-edu" + }, + { + "id": "certificate", + "name": "Certificate", + "icon": "verified" + }, + { + "id": "certificate-of-completion", + "name": "Certificate of completion", + "icon": "verified" + }, + { + "id": "course-syllabus", + "name": "Course syllabus", + "icon": "menu-book" + }, + { + "id": "lesson-plan", + "name": "Lesson plan", + "icon": "school" + }, + { + "id": "assignment-brief", + "name": "Assignment brief", + "icon": "assignment" + }, + { + "id": "exam-paper", + "name": "Exam paper", + "icon": "assignment" + }, + { + "id": "grade-report", + "name": "Grade report", + "icon": "school" + }, + { + "id": "thesis", + "name": "Thesis", + "icon": "science" + }, + { + "id": "dissertation", + "name": "Dissertation", + "icon": "science" + }, + { + "id": "study-guide", + "name": "Study guide", + "icon": "menu-book" + }, + { + "id": "academic-record", + "name": "Academic record", + "icon": "school" + }, + { + "id": "medical-report", + "name": "Medical report", + "icon": "medical-services" + }, + { + "id": "lab-report", + "name": "Lab report", + "icon": "science" + }, + { + "id": "radiology-report", + "name": "Radiology report", + "icon": "monitor-heart" + }, + { + "id": "pathology-report", + "name": "Pathology report", + "icon": "medical-information" + }, + { + "id": "prescription", + "name": "Prescription", + "icon": "medication" + }, + { + "id": "referral-letter", + "name": "Referral letter", + "icon": "stethoscope" + }, + { + "id": "discharge-summary", + "name": "Discharge summary", + "icon": "medical-services" + }, + { + "id": "immunization-record", + "name": "Immunization record", + "icon": "vaccines" + }, + { + "id": "medical-invoice", + "name": "Medical invoice", + "icon": "receipt-long" + }, + { + "id": "insurance-policy", + "name": "Insurance policy", + "icon": "shield" + }, + { + "id": "insurance-claim", + "name": "Insurance claim", + "icon": "security" + }, + { + "id": "insurance-certificate", + "name": "Insurance certificate", + "icon": "approval" + }, + { + "id": "explanation-of-benefits", + "name": "Explanation of benefits", + "icon": "medical-information" + }, + { + "id": "property-listing", + "name": "Property listing", + "icon": "real-estate-agent" + }, + { + "id": "appraisal-report", + "name": "Appraisal report", + "icon": "real-estate-agent" + }, + { + "id": "home-inspection-report", + "name": "Home inspection report", + "icon": "home" + }, + { + "id": "title-document", + "name": "Title document", + "icon": "home-work" + }, + { + "id": "closing-statement", + "name": "Closing statement", + "icon": "real-estate-agent" + }, + { + "id": "tenancy-agreement", + "name": "Tenancy agreement", + "icon": "home-work" + }, + { + "id": "eviction-notice", + "name": "Eviction notice", + "icon": "gavel" + }, + { + "id": "hoa-document", + "name": "HOA document", + "icon": "home-work" + }, + { + "id": "utility-bill", + "name": "Utility bill", + "icon": "bolt" + }, + { + "id": "permit", + "name": "Permit", + "icon": "approval" + }, + { + "id": "license", + "name": "License", + "icon": "verified" + }, + { + "id": "registration-form", + "name": "Registration form", + "icon": "how-to-reg" + }, + { + "id": "application-form", + "name": "Application form", + "icon": "assignment" + }, + { + "id": "government-notice", + "name": "Government notice", + "icon": "campaign" + }, + { + "id": "grant-application", + "name": "Grant application", + "icon": "assignment" + }, + { + "id": "grant-agreement", + "name": "Grant agreement", + "icon": "handshake" + }, + { + "id": "visa-document", + "name": "Visa document", + "icon": "public" + }, + { + "id": "immigration-document", + "name": "Immigration document", + "icon": "badge" + }, + { + "id": "legal-filing", + "name": "Legal filing", + "icon": "balance" + }, + { + "id": "itinerary", + "name": "Itinerary", + "icon": "map" + }, + { + "id": "travel-itinerary", + "name": "Travel itinerary", + "icon": "flight" + }, + { + "id": "booking-confirmation", + "name": "Booking confirmation", + "icon": "hotel" + }, + { + "id": "reservation", + "name": "Reservation", + "icon": "restaurant" + }, + { + "id": "ticket", + "name": "Ticket", + "icon": "confirmation-number" + }, + { + "id": "event-agenda", + "name": "Event agenda", + "icon": "calendar-month" + }, + { + "id": "event-program", + "name": "Event program", + "icon": "calendar-month" + }, + { + "id": "invitation", + "name": "Invitation", + "icon": "mail" + }, + { + "id": "registration-confirmation", + "name": "Registration confirmation", + "icon": "fact-check" + }, + { + "id": "form", + "name": "Form", + "icon": "assignment" + }, + { + "id": "questionnaire", + "name": "Questionnaire", + "icon": "assignment" + }, + { + "id": "survey-form", + "name": "Survey form", + "icon": "checklist" + }, + { + "id": "feedback-form", + "name": "Feedback form", + "icon": "forum" + }, + { + "id": "intake-form", + "name": "Intake form", + "icon": "assignment" + }, + { + "id": "order-form", + "name": "Order form", + "icon": "shopping-cart" + }, + { + "id": "claim-form", + "name": "Claim form", + "icon": "assignment" + }, + { + "id": "warranty-document", + "name": "Warranty document", + "icon": "assured-workload" + }, + { + "id": "membership-document", + "name": "Membership document", + "icon": "badge" + }, + { + "id": "subscription-confirmation", + "name": "Subscription confirmation", + "icon": "fact-check" + }, + { + "id": "gift-certificate", + "name": "Gift certificate", + "icon": "redeem" + }, + { + "id": "sponsorship-agreement", + "name": "Sponsorship agreement", + "icon": "handshake" + }, + { + "id": "petition", + "name": "Petition", + "icon": "history-edu" + }, + { + "id": "agenda", + "name": "Agenda", + "icon": "checklist" + }, + { + "id": "fact-sheet", + "name": "Fact sheet", + "icon": "summarize" + }, + { + "id": "faq-document", + "name": "FAQ document", + "icon": "chat" + }, + { + "id": "glossary", + "name": "Glossary", + "icon": "menu-book" + }, + { + "id": "index", + "name": "Index", + "icon": "format-list-bulleted" + }, + { + "id": "table-of-contents", + "name": "Table of contents", + "icon": "topic" + } + ] +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/classification/ClassificationLabelsControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/classification/ClassificationLabelsControllerTest.java deleted file mode 100644 index 221d567c31..0000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/classification/ClassificationLabelsControllerTest.java +++ /dev/null @@ -1,133 +0,0 @@ -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.ClassificationLabel; -import stirling.software.proprietary.classification.model.ClassificationLabels; -import stirling.software.proprietary.classification.store.ClassificationLabelStore; -import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; - -@ExtendWith(MockitoExtension.class) -@DisplayName("ClassificationLabelsController") -class ClassificationLabelsControllerTest { - - private static final Long TEAM = 7L; - - @Mock private PolicyManagementAuthority policyManagementAuthority; - @Mock private UserServiceInterface userService; - - private ClassificationLabelStore store; - private ApplicationProperties applicationProperties; - private ClassificationLabelsController controller; - - @BeforeEach - void setUp() { - store = new InProcessClassificationLabelStore(); - applicationProperties = new ApplicationProperties(); - controller = - new ClassificationLabelsController( - store, policyManagementAuthority, applicationProperties, userService); - } - - private static ClassificationLabels sample() { - return new ClassificationLabels( - List.of( - new ClassificationLabel("invoice", "Invoice", "receipt-long"), - new ClassificationLabel("contract", "Contract", null))); - } - - private void loginEnabled(boolean enabled) { - applicationProperties.getSecurity().setEnableLogin(enabled); - } - - @Test - @DisplayName("GET returns 204 when the team has no labels") - void getEmpty() { - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - ResponseEntity response = controller.getTeamLabels(); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - } - - @Test - @DisplayName("PUT then GET round-trips the team's labels (login disabled)") - void saveThenGet() { - loginEnabled(false); - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - - controller.saveTeamLabels(sample()); - ResponseEntity got = controller.getTeamLabels(); - - assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(got.getBody()).isNotNull(); - assertThat(got.getBody().labels()).hasSize(2); - assertThat(got.getBody().labels().getFirst().name()).isEqualTo("Invoice"); - assertThat(got.getBody().labels().getFirst().icon()).isEqualTo("receipt-long"); - } - - @Test - @DisplayName("PUT is scoped per team") - void perTeam() { - loginEnabled(false); - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - controller.saveTeamLabels(sample()); - - when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L); - assertThat(controller.getTeamLabels().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.saveTeamLabels(sample())) - .isInstanceOf(ResponseStatusException.class) - .hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN); - } - - @Test - @DisplayName("PUT rejects an invalid label set with 400") - void putInvalid() { - loginEnabled(false); - ClassificationLabels duplicate = - new ClassificationLabels( - List.of( - new ClassificationLabel("invoice", "Invoice", null), - new ClassificationLabel("invoice", "Invoice", null))); - - assertThatThrownBy(() -> controller.saveTeamLabels(duplicate)) - .isInstanceOf(ResponseStatusException.class) - .hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST); - } - - @Test - @DisplayName("DELETE resets the team back to no stored labels") - void deleteResets() { - loginEnabled(false); - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - controller.saveTeamLabels(sample()); - - ResponseEntity response = controller.resetTeamLabels(); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - } -} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/LabelsValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/LabelsValidatorTest.java deleted file mode 100644 index b9730370c7..0000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/classification/model/LabelsValidatorTest.java +++ /dev/null @@ -1,146 +0,0 @@ -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 java.util.Locale; -import java.util.stream.IntStream; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -@DisplayName("LabelsValidator") -class LabelsValidatorTest { - - private static ClassificationLabels labels(ClassificationLabel... labels) { - return new ClassificationLabels(List.of(labels)); - } - - private static ClassificationLabel label(String name) { - return new ClassificationLabel(slug(name), name, null); - } - - private static String slug(String name) { - return name.trim() - .toLowerCase(Locale.ROOT) - .replaceAll("[^a-z0-9]+", "-") - .replaceAll("(^-|-$)", ""); - } - - @Test - @DisplayName("accepts a well-formed label set") - void acceptsValid() { - ClassificationLabels set = - labels( - new ClassificationLabel("invoice", "Invoice", "receipt-long"), - label("Contract")); - assertThatCode(() -> LabelsValidator.validate(set)).doesNotThrowAnyException(); - } - - @Test - @DisplayName("accepts an empty label set (reads as: use the default)") - void acceptsEmpty() { - assertThatCode(() -> LabelsValidator.validate(labels())).doesNotThrowAnyException(); - } - - @Test - @DisplayName("rejects a null label set") - void rejectsNull() { - assertThatThrownBy(() -> LabelsValidator.validate(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Labels are required"); - } - - @Test - @DisplayName("rejects duplicate names (distinct ids)") - void rejectsDuplicateNames() { - ClassificationLabels set = - labels( - new ClassificationLabel("invoice-a", "Invoice", null), - new ClassificationLabel("invoice-b", "Invoice", null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Duplicate label name"); - } - - @Test - @DisplayName("rejects duplicate names differing only by case") - void rejectsDuplicateNamesCaseInsensitive() { - ClassificationLabels set = - labels( - new ClassificationLabel("invoice-a", "Invoice", null), - new ClassificationLabel("invoice-b", "INVOICE", null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Duplicate label name"); - } - - @Test - @DisplayName("rejects duplicate ids") - void rejectsDuplicateIds() { - ClassificationLabels set = - labels( - new ClassificationLabel("invoice", "Invoice", null), - new ClassificationLabel("invoice", "Sales invoice", null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Duplicate label id"); - } - - @Test - @DisplayName("rejects a blank name") - void rejectsBlankName() { - ClassificationLabels set = labels(new ClassificationLabel("blank", " ", null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Label name must not be blank"); - } - - @Test - @DisplayName("rejects a blank id") - void rejectsBlankId() { - ClassificationLabels set = labels(new ClassificationLabel(" ", "Invoice", null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Label id must not be blank"); - } - - @Test - @DisplayName("rejects an over-long name") - void rejectsOverLongName() { - ClassificationLabels set = - labels( - new ClassificationLabel( - "x", "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1), null)); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("too long"); - } - - @Test - @DisplayName("rejects an over-long icon (a null icon is fine)") - void rejectsOverLongIcon() { - ClassificationLabels set = - labels( - new ClassificationLabel( - "invoice", - "Invoice", - "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1))); - assertThatThrownBy(() -> LabelsValidator.validate(set)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("icon is too long"); - } - - @Test - @DisplayName("rejects more labels than the cap") - void rejectsTooManyLabels() { - List tooMany = - IntStream.rangeClosed(0, LabelsValidator.MAX_LABELS) - .mapToObj(i -> label("label" + i)) - .toList(); - assertThatThrownBy(() -> LabelsValidator.validate(new ClassificationLabels(tooMany))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Too many labels"); - } -} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java index c5ee39be14..2773ed7965 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java @@ -14,7 +14,6 @@ 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; @@ -27,10 +26,8 @@ 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.classification.ClassificationLabelProvider; import stirling.software.proprietary.classification.model.ClassificationLabel; -import stirling.software.proprietary.classification.model.ClassificationLabels; -import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore; -import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.service.AiEngineClient; import stirling.software.proprietary.service.PdfContentExtractor; @@ -42,22 +39,16 @@ import tools.jackson.databind.json.JsonMapper; @MockitoSettings(strictness = Strictness.LENIENT) class ClassifyLabelControllerTest { - private static final Long TEAM = 7L; - @Mock private CustomPDFDocumentFactory pdfDocumentFactory; @Mock private TempFileManager tempFileManager; @Mock private PdfContentExtractor pdfContentExtractor; @Mock private PdfMetadataService pdfMetadataService; @Mock private AiEngineClient aiEngineClient; - @Mock private PolicyManagementAuthority policyManagementAuthority; private final ObjectMapper objectMapper = JsonMapper.builder().build(); - private InProcessClassificationLabelStore labelStore; private ClassifyLabelController controller; - @BeforeEach - void setUp() { - labelStore = new InProcessClassificationLabelStore(); + private void withLabels(List labels) { controller = new ClassifyLabelController( pdfDocumentFactory, @@ -66,9 +57,8 @@ class ClassifyLabelControllerTest { pdfMetadataService, aiEngineClient, objectMapper, - null, - labelStore, - policyManagementAuthority); + ClassificationLabelProvider.withLabels(labels), + null); } private void stubSinglePageDocument() throws Exception { @@ -98,12 +88,7 @@ class ClassifyLabelControllerTest { @Test void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception { - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - labelStore.save( - TEAM, - new ClassificationLabels( - List.of(new ClassificationLabel("invoice", "Invoice", null))), - "admin"); + withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null))); stubSinglePageDocument(); @@ -118,16 +103,12 @@ class ClassifyLabelControllerTest { } @Test - void classifyAndLabel_sendsTeamLabelIdsAndNames() throws Exception { - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); - labelStore.save( - TEAM, - new ClassificationLabels( - List.of( - new ClassificationLabel("invoice", "Invoice", "receipt-long"), - new ClassificationLabel("contract", "Contract", null), - new ClassificationLabel("timesheet", "Timesheet", null))), - "admin"); + void classifyAndLabel_sendsLabelIdsAndNames() throws Exception { + withLabels( + List.of( + new ClassificationLabel("invoice", "Invoice", "receipt-long"), + new ClassificationLabel("contract", "Contract", null), + new ClassificationLabel("timesheet", "Timesheet", null))); stubSinglePageDocument(); @@ -152,13 +133,13 @@ class ClassifyLabelControllerTest { } @Test - void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception { - when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM); + void classifyAndLabel_skipsClassificationWhenNoLabels() throws Exception { + withLabels(List.of()); stubSinglePageDocument(); - // No team labels stored, and the engine holds no default of its own, so the file is passed - // through unlabelled: neither the engine nor the metadata write is invoked. + // No vocabulary, and the engine holds no default of its own, so the file is passed through + // unlabelled: neither the engine nor the metadata write is invoked. verify(aiEngineClient, never()).post(anyString(), anyString(), any()); verify(pdfMetadataService, never()) .setClassificationMetadata(any(PDDocument.class), anyString()); diff --git a/app/saas/src/main/resources/db/migration/saas/V39__drop_classification_labels.sql b/app/saas/src/main/resources/db/migration/saas/V39__drop_classification_labels.sql new file mode 100644 index 0000000000..9ef97f9f99 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V39__drop_classification_labels.sql @@ -0,0 +1,10 @@ +-- Classification labels are now a fixed, built-in set bundled with the app and sent to the engine +-- per request (see ClassificationLabelProvider); the per-team classification_labels table (created +-- in V30) is no longer read or written. Drop it. +-- +-- Forward migration: V30 is kept so any DB that already applied it still validates. This runs after +-- V30 in every case, so it drops the table whether V30 just created it (fresh DB) or it was created +-- and populated on an earlier deploy. IF EXISTS only guards the edge case where the table is already +-- absent, keeping the migration safe to apply regardless of prior state. + +DROP TABLE IF EXISTS classification_labels; diff --git a/engine/src/stirling/agents/document_classifier.py b/engine/src/stirling/agents/document_classifier.py index bb4ab5ef00..73d97595ed 100644 --- a/engine/src/stirling/agents/document_classifier.py +++ b/engine/src/stirling/agents/document_classifier.py @@ -116,8 +116,8 @@ class DocumentClassifierAgent: ) async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse: - # The caller (the backend) always supplies the allowed vocabulary — the - # team's stored labels — so the engine holds no vocabulary of its own. + # The caller (the backend) always supplies the allowed vocabulary — its + # fixed built-in label set — so the engine holds no vocabulary of its own. allowed = request.labels window = select_window(request.pages) prompt = self._build_prompt(request.file_name, allowed, window) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index c182ce2b89..cb8b4d6fb7 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3734,20 +3734,9 @@ uploadToServer = "Upload to server" versionHistory = "Version history" [fileSidebar.groupsModal] -add = "Add" -addLabel = "Add a label…" -categoryIconAria = "Choose an icon for {{name}}" -createCategory = "Create category" -delete = "Delete category" done = "Done" -hide = "Hide category" -newCategory = "New category name…" -removeLabel = "Remove {{name}}" -rename = "Rename" -reset = "Reset to defaults" -search = "Search labels…" -show = "Show category" -subtitle = "Group your files into parent categories. Add existing or new labels to a category, rename it, or create your own. Files in none of your categories appear under “Other”." +reset = "Show all" +subtitle = "Show or hide categories in the files sidebar." title = "Sidebar categories" [filesPage] @@ -6014,31 +6003,11 @@ summaryMore = "{{first}}, {{second}} and {{more}} more" summaryTwo = "{{first}} and {{second}}" [policies.labels] -add = "Add" -addPlaceholder = "Add a label…" -customNote = "Customized for your team." -defaultNote = "Using the built-in default, shared with your team." -duplicate = "\"{{name}}\" already exists." -edit = "Edit labels" -empty = "No labels yet." -export = "Export JSON" -iconAria = "Choose an icon for {{name}}" -import = "Import JSON" -importError = "Couldn't import that file." -managedNote = "Team labels are managed by your team leader." -modalSubtitle = "Shared with your whole team. The classifier picks the labels that fit each document." -modalTitle = "Classification labels" -pickIcon = "Use {{label}} icon" -removeAria = "Remove {{name}}" -resetToDefault = "Reset to default" -saveForTeam = "Save for team" -saving = "Saving…" -sectionLabel = "Classification labels" -startFromScratch = "Start from scratch" -teamCount = "team labels" -tooLong = "Labels can be at most {{max}} characters." -ungrouped = "Ungrouped" -view = "View labels" +categoryCount = "categories" +hideCategory = "Hide category" +labelCount = "labels" +sharedNote = "These labels are built in and shared across your whole team." +showCategory = "Show category" [policies.pii] account = "Account numbers (labelled)" @@ -7451,6 +7420,11 @@ title = "Policies" [portal.policies.card] comingSoon = "Upgrade to Enterprise" notSetUp = "Not set up" +requiresAiEngine = "Requires AI engine" + +[portal.policies.categories.classification] +desc = "Identify each document's type on upload and tag its metadata for filing and search." +label = "Classification" [portal.policies.categories.compliance] desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document." @@ -7475,6 +7449,13 @@ label = "Security" [portal.policies.config] scopeAll = "All documents" +[portal.policies.config.classification] +summary = "Classifies every uploaded document against your team's labels and tags its metadata." + +[portal.policies.config.classification.rules] +0 = "Classify" +1 = "Tag metadata" + [portal.policies.config.compliance] summary = "Validates documents against regulatory frameworks before they leave the system." @@ -7569,6 +7550,7 @@ title = "No activity yet" [portal.policies.endpoints] addWatermark = "Watermark" autoRedact = "Redact PII" +classifyAndLabel = "Classify" compressPdf = "Compress" flatten = "Flatten" ocrPdf = "OCR" @@ -7611,6 +7593,10 @@ continue = "Continue" enablePolicy = "Enable policy" saveChanges = "Save changes" +[portal.policies.wizard.capability.classify] +desc = "Identifies the document's type from your team's labels and tags it, so it files and searches by category." +label = "Classify the document" + [portal.policies.wizard.capability.compress] desc = "Compresses the document to a smaller file size." label = "Reduce file size" @@ -7635,6 +7621,10 @@ label = "Strip active content" desc = "Stamps a visible mark (e.g. “Confidential”) across every page." label = "Apply a watermark" +[portal.policies.wizard.classification] +description = "Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team." +labelsHeading = "Classification labels" + [portal.policies.wizard.errors] noTools = "Enable at least one tool in the workflow first." saveFailed = "Couldn't save the policy. Please try again." diff --git a/frontend/editor/src/core/components/policies/policyCategoryIcon.tsx b/frontend/editor/src/core/components/policies/policyCategoryIcon.tsx new file mode 100644 index 0000000000..bf50c76037 --- /dev/null +++ b/frontend/editor/src/core/components/policies/policyCategoryIcon.tsx @@ -0,0 +1,36 @@ +// Shared source of truth for a policy category's outline icon, keyed by category +// id (not a parallel icon-name vocabulary). Used by the editor's policy +// definitions and the portal's catalogue cards, summaries, and setup wizard. + +import type { ReactNode } from "react"; +import type { SxProps, Theme } from "@mui/material"; +import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined"; +import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined"; +import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined"; +import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined"; + +type MuiIcon = React.ComponentType<{ sx?: SxProps; className?: string }>; + +/** Policy category id → outline glyph. */ +const POLICY_CATEGORY_ICONS: Record = { + ingestion: LayersOutlinedIcon, + security: ShieldOutlinedIcon, + classification: LabelOutlinedIcon, + compliance: CheckCircleOutlinedIcon, + routing: AltRouteOutlinedIcon, + retention: ScheduleOutlinedIcon, +}; + +const FALLBACK_ICON = LabelOutlinedIcon; + +// Defaults to inheriting the surrounding font-size so a wrapping box controls size. +export function policyCategoryIcon( + categoryId: string, + sx: SxProps = { fontSize: "inherit" }, + className?: string, +): ReactNode { + const Icon = POLICY_CATEGORY_ICONS[categoryId] ?? FALLBACK_ICON; + return ; +} diff --git a/frontend/editor/src/core/ui/LabelChip.css b/frontend/editor/src/core/ui/LabelChip.css deleted file mode 100644 index 6be1332b0a..0000000000 --- a/frontend/editor/src/core/ui/LabelChip.css +++ /dev/null @@ -1,57 +0,0 @@ -/* Shared classification-label pill (team labels editor + sidebar category manager). */ - -.sui-labelchip { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: 0.2rem 0.3rem 0.2rem 0.25rem; - border: 1px solid var(--border-default); - border-radius: var(--radius-lg); - background: var(--color-surface); - max-width: 16rem; -} - -.sui-labelchip-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - color: var(--color-text-2); -} - -.sui-labelchip-name { - font-size: 0.8125rem; - color: var(--color-text-1); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.sui-labelchip-count { - font-size: 0.6875rem; - font-weight: 500; - color: var(--color-text-3); - padding-left: 0.1rem; -} - -.sui-labelchip-remove { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - border: none; - border-radius: 999px; - background: transparent; - color: var(--color-text-3); - cursor: pointer; - transition: - background var(--motion-fast), - color var(--motion-fast); -} - -.sui-labelchip-remove:hover { - background: var(--color-bg-hover); - color: var(--color-red); -} diff --git a/frontend/editor/src/core/ui/LabelChip.stories.tsx b/frontend/editor/src/core/ui/LabelChip.stories.tsx deleted file mode 100644 index 79fc9b1be3..0000000000 --- a/frontend/editor/src/core/ui/LabelChip.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { LabelChip } from "@app/ui/LabelChip"; - -const meta: Meta = { - title: "Primitives/LabelChip", - component: LabelChip, - tags: ["autodocs"], - parameters: { layout: "centered" }, - args: { label: "Invoice", icon: "receipt-long" }, - argTypes: { - label: { control: "text" }, - icon: { control: "text" }, - count: { control: "number" }, - onRemove: { action: "removed" }, - }, -}; -export default meta; -type Story = StoryObj; - -/** The classification-label pill shared by the labels editor and the sidebar category manager. */ -export const Playground: Story = {}; - -/** With a file count, as shown in the sidebar category manager. */ -export const WithCount: Story = { - args: { label: "Contract", icon: "handshake", count: 12 }, -}; - -/** Removable — the trailing × appears when `onRemove` is set. */ -export const Removable: Story = { - args: { label: "NDA", icon: "lock", onRemove: () => {} }, -}; - -/** Falls back to the default "sell" icon when none is given. */ -export const DefaultIcon: Story = { - args: { label: "Uncategorised label", icon: undefined }, -}; - -/** Long names truncate rather than overflow the pill. */ -export const LongName: Story = { - args: { label: "Memorandum of understanding and mutual agreement", count: 3 }, -}; - -export const Row: Story = { - render: () => ( -

- - {}} /> - - -
- ), -}; diff --git a/frontend/editor/src/core/ui/LabelChip.tsx b/frontend/editor/src/core/ui/LabelChip.tsx deleted file mode 100644 index 7e45bc4a2a..0000000000 --- a/frontend/editor/src/core/ui/LabelChip.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { ReactNode } from "react"; -import CloseIcon from "@mui/icons-material/Close"; -import { LocalIcon } from "@app/components/shared/LocalIcon"; -import "@app/ui/LabelChip.css"; - -export interface LabelChipProps { - /** The label text. */ - label: string; - /** Material Symbols key for a leading icon; ignored when `leading` is given. */ - icon?: string; - /** Custom leading node (e.g. an icon picker), overriding `icon`. */ - leading?: ReactNode; - /** Optional trailing count (e.g. how many files carry this label). */ - count?: number; - /** Show a trailing `×`; called on click. */ - onRemove?: () => void; - /** Accessible name for the remove button. */ - removeAriaLabel?: string; -} - -/** - * A classification-label pill: leading icon (or a custom control like an icon - * picker) + name, with an optional count and remove button. The shared look for - * every place labels are shown as chips — the team labels editor and the - * sidebar category manager both render this so they stay visually identical. - */ -export function LabelChip({ - label, - icon, - leading, - count, - onRemove, - removeAriaLabel, -}: LabelChipProps) { - return ( - - {leading ?? ( - - - - )} - - {label} - - {count != null && count > 0 && ( - {count} - )} - {onRemove && ( - - )} - - ); -} diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index bf6e4b395c..1e2087537c 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -56,11 +56,11 @@ export interface PolicyField { export interface PolicyCategory { id: string; label: string; - icon: string; tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red"; desc: string; providesClassification?: boolean; comingSoon?: boolean; + requiresAiEngine?: boolean; } export interface PolicyConfigDef { @@ -133,14 +133,21 @@ export interface CatalogueEntry { /* Endpoint display labels */ /* ──────────────────────────────────────────────────────────────────────── */ -/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */ -export const ENDPOINT_LABELS: Partial> = { +/** + * i18n keys keyed by endpoint; labels stored steps in the detail view. Mostly + * {@link ToolEndpoint}s, plus the AI classify endpoint, which isn't part of the generated union. + */ +export const ENDPOINT_LABELS: Partial< + Record +> = { "/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact", "/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf", "/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark", "/api/v1/misc/ocr-pdf": "portal.policies.endpoints.ocrPdf", "/api/v1/misc/flatten": "portal.policies.endpoints.flatten", "/api/v1/misc/compress-pdf": "portal.policies.endpoints.compressPdf", + "/api/v1/ai/tools/classify-and-label": + "portal.policies.endpoints.classifyAndLabel", }; export function humanizeEndpoint( @@ -170,7 +177,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "ingestion", label: "portal.policies.categories.ingestion.label", - icon: "layers", tone: "blue", desc: "portal.policies.categories.ingestion.desc", providesClassification: true, @@ -179,14 +185,20 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "security", label: "portal.policies.categories.security.label", - icon: "shield", tone: "purple", desc: "portal.policies.categories.security.desc", }, + { + id: "classification", + label: "portal.policies.categories.classification.label", + tone: "blue", + desc: "portal.policies.categories.classification.desc", + providesClassification: true, + requiresAiEngine: true, + }, { id: "compliance", label: "portal.policies.categories.compliance.label", - icon: "check", tone: "amber", desc: "portal.policies.categories.compliance.desc", comingSoon: true, @@ -194,7 +206,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "routing", label: "portal.policies.categories.routing.label", - icon: "route", tone: "green", desc: "portal.policies.categories.routing.desc", comingSoon: true, @@ -202,7 +213,6 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "retention", label: "portal.policies.categories.retention.label", - icon: "schedule", tone: "neutral", desc: "portal.policies.categories.retention.desc", comingSoon: true, @@ -264,6 +274,16 @@ export const POLICY_CONFIG: Record = { ], fields: [], }, + classification: { + summary: "portal.policies.config.classification.summary", + rules: [ + "portal.policies.config.classification.rules.0", + "portal.policies.config.classification.rules.1", + ], + scopeLabel: "portal.policies.config.scopeAll", + defaultOperations: [policyStep("classify")], + fields: [], + }, compliance: { summary: "portal.policies.config.compliance.summary", rules: [ diff --git a/frontend/editor/src/portal/components/PolicySummary.tsx b/frontend/editor/src/portal/components/PolicySummary.tsx index cffb822387..9be6ff49cf 100644 --- a/frontend/editor/src/portal/components/PolicySummary.tsx +++ b/frontend/editor/src/portal/components/PolicySummary.tsx @@ -15,7 +15,7 @@ import { type CatalogueEntry, type PoliciesResponse, } from "@portal/api/policies"; -import { policyIcon } from "@portal/components/policies/policyIcons"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import "@portal/components/PolicySummary.css"; /** @@ -64,7 +64,7 @@ export function PolicySummary() { render: ({ entry }) => (
- {policyIcon(entry.category.icon)} + {policyCategoryIcon(entry.category.id)}
{t(entry.category.label)} diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css new file mode 100644 index 0000000000..02f7bfdbf5 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css @@ -0,0 +1,53 @@ +/* Read-only classification-vocabulary viewer in the policy wizard. */ +.classification-summary { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.classification-summary-stats { + display: flex; + gap: var(--space-3); + font-size: 0.8125rem; + color: var(--color-text-2); +} +.classification-summary-note { + font-size: 0.75rem; + color: var(--color-text-3); +} + +/* Expandable category → labels list. */ +.classification-categories { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.classification-category { + border-top: 1px solid var(--border-default); +} +.classification-category:last-child { + border-bottom: 1px solid var(--border-default); +} +.classification-category-header { + min-height: 2.5rem; +} +.classification-category-lead { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} +.classification-category-name { + font-weight: 600; + font-size: 0.875rem; +} +.classification-category-count { + font-size: 0.75rem; + color: var(--color-text-3); +} +.classification-category-labels { + display: flex; + flex-wrap: wrap; + gap: var(--space-1_5); + padding: 0 var(--space-2) var(--space-2) 2rem; +} diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx new file mode 100644 index 0000000000..7a21cae2b7 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx @@ -0,0 +1,97 @@ +// Read-only view of the classification vocabulary shown in the policy wizard. The labels and their +// categories are a fixed, built-in set shared across the whole team — there's nothing to edit, but +// the full vocabulary is browsable: expand a category to see the labels it groups. + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import { Button, Card, Chip } from "@app/ui"; +import { LocalIcon } from "@app/components/shared/LocalIcon"; +import { + DEFAULT_CLASSIFICATION_LABELS, + LABEL_FAMILIES, +} from "@app/data/classificationLabels"; +import "@portal/components/policies/ClassificationLabelsSection.css"; + +export function ClassificationLabelsSection() { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState>(new Set()); + + const toggle = (id: string) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + return ( + +
+
+ + {DEFAULT_CLASSIFICATION_LABELS.length}{" "} + {t("policies.labels.labelCount", "labels")} + + + {LABEL_FAMILIES.length}{" "} + {t("policies.labels.categoryCount", "categories")} + +
+ +
    + {LABEL_FAMILIES.map((family) => { + const open = expanded.has(family.id); + return ( +
  • +
  • + ); + })} +
+ + + {t( + "policies.labels.sharedNote", + "These labels are built in and shared across your whole team.", + )} + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx index 91abcd3f75..c5169217dd 100644 --- a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx @@ -1,19 +1,28 @@ import { useTranslation } from "react-i18next"; import { Card, Chip, StatusBadge } from "@app/ui"; import type { CatalogueEntry } from "@portal/api/policies"; -import { policyIcon } from "@portal/components/policies/policyIcons"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import "@portal/views/Policies.css"; interface PolicyCategoryCardProps { entry: CatalogueEntry; onOpen: (entry: CatalogueEntry) => void; + /** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */ + locked?: boolean; + /** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */ + lockedLabel?: string; } -export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { +export function PolicyCategoryCard({ + entry, + onOpen, + locked = false, + lockedLabel, +}: PolicyCategoryCardProps) { const { t } = useTranslation(); const { category, config, policy } = entry; const comingSoon = category.comingSoon === true; - const openable = !comingSoon; + const openable = !comingSoon && !locked; const status = policy?.state.status; const enforces = config.rules.map((r) => t(r)).join(" · "); @@ -21,7 +30,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { onOpen(entry) : undefined} @@ -39,7 +48,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { } > - {policyIcon(category.icon)} + {policyCategoryIcon(category.id)}
@@ -53,6 +62,10 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { {t("portal.policies.card.comingSoon")} + ) : locked ? ( + + {lockedLabel ?? t("portal.policies.card.requiresAiEngine")} + ) : policy ? (
diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.stories.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.stories.tsx index 01aaad2fc6..bf68a1ffe1 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.stories.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.stories.tsx @@ -8,6 +8,9 @@ import { import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; const security = POLICY_CATEGORIES.find((c) => c.id === "security")!; +const classification = POLICY_CATEGORIES.find( + (c) => c.id === "classification", +)!; const meta: Meta = { title: "Portal/Policies/PolicySetupWizard", @@ -47,3 +50,14 @@ export const Edit: Story = { }, }, }; + +/** Classification: the workflow step shows the team label editor, not tool toggles. */ +export const Classification: Story = { + args: { + entry: { + category: classification, + config: POLICY_CONFIG.classification, + policy: null, + }, + }, +}; diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index 43be7ca17c..632e3f610c 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -1,5 +1,10 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import CheckIcon from "@mui/icons-material/Check"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; +import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined"; +import StorageOutlinedIcon from "@mui/icons-material/StorageOutlined"; import { Banner, Button, @@ -29,12 +34,27 @@ import { import { fetchSources } from "@portal/api/sources"; import { useAsync } from "@portal/hooks/useAsync"; import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; -import { policyIcon } from "@portal/components/policies/policyIcons"; -import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig"; import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig"; +import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection"; import "@portal/views/Policies.css"; +/** Outline icon for a source tile, keyed by the backend source `type`. */ +function sourceIcon(type: string): ReactNode { + const sx = { fontSize: "1.1rem" } as const; + switch (type) { + case "editor": + return ; + case "folder": + return ; + case "s3": + return ; + default: + return ; + } +} + interface PolicySetupWizardProps { /** The category being configured, or null when closed. */ entry: CatalogueEntry | null; @@ -119,6 +139,13 @@ const CAPABILITY_META: Record< descKey: "portal.policies.wizard.capability.compress.desc", descEn: "Compresses the document to a smaller file size.", }, + classify: { + labelKey: "portal.policies.wizard.capability.classify.label", + labelEn: "Classify the document", + descKey: "portal.policies.wizard.capability.classify.desc", + descEn: + "Identifies the document's type from your team's labels and tags it, so it files and searches by category.", + }, }; function seedTools(entry: CatalogueEntry): ToolState[] { @@ -179,9 +206,18 @@ function PolicySetupWizardBody({ const { category, config, policy } = entry; const isEdit = policy != null; + const isClassification = category.id === "classification"; const [step, setStep] = useState("workflow"); - const [tools, setTools] = useState(() => seedTools(entry)); + const [tools, setTools] = useState(() => { + const seeded = seedTools(entry); + // Classification's single tool has no toggle in the workflow step, so keep it + // enabled unconditionally — otherwise editing a policy whose saved steps + // somehow lack it would strand submit with no way to re-enable it. + return isClassification + ? seeded.map((t) => ({ ...t, enabled: true })) + : seeded; + }); const [fieldValues, setFieldValues] = useState(() => resolveFieldValues(entry), ); @@ -190,11 +226,25 @@ function PolicySetupWizardBody({ ); const sourcesAsync = useAsync(() => fetchSources(), []); - const availableSources = useMemo( - () => - (sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"), - [sourcesAsync.data], - ); + const availableSources = useMemo(() => { + const backendSources = (sourcesAsync.data?.sources ?? []).filter( + (s) => s.status !== "disabled", + ); + // The editor is always an available source. The backend now returns it as a + // virtual source too, so take that when present (avoids a duplicate tile) and + // otherwise fall back to a synthetic one; keep it first, selected by default. + const editorSource = backendSources.find((s) => s.id === "editor") ?? { + id: "editor", + name: t("portal.sources.types.editor.label"), + type: "editor", + status: "active" as const, + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: null, + }; + return [editorSource, ...backendSources.filter((s) => s.id !== "editor")]; + }, [sourcesAsync.data, t]); // Document-type scoping has no UI; preserve any saved scope on edit and // default new policies to all document types. const [scopeTypes] = useState(policy?.state.scopeTypes ?? []); @@ -285,7 +335,7 @@ function PolicySetupWizardBody({ title={ - {policyIcon(category.icon)} + {policyCategoryIcon(category.id)} {isEdit ? t("portal.policies.wizard.title.edit", { @@ -349,7 +399,25 @@ function PolicySetupWizardBody({ /> )} - {step === "workflow" && ( + {step === "workflow" && isClassification && ( +
+

+ {t( + "portal.policies.wizard.classification.description", + "Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team.", + )} +

+

+ {t( + "portal.policies.wizard.classification.labelsHeading", + "Classification labels", + )} +

+ +
+ )} + + {step === "workflow" && !isClassification && (

{t( @@ -448,35 +516,38 @@ function PolicySetupWizardBody({ // The backend always returns the editor as a virtual source, so the // loaded list is never empty - no "no sources" state exists.

- {availableSources.map((src) => ( - // A selectable multi-line tile (icon + name + type + check). - // Uses the shared Button (raw - ))} + + ); + })}
)} diff --git a/frontend/editor/src/portal/components/policies/policyIcons.ts b/frontend/editor/src/portal/components/policies/policyIcons.ts deleted file mode 100644 index 5ab6113b49..0000000000 --- a/frontend/editor/src/portal/components/policies/policyIcons.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Glyphs for the policy catalogue's string icon keys. The catalogue model - * carries semantic keys (e.g. "shield", "layers") rather than React nodes so - * the data stays portable; the portal owns the rendering and maps each key to a - * glyph here. - */ -export const POLICY_ICON_GLYPHS: Record = { - layers: "▤", - shield: "🛡", - check: "✓", - route: "⇉", - clock: "⏲", - // Source icons. - file: "▢", - device: "▣", - globe: "◍", - cloud: "☁", - mail: "✉", - folder: "▤", -}; - -/** Resolve an icon key to its glyph, falling back to a neutral dot. */ -export function policyIcon(key: string): string { - return POLICY_ICON_GLYPHS[key] ?? "•"; -} diff --git a/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts b/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts new file mode 100644 index 0000000000..9fe76a19fa --- /dev/null +++ b/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts @@ -0,0 +1,26 @@ +// AI-engine flag from the backend's public app-config. Gates Classification +// setup: the card always shows but can't be enabled until the engine is on. +// `loading` lets callers hold the decision rather than flash a locked card. + +import { apiClient } from "@portal/api/http"; +import { useAsync } from "@portal/hooks/useAsync"; + +interface AppConfigShape { + aiEngineEnabled?: boolean; +} + +export interface AiEngineState { + enabled: boolean; + loading: boolean; +} + +export function useAiEngineEnabled(): AiEngineState { + const state = useAsync( + () => apiClient.local.json("/api/v1/config/app-config"), + [], + ); + return { + enabled: Boolean(state.data?.aiEngineEnabled), + loading: state.loading && state.data === null, + }; +} diff --git a/frontend/editor/src/portal/mocks/handlers/classification.ts b/frontend/editor/src/portal/mocks/handlers/classification.ts new file mode 100644 index 0000000000..c41ae9a254 --- /dev/null +++ b/frontend/editor/src/portal/mocks/handlers/classification.ts @@ -0,0 +1,10 @@ +import { http, HttpResponse } from "msw"; + +// The Classification policy only needs the app-config `aiEngineEnabled` flag to be +// on; its label vocabulary is a fixed built-in set (no team endpoint anymore). + +export const classificationHandlers = [ + http.get("/api/v1/config/app-config", () => + HttpResponse.json({ aiEngineEnabled: true }), + ), +]; diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index 52e8ab75b9..2e50a14eb1 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -12,6 +12,7 @@ import { usersHandlers } from "@portal/mocks/handlers/users"; import { teamSaasHandlers } from "@portal/mocks/handlers/teamSaas"; import { agentsHandlers } from "@portal/mocks/handlers/agents"; import { policiesHandlers } from "@portal/mocks/handlers/policies"; +import { classificationHandlers } from "@portal/mocks/handlers/classification"; import { documentsHandlers } from "@portal/mocks/handlers/documents"; import { sdkComponentsHandlers } from "@portal/mocks/handlers/sdkComponents"; import { editorDeployHandlers } from "@portal/mocks/handlers/editorDeploy"; @@ -32,6 +33,7 @@ export const handlers = [ ...teamSaasHandlers, ...agentsHandlers, ...policiesHandlers, + ...classificationHandlers, ...documentsHandlers, ...sdkComponentsHandlers, ...editorDeployHandlers, diff --git a/frontend/editor/src/portal/views/Policies.css b/frontend/editor/src/portal/views/Policies.css index 75adbc6a85..021e48d76c 100644 --- a/frontend/editor/src/portal/views/Policies.css +++ b/frontend/editor/src/portal/views/Policies.css @@ -266,116 +266,23 @@ } /* Sources picker */ +/* Selectable source tiles: a vertical stack of full-width shared Buttons + (icon · name · check). Layout is the Button's own leftSection/label/ + rightSection — only the selected border/tint is added here. */ .portal-policies__sources { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.5rem; -} - -@media (max-width: 40rem) { - .portal-policies__sources { - grid-template-columns: 1fr; - } -} - -.portal-policies__source { display: flex; - align-items: center; + flex-direction: column; gap: 0.5rem; - padding: 0.625rem; - text-align: left; - background: var(--color-surface); - border: 1.5px solid var(--color-border); - border-radius: var(--radius-md); - cursor: pointer; - /* Shared Button pins a fixed control height and natural width; this tile is a - full-width, two-line card, so fill the grid cell and grow to fit content. */ - width: 100%; - height: auto; - font-weight: inherit; - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} - -/* The shared Button wraps children in an inner/label node. Let the inner grow - (so the ::after check sits at the far right) and the label carry the - icon + two-line-text row. */ -.portal-policies__source .mantine-Button-inner { - flex: 1; - min-width: 0; -} - -.portal-policies__source .mantine-Button-label { - display: flex; - align-items: center; - gap: 0.5rem; - width: 100%; - white-space: normal; -} - -.portal-policies__source::after { - content: ""; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - margin-left: auto; - width: 1rem; - height: 1rem; - border-radius: var(--radius-sm); - border: 1.5px solid var(--color-border-strong); - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} - -.portal-policies__source:hover { - background: var(--color-bg-hover); } .portal-policies__source--on { border-color: var(--color-blue); - background: var(--color-blue-light); -} - -.portal-policies__source--on::after { - content: "✓"; - font-size: 0.6875rem; - font-weight: 700; - color: #fff; - border-color: var(--color-blue); - background: var(--color-blue); -} - -.portal-policies__source-icon { - font-size: 1rem; - line-height: 1.2; - flex-shrink: 0; - color: var(--color-text-3); -} - -.portal-policies__source--on .portal-policies__source-icon { - color: var(--color-blue); -} - -.portal-policies__source-text { - display: flex; - flex-direction: column; - gap: 0.0625rem; - min-width: 0; } .portal-policies__source-label { - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-policies__source-desc { - font-size: 0.6875rem; - color: var(--color-text-4); - line-height: 1.4; + display: inline-flex; + align-items: center; + gap: 0.5rem; } .portal-policies__link { diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index 8d58ed0e79..a4ffcf1067 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -20,6 +20,7 @@ import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary"; import { PolicyCategoryCard } from "@portal/components/policies/PolicyCategoryCard"; import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel"; import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; +import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled"; import "@portal/views/Policies.css"; export function Policies() { @@ -34,6 +35,18 @@ export function Policies() { const [busy, setBusy] = useState(false); const [pageError, setPageError] = useState(null); + const { enabled: aiEngineEnabled, loading: aiEngineLoading } = + useAiEngineEnabled(); + + function isLocked(entry: CatalogueEntry): boolean { + return ( + entry.category.requiresAiEngine === true && + !aiEngineEnabled && + !aiEngineLoading && + !entry.policy + ); + } + const catalogue = data?.catalogue ?? []; const refetch = useCallback(() => setVersion((v) => v + 1), []); // The catalogue cards are always shown (they're the "configure a policy" CTAs), @@ -57,6 +70,11 @@ export function Policies() { })); function openEntry(entry: CatalogueEntry) { + // Block setup of an AI-required policy until the engine is confirmed on (so a + // click during the app-config load can't open a wizard for a disabled + // feature); a configured policy stays openable so it can be paused/deleted. + if (entry.category.requiresAiEngine && !aiEngineEnabled && !entry.policy) + return; if (entry.policy) setDetail(entry); else setWizard(entry); } @@ -157,6 +175,8 @@ export function Policies() { key={entry.category.id} entry={entry} onOpen={openEntry} + locked={isLocked(entry)} + lockedLabel={t("portal.policies.card.requiresAiEngine")} /> ))}
diff --git a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.css b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.css new file mode 100644 index 0000000000..b967707164 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.css @@ -0,0 +1,37 @@ +/* Category show/hide list (Files-sidebar visibility picker). */ +.category-visibility { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.category-visibility-row { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 2.25rem; + padding: 0 var(--space-1); +} +.category-visibility-icon { + display: inline-flex; + align-items: center; + flex: none; + color: var(--color-text-2); +} +.category-visibility-name { + flex: 1; + min-width: 0; + font-size: 0.875rem; + color: var(--color-text-1); +} +.category-visibility-count { + font-size: 0.75rem; + color: var(--color-text-3); +} +/* Hidden categories read as dimmed until re-shown. */ +.category-visibility-row--hidden .category-visibility-icon, +.category-visibility-row--hidden .category-visibility-name, +.category-visibility-row--hidden .category-visibility-count { + opacity: 0.5; +} diff --git a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.stories.tsx b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.stories.tsx new file mode 100644 index 0000000000..43f8cd5ee2 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.stories.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ClassificationCategoryManager } from "@app/components/policies/ClassificationCategoryManager"; +import type { SidebarCategory } from "@app/services/fileSidebarCategories"; + +const CATEGORIES: SidebarCategory[] = [ + { id: "finance", name: "Financial", icon: "payments", labelKeys: [] }, + { id: "legal", name: "Legal", icon: "gavel", labelKeys: [] }, + { id: "hr", name: "HR", icon: "badge", labelKeys: [] }, + { id: "reports", name: "Reports", icon: "monitoring", labelKeys: [] }, +]; + +const COUNTS = new Map([ + ["finance", 12], + ["legal", 4], + ["reports", 7], +]); + +// The store is device-local; the story holds the hidden state so the toggle is live. +function Harness() { + const [categories, setCategories] = useState(CATEGORIES); + return ( +
+ + setCategories((prev) => + prev.map((c) => (c.id === id ? { ...c, hidden } : c)), + ) + } + /> +
+ ); +} + +const meta: Meta = { + title: "Policies/ClassificationCategoryManager", + component: Harness, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** Show/hide the fixed, shared categories in the Files sidebar. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx new file mode 100644 index 0000000000..143e344f82 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx @@ -0,0 +1,68 @@ +// Uber-simple category visibility list for the Files sidebar. Categories are the fixed, shared set +// (the built-in label families) — they can't be created, renamed, re-grouped, or have their labels +// edited. The only choice is showing or hiding each one in this device's sidebar; a hidden category +// forms no group, so its files fall to "Other". + +import { useTranslation } from "react-i18next"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { LocalIcon } from "@app/components/shared/LocalIcon"; +import type { SidebarCategory } from "@app/services/fileSidebarCategories"; +import "@app/components/policies/ClassificationCategoryManager.css"; + +interface ClassificationCategoryManagerProps { + categories: SidebarCategory[]; + onToggleHidden: (id: string, hidden: boolean) => void; + /** Optional per-category file counts. */ + counts?: Map; +} + +export function ClassificationCategoryManager({ + categories, + onToggleHidden, + counts, +}: ClassificationCategoryManagerProps) { + const { t } = useTranslation(); + return ( +
    + {categories.map((category) => { + const count = counts?.get(category.id); + return ( +
  • + + + + {category.name} + {count !== undefined && ( + {count} + )} + onToggleHidden(category.id, !category.hidden)} + > + {category.hidden ? ( + + ) : ( + + )} + +
  • + ); + })} +
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/ClassificationLabelsSection.tsx b/frontend/editor/src/proprietary/components/policies/ClassificationLabelsSection.tsx deleted file mode 100644 index 82e1705196..0000000000 --- a/frontend/editor/src/proprietary/components/policies/ClassificationLabelsSection.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/** - * The Classification policy's team-labels control, shown in its Edit-Settings - * view: a compact summary (count + chips) with an Expand button that opens the - * fat {@link LabelsEditorModal}. Team-shared; only users who can configure - * policies may edit it. Owns the editable draft and the load/save/import/export - * wiring via {@link useClassificationLabels}. - */ - -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 "@app/ui/Card"; -import { Button } from "@app/ui/Button"; -import { Chip } from "@app/ui/Chip"; -import { Banner } from "@app/ui/Banner"; -import { useClassificationLabels } from "@app/hooks/useClassificationLabels"; -import { LabelsEditorModal } from "@app/components/policies/LabelsEditorModal"; -import { - downloadLabels, - parseLabelsFile, - validateLabels, -} from "@app/services/labelsFile"; -import { - DEFAULT_CLASSIFICATION_LABELS, - type ClassificationLabel, -} from "@app/data/classificationLabels"; -import "@app/components/policies/LabelsEditor.css"; - -/** Chips shown in the collapsed team summary before it gets noisy. */ -const SUMMARY_CHIP_COUNT = 12; - -interface ClassificationLabelsSectionProps { - canConfigure: boolean; -} - -export function ClassificationLabelsSection({ - canConfigure, -}: ClassificationLabelsSectionProps) { - const { t } = useTranslation(); - const { teamLabels, isCustom, loading, saving, error, saveTeam } = - useClassificationLabels(true); - - const [draft, setDraft] = useState(teamLabels); - const [open, setOpen] = useState(false); - const [localError, setLocalError] = useState(null); - - // Sync the draft to server truth whenever it changes (load / save / reset). - // Local edits don't change `teamLabels`, so this never clobbers them mid-edit. - useEffect(() => setDraft(teamLabels), [teamLabels]); - - const dirty = useMemo( - () => JSON.stringify(draft) !== JSON.stringify(teamLabels), - [draft, teamLabels], - ); - - const close = () => { - setDraft(teamLabels); - setLocalError(null); - setOpen(false); - }; - - const onImportFile = (file: File) => { - setLocalError(null); - void parseLabelsFile(file) - .then(setDraft) - .catch((e: unknown) => - setLocalError( - e instanceof Error - ? e.message - : t("policies.labels.importError", "Couldn't import that file."), - ), - ); - }; - - const onSave = () => { - const errors = validateLabels({ labels: draft }); - if (errors.length > 0) { - setLocalError(errors[0]); - return; - } - setLocalError(null); - void saveTeam(draft).then(() => setOpen(false)); - }; - - return ( -
-

- {t("policies.labels.sectionLabel", "Classification labels")} -

- - {loading ? ( - {t("loading", "Loading…")} - ) : ( -
-
- - {teamLabels.length}{" "} - {t("policies.labels.teamCount", "team labels")} - -
-
- {teamLabels.slice(0, SUMMARY_CHIP_COUNT).map((label) => ( - - {label.name} - - ))} - {teamLabels.length > SUMMARY_CHIP_COUNT && ( - - +{teamLabels.length - SUMMARY_CHIP_COUNT} - - )} -
- - {isCustom - ? t("policies.labels.customNote", "Customized for your team.") - : t( - "policies.labels.defaultNote", - "Using the built-in default, shared with your team.", - )} - - -
- )} -
- - {!canConfigure && ( - } - description={t( - "policies.labels.managedNote", - "Team labels are managed by your team leader.", - )} - /> - )} - - {error && !open && } - - downloadLabels(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_LABELS); - }} - onClear={() => { - // Stage an empty list to build from scratch — also reversible until Save. - setLocalError(null); - setDraft([]); - }} - onSave={onSave} - dirty={dirty} - saving={saving} - readOnly={!canConfigure} - error={localError ?? error} - /> -
- ); -} diff --git a/frontend/editor/src/proprietary/components/policies/LabelIconPicker.tsx b/frontend/editor/src/proprietary/components/policies/LabelIconPicker.tsx deleted file mode 100644 index a1ee0894d8..0000000000 --- a/frontend/editor/src/proprietary/components/policies/LabelIconPicker.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Small popover picker for a classification label's icon: a trigger button - * showing the current icon, opening a grid of the curated - * {@link LABEL_ICON_OPTIONS}. Icons render via {@link LocalIcon} (Material - * Symbols). No search yet — the palette is small enough to scan. - */ - -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Popover, SimpleGrid, Tooltip } from "@mantine/core"; -import { ActionIcon } from "@app/ui/ActionIcon"; -import { LocalIcon } from "@app/components/shared/LocalIcon"; -import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; -import { LABEL_ICON_OPTIONS, DEFAULT_LABEL_ICON } from "@app/data/labelIcons"; - -interface LabelIconPickerProps { - /** Current icon key, or undefined to show the default placeholder. */ - value?: string; - onChange: (icon: string) => void; - ariaLabel: string; -} - -export function LabelIconPicker({ - value, - onChange, - ariaLabel, -}: LabelIconPickerProps) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - - return ( - setOpen(false)} - position="bottom-start" - withArrow - trapFocus - withinPortal - zIndex={Z_INDEX_AUTOMATE_DROPDOWN} - > - - setOpen((o) => !o)} - aria-label={ariaLabel} - aria-haspopup="true" - > - - - - - - {LABEL_ICON_OPTIONS.map((option) => { - const iconLabel = t( - `policies.labels.iconName.${option.icon}`, - option.label, - ); - return ( - - { - onChange(option.icon); - setOpen(false); - }} - aria-label={t( - "policies.labels.pickIcon", - "Use {{label}} icon", - { - label: iconLabel, - }, - )} - aria-pressed={option.icon === value} - > - - - - ); - })} - - - - ); -} diff --git a/frontend/editor/src/proprietary/components/policies/LabelsEditor.css b/frontend/editor/src/proprietary/components/policies/LabelsEditor.css deleted file mode 100644 index 20e8cb32cc..0000000000 --- a/frontend/editor/src/proprietary/components/policies/LabelsEditor.css +++ /dev/null @@ -1,183 +0,0 @@ -/* Classification-labels editor: add box, chip grid, icon picker, and the - summary/modal chrome around them. */ - -.labels-editor { - display: flex; - flex-direction: column; - gap: var(--space-2); - min-height: 0; -} - -/* ---- Add box ---- */ -.labels-add { - display: flex; - align-items: center; - gap: var(--space-2); -} -.labels-add-input { - flex: 1; - min-width: 0; - padding: 0.4rem 0.6rem; - font-size: 0.8125rem; - font-family: inherit; - color: var(--color-text-1); - background: var(--color-surface); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - outline: none; - transition: border-color var(--motion-fast); -} -.labels-add-input:focus { - border-color: var(--color-blue); -} -.labels-add-error { - margin: 0; - font-size: 0.75rem; - color: var(--color-red); -} -.labels-empty { - margin: 0; - font-size: 0.8125rem; - color: var(--color-text-3); -} - -/* ---- Chip grid (chips themselves are the shared @app/ui/LabelChip) ---- */ -.labels-chips { - display: flex; - flex-wrap: wrap; - gap: var(--space-1_5); - overflow-y: auto; - min-height: 0; -} - -/* ---- Icon picker (trigger + popover options) ---- */ -.labels-icon-pick { - display: inline-flex; - align-items: center; - justify-content: center; - flex: none; - width: 1.5rem; - height: 1.5rem; - border: none; - border-radius: var(--radius-md); - background: transparent; - color: var(--color-text-2); - cursor: pointer; - transition: - background var(--motion-fast), - color var(--motion-fast); -} -.labels-icon-pick:hover { - background: var(--color-bg-hover); - color: var(--color-text-1); -} -.labels-icon-option { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border: none; - background: transparent; - border-radius: var(--radius-md); - color: var(--color-text-2); - cursor: pointer; - transition: - background var(--motion-fast), - color var(--motion-fast); -} -.labels-icon-option:hover { - background: var(--color-bg-hover); - color: var(--color-text-1); -} -.labels-icon-option.is-selected { - background: var(--color-blue-light); - color: var(--color-blue); -} - -/* ---- Section summary (Edit-Settings card) ---- */ -.labels-summary { - display: flex; - flex-direction: column; - gap: var(--space-2); -} -.labels-summary-stats { - display: flex; - gap: var(--space-3); - font-size: 0.8125rem; - color: var(--color-text-2); -} -.labels-summary-note { - font-size: 0.75rem; - color: var(--color-text-3); -} - -/* ---- Fat modal ---- */ -.labels-modal { - width: min(1100px, 94vw); - max-width: min(1100px, 94vw); -} -.labels-modal-body { - max-height: min(70vh, 640px); - overflow-y: auto; - display: flex; - flex-direction: column; - gap: var(--space-3); - min-height: 0; -} -.labels-toolbar { - display: flex; - gap: var(--space-2); -} -.labels-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - width: 100%; -} -.labels-footer-left, -.labels-footer-right { - display: flex; - align-items: center; - gap: var(--space-2); -} - -/* ---- Grouped mode: chips under collapsible parent categories ---- */ -.labels-groups { - display: flex; - flex-direction: column; - gap: var(--space-3); -} -.labels-group { - display: flex; - flex-direction: column; - gap: var(--space-2); -} -.labels-group-header { - display: flex; - align-items: center; - gap: var(--space-2); - border: none; - background: transparent; - padding: 0; - cursor: pointer; - font-size: 0.85rem; - font-weight: 600; - color: var(--color-text-1, inherit); -} -.labels-group-name { - flex: 1; - text-align: left; -} -.labels-group-count { - font-size: 0.75rem; - font-weight: 500; - color: var(--color-text-3, rgba(128, 128, 128, 0.9)); -} -.labels-group-ungrouped { - margin: 0; - font-size: 0.85rem; - font-weight: 600; - color: var(--color-text-2, inherit); -} diff --git a/frontend/editor/src/proprietary/components/policies/LabelsEditor.tsx b/frontend/editor/src/proprietary/components/policies/LabelsEditor.tsx deleted file mode 100644 index 16b04367ff..0000000000 --- a/frontend/editor/src/proprietary/components/policies/LabelsEditor.tsx +++ /dev/null @@ -1,258 +0,0 @@ -// Editor for a classification-label list: an add box on top, then the labels as chips (icon picker + remove), duplicate names rejected case-insensitively (optionally also against `reservedNames`). In `grouped` mode the chips are organised under collapsible parent categories (the device-local sidebar categories), matching the sidebar's category picker; labels in no category fall under "Ungrouped". Grouping is presentational — editing still mutates the flat list. - -import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import AddIcon from "@mui/icons-material/Add"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; -import { Button } from "@app/ui/Button"; -import { LabelChip } from "@app/ui/LabelChip"; -import { LocalIcon } from "@app/components/shared/LocalIcon"; -import { LabelIconPicker } from "@app/components/policies/LabelIconPicker"; -import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons"; -import { - getSidebarCategories, - subscribeSidebarCategories, -} from "@app/services/fileSidebarCategories"; -import { - labelId, - type ClassificationLabel, -} from "@app/data/classificationLabels"; - -const MAX_TEXT_LENGTH = 128; - -interface LabelsEditorProps { - value: ClassificationLabel[]; - onChange: (next: ClassificationLabel[]) => void; - readOnly?: boolean; - /** Names that can't be added here because another set already owns them. */ - reservedNames?: string[]; - addPlaceholder?: string; - emptyText?: string; - /** Render chips under collapsible parent categories (like the sidebar picker). */ - grouped?: boolean; -} - -export function LabelsEditor({ - value, - onChange, - readOnly = false, - reservedNames, - addPlaceholder, - emptyText, - grouped = false, -}: LabelsEditorProps) { - const { t } = useTranslation(); - const [pending, setPending] = useState(""); - const [addError, setAddError] = useState(null); - - const add = () => { - const name = pending.trim(); - if (!name) return; - if (name.length > MAX_TEXT_LENGTH) { - setAddError( - t( - "policies.labels.tooLong", - "Labels can be at most {{max}} characters.", - { max: MAX_TEXT_LENGTH }, - ), - ); - return; - } - const key = name.toLowerCase(); - const taken = - value.some((label) => label.name.toLowerCase() === key) || - (reservedNames ?? []).some((reserved) => reserved.toLowerCase() === key); - if (taken) { - setAddError( - t("policies.labels.duplicate", '"{{name}}" already exists.', { name }), - ); - return; - } - setAddError(null); - setPending(""); - onChange([...value, { id: labelId(name), name }]); - }; - - const removeById = (id: string) => - onChange(value.filter((label) => label.id !== id)); - - const setIconById = (id: string, icon: string) => - onChange( - value.map((label) => (label.id === id ? { ...label, icon } : label)), - ); - - const renderChip = (label: ClassificationLabel) => { - const key = label.id; - return ( - - -
- ) : ( - setIconById(key, icon)} - ariaLabel={t( - "policies.labels.iconAria", - "Choose an icon for {{name}}", - { name: label.name }, - )} - /> - ) - } - onRemove={readOnly ? undefined : () => removeById(key)} - removeAriaLabel={t("policies.labels.removeAria", "Remove {{name}}", { - name: label.name, - })} - /> - ); - }; - - return ( -
- {!readOnly && ( -
- { - setPending(e.target.value); - if (addError) setAddError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - add(); - } - }} - /> - -
- )} - {addError &&

{addError}

} - - {value.length === 0 ? ( -

- {emptyText ?? t("policies.labels.empty", "No labels yet.")} -

- ) : grouped ? ( - - ) : ( -
- {value.map(renderChip)} -
- )} -
- ); -} - -interface GroupedLabelsProps { - value: ClassificationLabel[]; - renderChip: (label: ClassificationLabel) => ReactNode; -} - -/** Chips laid out under collapsible parent categories (device-local structure). */ -function GroupedLabels({ value, renderChip }: GroupedLabelsProps) { - const { t } = useTranslation(); - const categories = useSyncExternalStore( - subscribeSidebarCategories, - getSidebarCategories, - ); - - // Category sections that actually contain labels from `value`, plus the leftovers. - const { sections, ungrouped } = useMemo(() => { - const byId = new Map(value.map((l) => [l.id, l])); - const claimed = new Set(); - const sections = categories - .map((category) => { - const members = category.labelKeys - .map((id) => byId.get(id)) - .filter((l): l is ClassificationLabel => !!l); - members.forEach((m) => claimed.add(m.id)); - return { category, members }; - }) - .filter((s) => s.members.length > 0); - const ungrouped = value.filter((l) => !claimed.has(l.id)); - return { sections, ungrouped }; - }, [value, categories]); - - // Only the first section starts expanded — a scannable overview. - const [expanded, setExpanded] = useState>( - () => new Set(sections.length > 0 ? [sections[0].category.id] : []), - ); - const toggle = (id: string) => - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - - return ( -
- {sections.map(({ category, members }) => { - const isOpen = expanded.has(category.id); - return ( -
- - {isOpen && ( -
- {members.map(renderChip)} -
- )} -
- ); - })} - {ungrouped.length > 0 && ( -
-

- {t("policies.labels.ungrouped", "Ungrouped")} -

-
- {ungrouped.map(renderChip)} -
-
- )} -
- ); -} diff --git a/frontend/editor/src/proprietary/components/policies/LabelsEditorModal.tsx b/frontend/editor/src/proprietary/components/policies/LabelsEditorModal.tsx deleted file mode 100644 index 9cafed3aeb..0000000000 --- a/frontend/editor/src/proprietary/components/policies/LabelsEditorModal.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Full-screen ("fat") editor for the team's classification labels — the roomy - * view the settings summary's Edit button opens. Hosts the {@link LabelsEditor} - * chip grid 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 settings 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 "@app/ui/Modal"; -import { Button } from "@app/ui/Button"; -import { Banner } from "@app/ui/Banner"; -import { LabelsEditor } from "@app/components/policies/LabelsEditor"; -import type { ClassificationLabel } from "@app/data/classificationLabels"; - -interface LabelsEditorModalProps { - open: boolean; - onClose: () => void; - draft: ClassificationLabel[]; - onDraftChange: (next: ClassificationLabel[]) => void; - onImportFile: (file: File) => void; - onExport: () => void; - /** Stage the built-in default into the draft. */ - onReset: () => void; - /** Stage an empty list into the draft (build from scratch). */ - onClear: () => void; - onSave: () => void; - dirty: boolean; - saving: boolean; - readOnly: boolean; - /** Save (server) or import (file) failure to surface, if any. */ - error: string | null; -} - -export function LabelsEditorModal({ - open, - onClose, - draft, - onDraftChange, - onImportFile, - onExport, - onReset, - onClear, - onSave, - dirty, - saving, - readOnly, - error, -}: LabelsEditorModalProps) { - const { t } = useTranslation(); - const fileInput = useRef(null); - - const handleFile = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - // Reset the input so picking the same file twice still fires onChange. - e.target.value = ""; - if (file) onImportFile(file); - }; - - return ( - -
- {!readOnly && ( - <> - - - - )} -
-
- - {!readOnly && ( - - )} -
-
- } - > -
- {error && ( - } - description={error} - /> - )} - {!readOnly && ( -
- - - -
- )} - -
- - ); -} diff --git a/frontend/editor/src/proprietary/components/policies/policyToolChains.ts b/frontend/editor/src/proprietary/components/policies/policyToolChains.ts deleted file mode 100644 index a7c224d1ea..0000000000 --- a/frontend/editor/src/proprietary/components/policies/policyToolChains.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * The fixed, configurable tool chain per policy category — the locked set the - * config page shows (one section per tool). Tools can be configured + toggled - * on/off, but not added or removed. Each id is a frontend tool-registry key (and - * maps to that tool's backend endpoint via the registry's operationConfig). - */ -export const POLICY_TOOL_CHAINS: Record = { - // 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. */ -export function getPolicyToolChain(categoryId: string): string[] | null { - return POLICY_TOOL_CHAINS[categoryId] ?? null; -} diff --git a/frontend/editor/src/proprietary/data/classificationLabels.drift.test.ts b/frontend/editor/src/proprietary/data/classificationLabels.drift.test.ts new file mode 100644 index 0000000000..9250169d37 --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationLabels.drift.test.ts @@ -0,0 +1,54 @@ +import fs from "fs"; +import path from "path"; +import { describe, it, expect } from "vitest"; +import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels"; + +// The classifier vocabulary is bundled in TWO places that must not drift: +// - this frontend JSON (source of truth for sidebar categories + display names) +// - a backend copy the classify tool sends to the AI engine per request +// (app/proprietary/src/main/resources/classification/classification-labels.json). +// The backend sends label IDS the engine stores on the document and the frontend +// then displays, so a divergence means the classifier picks a label the UI can't +// render (or the UI advertises labels the classifier never uses). This guards it. + +const REPO_ROOT = path.resolve(__dirname, "../../../../.."); +const BACKEND_LABELS_FILE = path.join( + REPO_ROOT, + "app/proprietary/src/main/resources/classification/classification-labels.json", +); + +interface Label { + id: string; + name: string; + icon?: string | null; +} + +function readBackendLabels(): Label[] { + const raw = JSON.parse(fs.readFileSync(BACKEND_LABELS_FILE, "utf8")) as { + labels: Label[]; + }; + return raw.labels; +} + +describe("classification label vocabulary (frontend ↔ backend)", () => { + const frontend = DEFAULT_CLASSIFICATION_LABELS; + const backend = readBackendLabels(); + + it("has the identical set of label ids on both sides", () => { + const frontendIds = [...new Set(frontend.map((l) => l.id))].sort(); + const backendIds = [...new Set(backend.map((l) => l.id))].sort(); + expect(backendIds).toEqual(frontendIds); + }); + + it("has matching name and icon per id on both sides", () => { + const backendById = new Map(backend.map((l) => [l.id, l])); + for (const label of frontend) { + const other = backendById.get(label.id); + expect(other, `backend missing label "${label.id}"`).toBeDefined(); + expect({ name: other!.name, icon: other!.icon ?? null }).toEqual({ + name: label.name, + icon: label.icon ?? null, + }); + } + }); +}); diff --git a/frontend/editor/src/proprietary/data/classificationLabels.ts b/frontend/editor/src/proprietary/data/classificationLabels.ts index 3695255c4e..900a1b09af 100644 --- a/frontend/editor/src/proprietary/data/classificationLabels.ts +++ b/frontend/editor/src/proprietary/data/classificationLabels.ts @@ -1,14 +1,14 @@ -// Default classification labels. The SOURCE OF TRUTH is the co-located static -// JSON (`classificationLabels.json`), imported here and shaped into typed -// objects — edit THAT file, not this one. This is the ONLY copy of the label -// data: it seeds a team's editable set and drives the sidebar's grouping, -// icons, and display names. Neither the backend nor the engine keeps a copy. +// Classification labels. The SOURCE OF TRUTH is the co-located static JSON +// (`classificationLabels.json`), imported here and shaped into typed objects — +// edit THAT file, not this one. It's a fixed, built-in vocabulary shared by +// everyone (no per-team customization); it drives the sidebar's grouping, icons, +// and display names. // -// NOTE: this is only the built-in default vocabulary. A team's own -// (admin-editable) labels live in the backend store and are what the backend -// sends to the engine per classify request — the engine holds no vocabulary of -// its own. Edits to a team's set reach Python on the next run, independent of -// this file. +// The backend keeps a SECOND copy of this list +// (`app/proprietary/src/main/resources/classification/classification-labels.json`) +// which it sends to the engine per classify request (the engine holds no +// vocabulary of its own). The two copies must not drift — `classificationLabels.drift.test.ts` +// guards that. When you edit the labels here, update the backend copy too. // // `labels` is the flat set: each has a stable `id` (slug — the value on the wire, // in storage and keyed on) and a human `name` (display, translatable via diff --git a/frontend/editor/src/proprietary/data/labelIcons.ts b/frontend/editor/src/proprietary/data/labelIcons.ts index c71e7a9da4..66affc07cd 100644 --- a/frontend/editor/src/proprietary/data/labelIcons.ts +++ b/frontend/editor/src/proprietary/data/labelIcons.ts @@ -1,22 +1,21 @@ /** - * Curated palette of icons a classification label can use, shown in the label - * icon picker and rendered in the file sidebar's label groups. Keys are - * Material Symbols names rendered via {@link LocalIcon}. + * Curated palette of icons the classification labels use, rendered in the file + * sidebar's label/category groups. Keys are Material Symbols names rendered via + * {@link LocalIcon}. * - * IMPORTANT: each entry is written as an `icon: "…"` literal so the icon-bundler - * (`scripts/generate-icons.js`, which regex-scans for icon literals) picks every - * one up and bundles it — otherwise a picked icon would fall back to the CDN and - * render blank offline. After adding/removing entries run `task frontend:prepare:icons`. - * - * Search is intentionally omitted for now (no synonyms to maintain); the palette - * is small enough to eyeball. + * IMPORTANT — this list is also the icon BUNDLING MANIFEST for classification. + * The label/family icons live in `classificationLabels.json`, but the bundler + * (`scripts/generate-icons.js`) only regex-scans `.ts/.tsx` for `icon: "…"` + * literals, not JSON — so every icon those labels use must appear here as a + * literal or it falls back to the CDN and renders blank offline. Do NOT delete + * `LABEL_ICON_OPTIONS` as "unused": nothing imports it, but it's load-bearing + * for offline icons. After editing, run `task frontend:prepare:icons`. */ export interface LabelIconOption { /** Material Symbols key (no `material-symbols:` prefix). */ icon: string; - /** Short English name — the en-US default for the icon's tooltip/aria; the - * picker translates it via `policies.labels.iconName.`. */ + /** Short English name for the icon (documentation only). */ label: string; } @@ -188,8 +187,3 @@ export const LABEL_ICON_OPTIONS: LabelIconOption[] = [ { icon: "recycling", label: "Recycling" }, { icon: "agriculture", label: "Agriculture" }, ]; - -/** Set of valid palette keys, for validating a stored/imported icon. */ -export const LABEL_ICON_KEYS: ReadonlySet = new Set( - LABEL_ICON_OPTIONS.map((option) => option.icon), -); diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.tsx b/frontend/editor/src/proprietary/data/policyDefinitions.tsx index 3842b1885a..f3377c028e 100644 --- a/frontend/editor/src/proprietary/data/policyDefinitions.tsx +++ b/frontend/editor/src/proprietary/data/policyDefinitions.tsx @@ -5,18 +5,13 @@ * user's real files, not defined here. */ -import LayersIcon from "@mui/icons-material/Layers"; -import ShieldIcon from "@mui/icons-material/Shield"; -import CheckCircleIcon from "@mui/icons-material/CheckCircle"; -import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; -import StorageIcon from "@mui/icons-material/Storage"; import DescriptionIcon from "@mui/icons-material/Description"; import ComputerIcon from "@mui/icons-material/Computer"; 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 { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import type { PolicyCategory, PolicyConfigDef, @@ -30,7 +25,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "ingestion", label: "Ingestion", - icon: , + icon: policyCategoryIcon("ingestion", ICON_SX), desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.", // The classifier the wizard's "Set up Classification" action routes to. providesClassification: true, @@ -40,13 +35,13 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "security", label: "Security", - icon: , + icon: policyCategoryIcon("security", ICON_SX), desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.", }, { id: "classification", label: "Classification", - icon: , + icon: policyCategoryIcon("classification", 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, @@ -54,21 +49,21 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "compliance", label: "Compliance", - icon: , + icon: policyCategoryIcon("compliance", ICON_SX), desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.", comingSoon: true, }, { id: "routing", label: "Routing", - icon: , + icon: policyCategoryIcon("routing", ICON_SX), desc: "Auto-route documents to the right team, folder, or system.", comingSoon: true, }, { id: "retention", label: "Retention", - icon: , + icon: policyCategoryIcon("retention", ICON_SX), desc: "Set how long documents are kept, when to archive, and when to delete.", comingSoon: true, }, diff --git a/frontend/editor/src/proprietary/hooks/useClassificationLabels.ts b/frontend/editor/src/proprietary/hooks/useClassificationLabels.ts deleted file mode 100644 index 5f034ad7fc..0000000000 --- a/frontend/editor/src/proprietary/hooks/useClassificationLabels.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Loads and persists the team's classification labels - * (`/api/v1/classification/labels`) — one server-truth set shared by the whole - * team. When the team has none, this shows the built-in default as a starting - * point for the editor/sidebar; note the classifier itself does NOT use that - * default — the backend only classifies against a team's saved set (an unsaved - * team is not classified). Editing is gated to team leaders / admins by the - * backend — callers pass `canConfigure` (the policy gate) to keep read-only - * users out of the save path. - * - * `teamLabels` is also what the sidebar/editor display uses — the team set, or - * the built-in default when the team has none. - */ - -import { useCallback, useEffect, useState } from "react"; -import { - DEFAULT_CLASSIFICATION_LABELS, - type ClassificationLabel, -} from "@app/data/classificationLabels"; -import { fetchTeamLabels, saveTeamLabels } from "@app/services/labelsBackend"; - -export interface UseClassificationLabels { - /** Server-truth team labels (or the built-in default when the team has none). */ - teamLabels: ClassificationLabel[]; - /** Whether the team has a stored set (vs. the built-in default). */ - isCustom: boolean; - loading: boolean; - saving: boolean; - /** Last save failure, cleared on the next attempt. */ - error: string | null; - /** Persist the team set; resolves once server state is updated. */ - saveTeam: (next: ClassificationLabel[]) => Promise; -} - -export function useClassificationLabels( - enabled: boolean, -): UseClassificationLabels { - const [teamLabels, setTeamLabels] = useState( - DEFAULT_CLASSIFICATION_LABELS, - ); - const [isCustom, setIsCustom] = useState(false); - const [loading, setLoading] = useState(enabled); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!enabled) return; - let cancelled = false; - setLoading(true); - void (async () => { - const team = await fetchTeamLabels().catch(() => null); - if (cancelled) return; - setTeamLabels(team ?? DEFAULT_CLASSIFICATION_LABELS); - setIsCustom(team != null); - setLoading(false); - })(); - return () => { - cancelled = true; - }; - }, [enabled]); - - const saveTeam = useCallback(async (next: ClassificationLabel[]) => { - setSaving(true); - setError(null); - try { - const saved = await saveTeamLabels(next); - setTeamLabels(saved); - setIsCustom(true); - } catch (e) { - setError(e instanceof Error ? e.message : "Couldn't save the labels."); - throw e; - } finally { - setSaving(false); - } - }, []); - - return { - teamLabels, - isCustom, - loading, - saving, - error, - saveTeam, - }; -} diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index be95079b3a..8043e6b66a 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -35,8 +35,6 @@ import { removePolicy, } from "@app/services/policyBackend"; import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi"; -import { seedTeamLabelsIfEmpty } from "@app/services/labelsBackend"; -import { getPolicyToolChain } from "@app/components/policies/policyToolChains"; import type { PolicyToStore } from "@app/services/policyPipeline"; import type { PoliciesByCategory, @@ -139,13 +137,6 @@ export function usePolicies() { async (id: string, result: PolicyWizardResult) => { const category = loadPolicyCatalog().categories.find((c) => c.id === id); if (!category) throw new Error(`Unknown policy category: ${id}`); - // A policy whose chain classifies runs against the team's stored label set - // (the engine has no default). Seed it with the built-in defaults now so - // the very first enforced file has a vocabulary to classify against; no-op - // if the team already has a set (never clobbers admin edits). - if (getPolicyToolChain(id)?.includes("classify")) { - await seedTeamLabelsIfEmpty(); - } // One policy per category, ever: reuse any existing backend record. const existingBackendId = loadPolicies()[id]?.backendId ?? @@ -225,12 +216,6 @@ export function usePolicies() { async (id: string, result: PolicyConfigResult) => { const category = loadPolicyCatalog().categories.find((c) => c.id === id); if (!category) throw new Error(`Unknown policy category: ${id}`); - // Seed the team's default label set on first classification-policy setup - // (see enablePolicy) — the engine has no default, so the stored set is the - // only vocabulary. No-op once the team has any set. - if (getPolicyToolChain(id)?.includes("classify")) { - await seedTeamLabelsIfEmpty(); - } const current = loadPolicies()[id]; // One policy per category, ever: reuse the existing backend record (even // if the local link was lost) so a save never creates a duplicate. diff --git a/frontend/editor/src/proprietary/policies/operations.test.ts b/frontend/editor/src/proprietary/policies/operations.test.ts index 491a15aa59..253e9ae6bd 100644 --- a/frontend/editor/src/proprietary/policies/operations.test.ts +++ b/frontend/editor/src/proprietary/policies/operations.test.ts @@ -13,8 +13,9 @@ const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[]; describe("POLICY_OPERATIONS", () => { test("every category operation is a typed descriptor with a known endpoint", () => { - // The catalogue uses these six across all categories; each must be wired. + // The catalogue uses these across all categories; each must be wired. expect(ALL_TOOL_IDS.sort()).toEqual([ + "classify", "compress", "flatten", "ocr", diff --git a/frontend/editor/src/proprietary/policies/operations.ts b/frontend/editor/src/proprietary/policies/operations.ts index 66e92c448a..5d07df1717 100644 --- a/frontend/editor/src/proprietary/policies/operations.ts +++ b/frontend/editor/src/proprietary/policies/operations.ts @@ -11,10 +11,39 @@ import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAd import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation"; import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation"; import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; -import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor"; -import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes"; +import type { ToolEndpoint } from "@app/types/toolApiTypes"; import type { WirePipelineStep } from "@app/policies/types"; +/** + * Endpoints for AI-dispatched policy tools. Intentionally NOT part of the generated + * {@link ToolEndpoint} union: the backend controllers are `@Hidden` and `/api/v1/ai/tools/` is + * deliberately excluded from tool-model generation, so they can't be typed like standard tools. + */ +export type AiPolicyEndpoint = "/api/v1/ai/tools/classify-and-label"; + +/** An AI tool usable in a policy — the AI-endpoint analogue of a standard tool descriptor. */ +export interface AiToolDescriptor { + readonly endpoint: AiPolicyEndpoint; + readonly defaultParameters: TParams; + toApi(params: TParams): Record; + fromApi(api: Record): TParams; +} + +/** + * Describe an AI-dispatched policy tool. AI tools carry no tunable parameters today, so params are + * empty and the (de)serializers are identity over an empty object. + */ +export function describeAiToolOperation( + endpoint: AiPolicyEndpoint, +): AiToolDescriptor> { + return { + endpoint, + defaultParameters: {}, + toApi: () => ({}), + fromApi: () => ({}), + }; +} + export const POLICY_OPERATIONS = { redact: describeToolOperation( "/api/v1/security/auto-redact", @@ -37,17 +66,13 @@ export const POLICY_OPERATIONS = { "/api/v1/misc/compress-pdf", compressOperationConfig, ), + classify: describeAiToolOperation("/api/v1/ai/tools/classify-and-label"), } as const; export type PolicyToolId = keyof typeof POLICY_OPERATIONS; export type PolicyParams = - (typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor< - ToolEndpoint, - infer P - > - ? P - : never; + (typeof POLICY_OPERATIONS)[Id]["defaultParameters"]; /** Discriminated on `toolId` so `params` matches the tool. */ export type PolicyToolStep = { @@ -65,7 +90,10 @@ const TOOL_ID_BY_ENDPOINT = new Map( POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]), ); -export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint { +/** A policy step's endpoint: a standard {@link ToolEndpoint} or an {@link AiPolicyEndpoint}. */ +export type PolicyEndpoint = ToolEndpoint | AiPolicyEndpoint; + +export function policyEndpoint(toolId: PolicyToolId): PolicyEndpoint { return POLICY_OPERATIONS[toolId].endpoint; } @@ -90,19 +118,29 @@ export function policyStepToWire(step: PolicyToolStep): WirePipelineStep { return serializeStep(step); } +/** + * Minimal runtime shape shared by standard tool descriptors and {@link AiToolDescriptor}s — enough + * to (de)serialize a step at the wire boundary regardless of how its endpoint is typed. + */ +interface PolicyOperation { + readonly endpoint: string; + readonly defaultParameters: TParams; + toApi(params: TParams): Record; + fromApi(api: Record): TParams; +} + // Generic over the id so `params` stays correlated with the descriptor; TS can't do that through // the union, so `op` is widened here (a contained cast at the wire boundary). function serializeStep(step: { toolId: Id; params: PolicyParams; }): WirePipelineStep { - const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor< - ToolEndpoint, + const op = POLICY_OPERATIONS[step.toolId] as unknown as PolicyOperation< PolicyParams >; return { operation: op.endpoint, - parameters: op.toApi(step.params) as Record, + parameters: op.toApi(step.params), }; } @@ -119,13 +157,10 @@ function deserializeStep( toolId: Id, parameters: Record, ): PolicyToolStepOf { - const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor< - ToolEndpoint, + const op = POLICY_OPERATIONS[toolId] as unknown as PolicyOperation< PolicyParams >; // Wire params are untyped JSON; this is the one point they enter the typed model. - const params = op.fromApi( - parameters as unknown as ToolApiParams[ToolEndpoint], - ); + const params = op.fromApi(parameters); return { toolId, params } as unknown as PolicyToolStepOf; } diff --git a/frontend/editor/src/proprietary/services/fileSidebarCategories.ts b/frontend/editor/src/proprietary/services/fileSidebarCategories.ts index 0ae8dac0db..b1b74a5a2f 100644 --- a/frontend/editor/src/proprietary/services/fileSidebarCategories.ts +++ b/frontend/editor/src/proprietary/services/fileSidebarCategories.ts @@ -1,97 +1,82 @@ -// Device-local (localStorage) category structure for the Files sidebar: which parent -// categories exist, their name/icon/order, and which labels roll up into each. It's an editable -// override of the built-in LABEL_FAMILIES default — until the user customizes it, the default is -// used verbatim (so the store stays empty and the default can evolve). A label may sit in more -// than one category (multi-membership); a category with `hidden` set stays defined but isn't shown -// as a sidebar group. This is presentational only — the classifier never sees categories. +// The Files-sidebar categories are a fixed, built-in set shared by everyone (derived from +// LABEL_FAMILIES) — the team can't create, rename, or re-group them. The only per-user choice is +// which categories to SHOW or HIDE in the sidebar, kept device-local in localStorage. A hidden +// category isn't rendered as a group; its files fall to "Other". Presentational only — the +// classifier never sees categories. import { LABEL_FAMILIES } from "@app/data/classificationLabels"; export interface SidebarCategory { - /** Stable id — built-ins reuse their family id; custom ones get `custom:`. */ + /** Stable id (the label family's id). */ id: string; name: string; icon: string; /** Label ids in this category (matches a file's stored classification ids). */ labelKeys: string[]; - /** Defined but not rendered as a sidebar group. */ + /** Hidden from the sidebar (device-local, personal). */ hidden?: boolean; } -// v2: labelKeys hold label ids (was lower-cased names in v1); bumping discards -// stale name-keyed prefs so they don't silently stop matching. -const STORAGE_KEY = "stirling.fileSidebarCategories.v2"; +const HIDDEN_STORAGE_KEY = "stirling.fileSidebarHiddenCategories.v1"; -/** The built-in default, derived from LABEL_FAMILIES. Fresh copy per call (callers may mutate). */ -export function defaultCategories(): SidebarCategory[] { - return LABEL_FAMILIES.map((family) => ({ +/** The fixed, shared category set — never mutated. */ +const BASE_CATEGORIES: readonly Omit[] = + LABEL_FAMILIES.map((family) => ({ id: family.id, name: family.name, icon: family.icon, labelKeys: family.labels.map((label) => label.id), })); -} -function readStorage(): SidebarCategory[] | null { +function readHidden(): Set { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; + const raw = localStorage.getItem(HIDDEN_STORAGE_KEY); + if (!raw) return new Set(); const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) return null; - return parsed - .filter((c): c is SidebarCategory => { - const cat = c as Partial; - return ( - typeof cat.id === "string" && - typeof cat.name === "string" && - typeof cat.icon === "string" && - Array.isArray(cat.labelKeys) - ); - }) - .map((c) => ({ - id: c.id, - name: c.name, - icon: c.icon, - labelKeys: c.labelKeys.filter((k) => typeof k === "string"), - hidden: c.hidden === true, - })); + return Array.isArray(parsed) + ? new Set(parsed.filter((id): id is string => typeof id === "string")) + : new Set(); } catch { - return null; + return new Set(); } } -// null = using the built-in default (not yet customized). Cached so useSyncExternalStore sees a -// stable reference between writes. -let stored: SidebarCategory[] | null = readStorage(); +let hiddenIds = readHidden(); const listeners = new Set<() => void>(); -// Effective list, recomputed only on write so its identity is stable for memo/useSyncExternalStore. -let effective: SidebarCategory[] = stored ?? defaultCategories(); +// Recomputed only on write so its identity is stable for memo/useSyncExternalStore. +let effective: SidebarCategory[] = compute(); -function recompute() { - effective = stored ?? defaultCategories(); +function compute(): SidebarCategory[] { + return BASE_CATEGORIES.map((c) => ({ + ...c, + labelKeys: [...c.labelKeys], + hidden: hiddenIds.has(c.id), + })); } -function write(next: SidebarCategory[]) { - stored = next; - recompute(); +function persist() { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify([...hiddenIds])); } catch { - // Quota/private-mode failures degrade to session-only categories. + // Quota/private-mode failures degrade to session-only visibility. } + effective = compute(); for (const listener of listeners) listener(); } -/** Mutate the current effective list (snapshotting the default on first edit). */ -function mutate(fn: (categories: SidebarCategory[]) => SidebarCategory[]) { - write(fn(effective.map((c) => ({ ...c, labelKeys: [...c.labelKeys] })))); -} - +/** The shared categories, each flagged with the user's device-local visibility. */ export function getSidebarCategories(): SidebarCategory[] { return effective; } +/** Show or hide a category in this device's sidebar. */ +export function setCategoryHidden(id: string, hidden: boolean) { + if (hidden) hiddenIds.add(id); + else hiddenIds.delete(id); + persist(); +} + export function subscribeSidebarCategories(listener: () => void) { listeners.add(listener); return () => { @@ -99,98 +84,8 @@ export function subscribeSidebarCategories(listener: () => void) { }; } -export function isCustomized(): boolean { - return stored !== null; -} - -/** Map each label key to the ids of every VISIBLE category it belongs to. */ -export function labelCategoryMap( - categories: SidebarCategory[], -): Map { - const map = new Map(); - for (const category of categories) { - if (category.hidden) continue; - for (const key of category.labelKeys) { - const ids = map.get(key); - if (ids) ids.push(category.id); - else map.set(key, [category.id]); - } - } - return map; -} - -/** Set of every label key that belongs to any category (visible or not). */ -export function categorizedLabelKeys( - categories: SidebarCategory[], -): Set { - const keys = new Set(); - for (const category of categories) { - for (const key of category.labelKeys) keys.add(key); - } - return keys; -} - -// ---- editing ---- - -/** Create a new empty category; returns its id. */ -export function addCategory(name: string, icon: string): string { - const id = `custom:${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${ - effective.length - }`; - mutate((categories) => [...categories, { id, name, icon, labelKeys: [] }]); - return id; -} - -export function renameCategory(id: string, name: string) { - mutate((categories) => - categories.map((c) => (c.id === id ? { ...c, name } : c)), - ); -} - -export function setCategoryIcon(id: string, icon: string) { - mutate((categories) => - categories.map((c) => (c.id === id ? { ...c, icon } : c)), - ); -} - -export function setCategoryHidden(id: string, hidden: boolean) { - mutate((categories) => - categories.map((c) => (c.id === id ? { ...c, hidden } : c)), - ); -} - -export function deleteCategory(id: string) { - mutate((categories) => categories.filter((c) => c.id !== id)); -} - -export function addLabelToCategory(id: string, labelId: string) { - mutate((categories) => - categories.map((c) => - c.id === id && !c.labelKeys.includes(labelId) - ? { ...c, labelKeys: [...c.labelKeys, labelId] } - : c, - ), - ); -} - -export function removeLabelFromCategory(id: string, labelId: string) { - mutate((categories) => - categories.map((c) => - c.id === id - ? { ...c, labelKeys: c.labelKeys.filter((k) => k !== labelId) } - : c, - ), - ); -} - -/** Restore the built-in default and clear the customized flag (undoes any prior `mutate`). */ -export function resetSidebarCategories() { - stored = null; - recompute(); - try { - localStorage.removeItem(STORAGE_KEY); - } catch { - // Quota/private-mode failures degrade to session-only categories. - } - for (const listener of listeners) listener(); +/** Show every category again (clear the device-local hidden set). */ +export function resetHiddenCategories() { + hiddenIds = new Set(); + persist(); } diff --git a/frontend/editor/src/proprietary/services/labelsBackend.test.ts b/frontend/editor/src/proprietary/services/labelsBackend.test.ts deleted file mode 100644 index d530364b3a..0000000000 --- a/frontend/editor/src/proprietary/services/labelsBackend.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import apiClient from "@app/services/apiClient"; -import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels"; -import { seedTeamLabelsIfEmpty } from "@app/services/labelsBackend"; - -vi.mock("@app/services/apiClient"); - -const get = vi.mocked(apiClient.get); -const put = vi.mocked(apiClient.put); - -// The service only reads `status`/`data` off the axios response; a partial shape -// is all these tests need, so cast the mock values through this helper. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const res = (value: object): any => value; - -// 204 No Content (nothing stored) comes back as an empty body. -const emptyResponse = res({ status: 204, data: "" }); -const storedResponse = res({ - status: 200, - data: { labels: [{ id: "invoice", name: "Invoice", icon: "receipt" }] }, -}); -const putEcho = res({ data: { labels: DEFAULT_CLASSIFICATION_LABELS } }); - -describe("seedTeamLabelsIfEmpty", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("seeds the built-in defaults when the team has no set", async () => { - get.mockResolvedValue(emptyResponse); - put.mockResolvedValue(putEcho); - - await seedTeamLabelsIfEmpty(); - - expect(put).toHaveBeenCalledTimes(1); - expect(put).toHaveBeenCalledWith("/api/v1/classification/labels", { - labels: DEFAULT_CLASSIFICATION_LABELS, - }); - }); - - it("is a no-op when the team already has a set (never clobbers)", async () => { - get.mockResolvedValue(storedResponse); - - await seedTeamLabelsIfEmpty(); - - expect(put).not.toHaveBeenCalled(); - }); - - it("rides out a transient fetch failure, then seeds", async () => { - vi.useFakeTimers(); - get - .mockRejectedValueOnce(new Error("network blip")) - .mockResolvedValueOnce(emptyResponse); - put.mockResolvedValue(putEcho); - - const pending = seedTeamLabelsIfEmpty(); - await vi.runAllTimersAsync(); - await pending; - - expect(get).toHaveBeenCalledTimes(2); - expect(put).toHaveBeenCalledTimes(1); - }); - - it("rides out a transient write failure, then seeds", async () => { - vi.useFakeTimers(); - get.mockResolvedValue(emptyResponse); - put - .mockRejectedValueOnce(new Error("network blip")) - .mockResolvedValueOnce(putEcho); - - const pending = seedTeamLabelsIfEmpty(); - await vi.runAllTimersAsync(); - await pending; - - expect(put).toHaveBeenCalledTimes(2); - }); - - it("throws after a persistent fetch failure without writing (clobber-safe)", async () => { - vi.useFakeTimers(); - get.mockRejectedValue(new Error("backend down")); - - const pending = seedTeamLabelsIfEmpty(); - const assertion = expect(pending).rejects.toThrow("backend down"); - await vi.runAllTimersAsync(); - await assertion; - - // Never writes when the fetch can't confirm the team has no set. - expect(put).not.toHaveBeenCalled(); - expect(get).toHaveBeenCalledTimes(3); - }); - - it("throws after a persistent write failure", async () => { - vi.useFakeTimers(); - get.mockResolvedValue(emptyResponse); - put.mockRejectedValue(new Error("write failed")); - - const pending = seedTeamLabelsIfEmpty(); - const assertion = expect(pending).rejects.toThrow("write failed"); - await vi.runAllTimersAsync(); - await assertion; - - expect(put).toHaveBeenCalledTimes(3); - }); -}); diff --git a/frontend/editor/src/proprietary/services/labelsBackend.ts b/frontend/editor/src/proprietary/services/labelsBackend.ts deleted file mode 100644 index 8259f124de..0000000000 --- a/frontend/editor/src/proprietary/services/labelsBackend.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Backend layer for the team's classification labels - * (`/api/v1/classification/labels`) — one shared, server-truth list, editable - * only by a team leader (SaaS) / admin (self-hosted). A team with none reads as - * 204 → `null`, and callers fall back to the built-in - * {@link DEFAULT_CLASSIFICATION_LABELS}. - */ - -import apiClient from "@app/services/apiClient"; -import { - DEFAULT_CLASSIFICATION_LABELS, - type ClassificationLabel, -} from "@app/data/classificationLabels"; - -const TEAM_ENDPOINT = "/api/v1/classification/labels"; - -/** Wire shape shared with the backend: the label list wrapped in an object. */ -interface LabelsPayload { - labels: ClassificationLabel[]; -} - -async function fetchLabels( - endpoint: string, -): Promise { - const res = await apiClient.get(endpoint, { - suppressErrorToast: true, - }); - // 204 No Content (nothing stored) 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 LabelsPayload).labels ?? []; -} - -async function saveLabels( - endpoint: string, - labels: ClassificationLabel[], -): Promise { - const res = await apiClient.put(endpoint, { labels }); - return res.data.labels ?? []; -} - -/** The team's stored labels, or `null` when it has none (use the default). */ -export function fetchTeamLabels(): Promise { - return fetchLabels(TEAM_ENDPOINT); -} - -/** Persist the team's labels; returns the stored value. */ -export function saveTeamLabels( - labels: ClassificationLabel[], -): Promise { - return saveLabels(TEAM_ENDPOINT, labels); -} - -/** Seed-write retry budget: the seed is a hard prerequisite (see below), so ride - * out a transient blip rather than fail setup on the first hiccup. */ -const SEED_MAX_ATTEMPTS = 3; -const SEED_RETRY_MS = 400; - -async function withRetry(op: () => Promise): Promise { - let lastError: unknown; - for (let attempt = 0; attempt < SEED_MAX_ATTEMPTS; attempt++) { - try { - return await op(); - } catch (error) { - lastError = error; - if (attempt < SEED_MAX_ATTEMPTS - 1) { - await new Promise((resolve) => - setTimeout(resolve, SEED_RETRY_MS * (attempt + 1)), - ); - } - } - } - throw lastError; -} - -/** - * Seed the team's label set with the built-in defaults when it has none yet. - * - * The engine holds no default vocabulary, so the backend can only send what the - * team has stored — a team with an empty set classifies nothing (documents come - * back unlabelled and don't group). This writes the frontend's single default - * copy into the team set the first time a classification policy is set up, so - * classification works out of the box; the backend gates the write to admins. - * - * The seed is a hard prerequisite of enabling a classification policy, so it must - * land or fail loudly — never silently leave an empty set behind a created - * policy. Both the fetch and the write retry a transient failure; if either - * ultimately fails this THROWS, and the caller aborts the setup (the admin sees - * the error and retries) instead of shipping a policy that classifies nothing — - * the same way a failed policy save already aborts setup. Still clobber-safe: it - * writes only when the fetch DEFINITIVELY reports no set (204 → null), so it - * never overwrites a team's real (possibly customised) labels, and it's a no-op - * once any set exists (later admin edits are the source of truth). - */ -export async function seedTeamLabelsIfEmpty(): Promise { - const existing = await withRetry(fetchTeamLabels); - if (existing != null) return; - await withRetry(() => saveTeamLabels(DEFAULT_CLASSIFICATION_LABELS)); -} diff --git a/frontend/editor/src/proprietary/services/labelsFile.ts b/frontend/editor/src/proprietary/services/labelsFile.ts deleted file mode 100644 index 613b144c1f..0000000000 --- a/frontend/editor/src/proprietary/services/labelsFile.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Client-side import/export + validation for a classification-labels JSON file - * (`{"labels":[{"id":"invoice","name":"Invoice","icon":"receipt-long"}, …]}`). - * Sharing a label set between teams is done by exporting the JSON here and - * importing it on another team. Validation mirrors the backend - * (`LabelsValidator`) so a malformed file is caught before it's uploaded — the - * backend re-validates as the authority. A file may omit `id` (e.g. an older - * export); it is then derived from the name. - */ - -import { downloadJsonAsFile } from "@app/utils/downloadUtils"; -import { LABEL_ICON_KEYS } from "@app/data/labelIcons"; -import { - labelId, - type ClassificationLabel, -} from "@app/data/classificationLabels"; - -// Kept in sync with the backend LabelsValidator (the authority); enforced here -// too so an oversized import is rejected before upload. -const MAX_LABELS = 500; -const MAX_TEXT_LENGTH = 128; - -interface LabelsFileShape { - labels: ClassificationLabel[]; -} - -/** A label's effective id: the provided one, else derived from the name. */ -function effectiveId(label: Partial): string { - const provided = typeof label.id === "string" ? label.id.trim() : ""; - return provided || labelId(label.name?.trim() ?? ""); -} - -/** Human-readable problems with a candidate labels file; empty means valid. */ -export function validateLabels(value: unknown): string[] { - const errors: string[] = []; - if (typeof value !== "object" || value === null) { - return ["File is not a labels object."]; - } - const { labels } = value as Partial; - if (!Array.isArray(labels)) { - return ['File must have a "labels" list.']; - } - if (labels.length > MAX_LABELS) { - errors.push(`Too many labels (max ${MAX_LABELS}).`); - } - const seenIds = new Set(); - for (const label of labels) { - if (!isText(label?.name)) { - errors.push("Every label needs a non-empty name."); - continue; - } - const name = label.name.trim(); - if (name.length > MAX_TEXT_LENGTH) { - errors.push(`Label "${name}" is over ${MAX_TEXT_LENGTH} characters.`); - } - const id = effectiveId(label); - if (!id) { - errors.push(`Label "${name}" has no usable id.`); - continue; - } - if (seenIds.has(id)) errors.push(`Duplicate label: ${name}`); - seenIds.add(id); - } - return errors; -} - -/** Coerce a validated value into a normalized label list (trims, drops extras). */ -export function normalizeLabels( - labels: ClassificationLabel[], -): ClassificationLabel[] { - return labels.map((label): ClassificationLabel => { - const name = label.name.trim(); - const id = effectiveId(label); - // Keep the icon only if it's a known palette key, so a hand-crafted import - // can't set an unbundled key that renders blank. - return label.icon && LABEL_ICON_KEYS.has(label.icon) - ? { id, name, icon: label.icon } - : { id, name }; - }); -} - -/** Parse + validate a picked file, resolving to a normalized label list. */ -export async function parseLabelsFile( - file: File, -): Promise { - let parsed: unknown; - try { - parsed = JSON.parse(await file.text()); - } catch { - throw new Error("That file isn't valid JSON."); - } - const errors = validateLabels(parsed); - if (errors.length > 0) throw new Error(errors[0]); - return normalizeLabels((parsed as LabelsFileShape).labels); -} - -/** Trigger a download of the labels as a pretty-printed JSON file. */ -export function downloadLabels( - labels: ClassificationLabel[], - fileName = "classification-labels.json", -): void { - downloadJsonAsFile({ labels }, fileName); -} - -function isText(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} diff --git a/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.css b/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.css index de669c7f29..16712e351a 100644 --- a/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.css +++ b/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.css @@ -1,10 +1,4 @@ -/* Category-manager modal: editable parent categories with member-label chips. - Matches the labels editor's width so the two read as a set. */ -.fsg-modal { - width: min(1100px, 94vw); - max-width: min(1100px, 94vw); -} - +/* Category show/hide modal (Files-sidebar visibility picker). */ .fsg-body { display: flex; flex-direction: column; @@ -14,119 +8,6 @@ padding-right: 4px; } -.fsg-cat { - display: flex; - flex-direction: column; - gap: 6px; -} - -.fsg-cat-header { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - font-weight: 600; - color: var(--text-primary, inherit); -} - -.fsg-cat-toggle { - display: inline-flex; - border: none; - background: transparent; - padding: 0; - cursor: pointer; -} - -.fsg-chevron { - font-size: 1.1rem !important; - color: var(--text-muted, rgba(128, 128, 128, 0.9)); - margin-left: -4px; -} - -.fsg-cat-name { - flex: 1; - text-align: left; - border: none; - background: transparent; - padding: 2px 4px; - border-radius: 4px; - font: inherit; - color: inherit; - cursor: text; -} - -.fsg-cat-name:hover { - background: var(--hover-bg, rgba(128, 128, 128, 0.12)); -} - -.fsg-rename { - flex: 1; -} - -.fsg-cat-action { - display: inline-flex; - border: none; - background: transparent; - padding: 2px; - border-radius: 4px; - cursor: pointer; - color: var(--text-muted, rgba(128, 128, 128, 0.9)); -} - -.fsg-cat-action:hover { - background: var(--hover-bg, rgba(128, 128, 128, 0.12)); - color: var(--text-primary, inherit); -} - -.fsg-cat-body { - display: flex; - flex-direction: column; - gap: 6px; - /* Indent past the chevron so the hierarchy reads at a glance. */ - padding-left: 24px; -} - -/* Member chips are the shared @app/ui/LabelChip. */ -.fsg-chips { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.fsg-count { - font-size: 11px; - font-weight: 500; - color: var(--text-muted, rgba(128, 128, 128, 0.9)); -} - -.fsg-add, -.fsg-new { - display: flex; - align-items: center; - gap: 6px; -} - -.fsg-new { - padding-top: 4px; - border-top: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.2)); -} - -.fsg-add-input { - flex: 1; - min-width: 0; - padding: 4px 8px; - font-size: 12px; - border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.35)); - border-radius: 6px; - background: transparent; - color: var(--text-primary, inherit); -} - -.fsg-add-input:focus { - outline: none; - border-color: var(--color-primary, #4f46e5); -} - .fsg-footer { display: flex; justify-content: space-between; diff --git a/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.tsx b/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.tsx index 12e1430e3c..41d1358594 100644 --- a/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.tsx +++ b/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.tsx @@ -1,74 +1,46 @@ -// The Files-sidebar category manager: a "tune" button opening a modal where you shape the parent categories your files group under. Each category (collapsible, busiest first) has an editable name + icon, a hide toggle, delete, and its member-label chips; add existing team labels to a category, create new categories, reset to the built-in defaults. All device-local (grouping only — it never changes the team's label vocabulary); files in no visible category fall back to "Other". +// The Files-sidebar category picker: a "tune" button opening a modal that lists the fixed, shared +// categories and lets the user show or hide each one in their own sidebar. Device-local and +// presentational only — it never changes the shared categories or the label vocabulary; files in a +// hidden (or no) category fall back to "Other". import { useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import TuneIcon from "@mui/icons-material/Tune"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; -import VisibilityIcon from "@mui/icons-material/Visibility"; -import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; -import AddIcon from "@mui/icons-material/Add"; -import { TextInput } from "@mantine/core"; import { Modal } from "@app/ui/Modal"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; -import { LabelChip } from "@app/ui/LabelChip"; -import { LabelIconPicker } from "@app/components/policies/LabelIconPicker"; +import { ClassificationCategoryManager } from "@app/components/policies/ClassificationCategoryManager"; import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled"; -import { useClassificationLabels } from "@app/hooks/useClassificationLabels"; -import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons"; import { bucketStubsByLabel } from "@app/components/shared/fileSidebarGroupingLogic"; import { - addCategory, - addLabelToCategory, - deleteCategory, getSidebarCategories, - removeLabelFromCategory, - renameCategory, - resetSidebarCategories, + resetHiddenCategories, setCategoryHidden, - setCategoryIcon, subscribeSidebarCategories, } from "@app/services/fileSidebarCategories"; import type { StirlingFileStub } from "@app/types/fileContext"; import "@app/components/shared/FileSidebarGroupControls.css"; interface FileSidebarGroupControlsProps { - /** The files currently listed, for live per-label counts. */ + /** The files currently listed, for live per-category counts. */ stubs: StirlingFileStub[]; } -/** New categories start with a neutral folder icon the user can change. */ -const NEW_CATEGORY_ICON = "folder"; - export function FileSidebarGroupControls({ stubs, }: FileSidebarGroupControlsProps) { const { t } = useTranslation(); const enabled = useClassificationEnabled(); const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); const categories = useSyncExternalStore( subscribeSidebarCategories, getSidebarCategories, ); - // Only fetch the team label set while the picker is open. - const { teamLabels: labelSet } = useClassificationLabels(open); - // Bucketed once and reused for both the per-label and per-category counts below. - const byLabel = useMemo(() => bucketStubsByLabel(stubs), [stubs]); - - // Per-label file counts from the same bucketing the sidebar groups use. - const labelCounts = useMemo(() => { - const counts = new Map(); - for (const [key, bucket] of byLabel) counts.set(key, bucket.stubs.length); - return counts; - }, [byLabel]); - - // Files per category (deduped across its labels). + // Files per category (deduped across its labels), from the same bucketing the sidebar groups use. const categoryCounts = useMemo(() => { + const byLabel = bucketStubsByLabel(stubs); const counts = new Map(); for (const category of categories) { const ids = new Set(); @@ -80,90 +52,7 @@ export function FileSidebarGroupControls({ counts.set(category.id, ids.size); } return counts; - }, [byLabel, categories]); - - // Translated display name + icon per label id, plus a name→id lookup for the - // add-label input (which matches on the visible/canonical text). - const vocab = useMemo(() => { - const byId = new Map(); - const idByName = new Map(); - for (const label of labelSet) { - const display = t(`classification.labels.${label.id}`, label.name); - byId.set(label.id, { display, icon: label.icon }); - idByName.set(display.toLowerCase(), label.id); - idByName.set(label.name.toLowerCase(), label.id); - } - return { byId, idByName }; - }, [labelSet, t]); - - const labelDisplay = (id: string) => vocab.byId.get(id)?.display ?? id; - const labelIcon = (id: string) => - vocab.byId.get(id)?.icon ?? DEFAULT_LABEL_ICON; - - const q = query.trim().toLowerCase(); - const matches = (text: string) => q === "" || text.toLowerCase().includes(q); - - // Busiest categories first (ties keep declaration order). - const sortedCategories = useMemo( - () => - [...categories].sort( - (a, b) => - (categoryCounts.get(b.id) ?? 0) - (categoryCounts.get(a.id) ?? 0), - ), - [categories, categoryCounts], - ); - - // Per-category collapse; on open only the busiest starts expanded. - const [expanded, setExpanded] = useState>(new Set()); - const toggleExpanded = (id: string) => - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - - // Inline rename + add-label drafts, keyed by category id. - const [renaming, setRenaming] = useState(null); - const [renameDraft, setRenameDraft] = useState(""); - const [addDraft, setAddDraft] = useState>({}); - const [newCategory, setNewCategory] = useState(""); - - const openPicker = () => { - setQuery(""); - setRenaming(null); - setNewCategory(""); - setExpanded( - new Set(sortedCategories.length > 0 ? [sortedCategories[0].id] : []), - ); - setOpen(true); - }; - - const commitRename = (id: string) => { - const name = renameDraft.trim(); - if (name) renameCategory(id, name); - setRenaming(null); - }; - - // Add an existing team label to a category (device-local grouping only — categories don't change - // the team vocabulary). A name that isn't a known team label is ignored; the input's datalist - // steers callers to real ones. - const addLabel = (categoryId: string) => { - const name = (addDraft[categoryId] ?? "").trim(); - if (!name) return; - const labelId = vocab.idByName.get(name.toLowerCase()); - if (!labelId) return; - addLabelToCategory(categoryId, labelId); - setAddDraft((prev) => ({ ...prev, [categoryId]: "" })); - }; - - const createCategory = () => { - const name = newCategory.trim(); - if (!name) return; - const id = addCategory(name, NEW_CATEGORY_ICON); - setExpanded((prev) => new Set(prev).add(id)); - setNewCategory(""); - }; + }, [stubs, categories]); // Classification off (non-AI SaaS tenant) → no "customize groups" affordance, // matching the flat, ungrouped list. (All hooks above run unconditionally.) @@ -175,7 +64,7 @@ export function FileSidebarGroupControls({ setOpen(true)} aria-label={t("fileSidebar.customizeGroups", "Customize groups")} data-testid="customize-groups" > @@ -185,12 +74,11 @@ export function FileSidebarGroupControls({ setOpen(false)} - width="xl" - className="fsg-modal" + width="md" title={t("fileSidebar.groupsModal.title", "Sidebar categories")} subtitle={t( "fileSidebar.groupsModal.subtitle", - "Group your files into parent categories. Add existing or new labels to a category, rename it, or create your own. Files in none of your categories appear under “Other”.", + "Show or hide categories in the files sidebar.", )} footer={
@@ -198,9 +86,9 @@ export function FileSidebarGroupControls({ variant="tertiary" size="sm" leftSection={} - onClick={resetSidebarCategories} + onClick={resetHiddenCategories} > - {t("fileSidebar.groupsModal.reset", "Reset to defaults")} + {t("fileSidebar.groupsModal.reset", "Show all")} - )} - - {categoryCounts.get(category.id) ?? 0} - - - setCategoryHidden(category.id, !category.hidden) - } - > - {category.hidden ? ( - - ) : ( - - )} - - deleteCategory(category.id)} - > - - -
- - {isExpanded && ( -
-
- {memberKeys.map((key) => ( - - removeLabelFromCategory(category.id, key) - } - removeAriaLabel={t( - "fileSidebar.groupsModal.removeLabel", - "Remove {{name}}", - { name: labelDisplay(key) }, - )} - /> - ))} -
-
- - setAddDraft((prev) => ({ - ...prev, - [category.id]: e.target.value, - })) - } - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - addLabel(category.id); - } - }} - /> - - {suggestions.map((name) => ( - - -
-
- )} - - ); - })} - -
- setNewCategory(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - createCategory(); - } - }} - /> - -
diff --git a/frontend/editor/src/saas/components/shared/fileSidebarGrouping.test.ts b/frontend/editor/src/saas/components/shared/fileSidebarGrouping.test.ts index ee55eabb5e..07f9d5c7db 100644 --- a/frontend/editor/src/saas/components/shared/fileSidebarGrouping.test.ts +++ b/frontend/editor/src/saas/components/shared/fileSidebarGrouping.test.ts @@ -35,7 +35,6 @@ describe("buildLabelGroups", () => { stub("b", ["contract"]), stub("c", ["nda", "contract"]), ], - [], t, [LEGAL], )!; @@ -44,20 +43,15 @@ describe("buildLabelGroups", () => { expect(legal.stubs.map((s) => s.id)).toEqual(["a", "b", "c"]); }); - it("gives a label not in any category its own group", () => { - const groups = buildLabelGroups( - [stub("a", ["weird-one"])], - [{ id: "weird-one", name: "Weird one" }], - t, - [LEGAL], - )!; - const group = groups.find((g) => g.id === "label:weird-one")!; - expect(group.label).toBe("Weird one"); - expect(group.stubs.map((s) => s.id)).toEqual(["a"]); + it("puts a label in no category into Other", () => { + const groups = buildLabelGroups([stub("a", ["weird-one"])], t, [LEGAL])!; + expect(groups.some((g) => g.id.startsWith("category:"))).toBe(false); + expect(groups.at(-1)!.id).toBe("other"); + expect(groups.at(-1)!.stubs.map((s) => s.id)).toEqual(["a"]); }); it("moves a hidden category's files into Other", () => { - const groups = buildLabelGroups([stub("a", ["invoice"])], [], t, [ + const groups = buildLabelGroups([stub("a", ["invoice"])], t, [ cat("finance", "Financial", ["invoice"], true), ])!; expect(groups.some((g) => g.id === "category:finance")).toBe(false); @@ -66,7 +60,7 @@ describe("buildLabelGroups", () => { }); it("a file with labels in two categories appears in both", () => { - const groups = buildLabelGroups([stub("a", ["nda", "invoice"])], [], t, [ + const groups = buildLabelGroups([stub("a", ["nda", "invoice"])], t, [ LEGAL, FINANCE, ])!; @@ -82,7 +76,6 @@ describe("buildLabelGroups", () => { it("puts unlabelled files in Other at the bottom", () => { const groups = buildLabelGroups( [stub("a", ["invoice"]), stub("b"), stub("c", [])], - [], t, [FINANCE], )!; @@ -91,7 +84,7 @@ describe("buildLabelGroups", () => { expect(other.stubs.map((s) => s.id)).toEqual(["b", "c"]); }); - it("orders groups: Recent, groups alphabetically, Other last", () => { + it("orders groups: Recent, categories alphabetically, Other last", () => { const groups = buildLabelGroups( [ stub("a", ["invoice"]), @@ -99,20 +92,19 @@ describe("buildLabelGroups", () => { stub("c", ["zzz"]), stub("d"), ], - [], t, [LEGAL, FINANCE], )!; + // "zzz" is in no category, so its file falls to Other with the unlabelled one. expect(groups.map((g) => g.id)).toEqual([ "recent", "category:finance", "category:legal", - "label:zzz", "other", ]); }); it("returns null for an empty library", () => { - expect(buildLabelGroups([], [], t, [LEGAL])).toBeNull(); + expect(buildLabelGroups([], t, [LEGAL])).toBeNull(); }); }); diff --git a/frontend/editor/src/saas/components/shared/fileSidebarGrouping.tsx b/frontend/editor/src/saas/components/shared/fileSidebarGrouping.tsx index 911b44de26..659efbdf31 100644 --- a/frontend/editor/src/saas/components/shared/fileSidebarGrouping.tsx +++ b/frontend/editor/src/saas/components/shared/fileSidebarGrouping.tsx @@ -1,4 +1,4 @@ -// Classification override of the Files-sidebar grouping seam: Recent, one group per VISIBLE category (device-local, editable — default from the built-in label families), a standalone group for any label not yet in a category, then Other for files in none of those. Labels are cached on the stub via a lazy metadata backfill so grouping stays cheap. +// Classification override of the Files-sidebar grouping seam: Recent, one group per VISIBLE category (the fixed, shared label families; each can be hidden device-local), then Other for files in none of those. Labels are cached on the stub via a lazy metadata backfill so grouping stays cheap. import { useEffect, @@ -10,7 +10,6 @@ import { import { useTranslation } from "react-i18next"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled"; -import { useClassificationLabels } from "@app/hooks/useClassificationLabels"; import { fileStorage } from "@app/services/fileStorage"; import { readStubClassificationLabels } from "@app/services/fileClassification"; import { hasInFlightPolicyRuns } from "@app/components/policies/policyRunStore"; @@ -55,7 +54,6 @@ export function useFileSidebarGroups( // like core, and don't fetch team labels or backfill from metadata. Gates the // whole feature so an AI-off SaaS tenant sees no Recent/Other/category chrome. const enabled = useClassificationEnabled(); - const { teamLabels: labelSet } = useClassificationLabels(enabled); const { bumpRevision } = useIndexedDB(); // Attempted reads keyed by id+lastModified: a re-classified file (new version bumps lastModified) is re-read and leaves "Other" on its own, while a truly-unlabelled file keeps a stable key and is read once. const attempted = useRef>(new Set()); @@ -113,7 +111,7 @@ export function useFileSidebarGroups( getSidebarCategories, ); return useMemo( - () => (enabled ? buildLabelGroups(stubs, labelSet, t, categories) : null), - [enabled, stubs, labelSet, t, categories], + () => (enabled ? buildLabelGroups(stubs, t, categories) : null), + [enabled, stubs, t, categories], ); } diff --git a/frontend/editor/src/saas/components/shared/fileSidebarGroupingLogic.ts b/frontend/editor/src/saas/components/shared/fileSidebarGroupingLogic.ts index 5b61d4a18b..82569a9bd5 100644 --- a/frontend/editor/src/saas/components/shared/fileSidebarGroupingLogic.ts +++ b/frontend/editor/src/saas/components/shared/fileSidebarGroupingLogic.ts @@ -2,10 +2,7 @@ import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons"; import { accentColor, accentCycleColor } from "@app/utils/accentColors"; -import { - categorizedLabelKeys, - type SidebarCategory, -} from "@app/services/fileSidebarCategories"; +import type { SidebarCategory } from "@app/services/fileSidebarCategories"; import type { StirlingFileStub } from "@app/types/fileContext"; import type { FileSidebarGroup } from "@core/components/shared/fileSidebarGrouping"; @@ -30,37 +27,22 @@ export function bucketStubsByLabel( } /** - * Pure grouping: Recent (top {@link RECENT_COUNT} by lastModified), then visible groups sorted - * alphabetically, then Other (files in no visible group) last. Visible groups are the non-hidden - * {@link SidebarCategory} entries with ≥1 file (a file lands in every category it has a label in), - * plus a standalone group for any label on a file that isn't in a category yet. + * Pure grouping: Recent (top {@link RECENT_COUNT} by lastModified), then one group per VISIBLE + * category with ≥1 file (a file lands in every category it has a label in), sorted alphabetically, + * then Other (files in no visible category) last. Categories are the fixed, shared set; a category + * the user has hidden forms no group, so its files fall to Other. */ export function buildLabelGroups( stubs: StirlingFileStub[], - labelSet: readonly { id: string; name: string; icon?: string }[], t: (key: string, fallback: string) => string, categories: SidebarCategory[], ): FileSidebarGroup[] | null { if (stubs.length === 0) return null; - // Display name + icon per label id from the effective set; ids no longer in the - // set still group, resolving to the id text and the default icon. - const nameById = new Map(); - const iconById = new Map(); - for (const label of labelSet) { - nameById.set(label.id, label.name); - iconById.set(label.id, label.icon); - } - const labelName = (id: string) => - t(`classification.labels.${id}`, nameById.get(id) ?? id); - const recent = [...stubs] .sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)) .slice(0, RECENT_COUNT); - const byLabel = bucketStubsByLabel(stubs); - const inACategory = categorizedLabelKeys(categories); - const visible: Omit[] = []; // One group per visible category: every file carrying any of its labels, in the input's order @@ -81,19 +63,6 @@ export function buildLabelGroups( }); } - // A label on a file but not in any category still gets its own group, so nothing is stranded - // until the user files it into a category. - for (const [labelId, bucket] of byLabel) { - if (inACategory.has(labelId)) continue; - visible.push({ - id: `label:${labelId}`, - label: labelName(labelId), - icon: iconById.get(labelId) ?? DEFAULT_LABEL_ICON, - stubs: bucket.stubs, - defaultExpanded: false, - }); - } - visible.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }), );