Compare commits

...
Author SHA1 Message Date
posthog-eu[bot]andGitHub 82f7c04a74 Give the desktop version-mismatch warning a resolution path
The Software Updates section showed a red "version mismatch" warning next
to "Check for Updates", but that button only queries for a newer release and
can never reconcile a frontend/backend mismatch — so users clicked it, saw a
spinner, and the warning stayed put.

Reframe the warning as an informational Alert placed at the bottom of the card,
decoupled from the update button, and give it a concrete resolution path
(restart to finish applying the update; reinstall the latest version if it
persists). It now names the two versions in play instead of the opaque
"client / AppConfig" wording.

Generated-By: PostHog Code
Task-Id: 008872df-bfde-4089-a6ed-f4a431e93957
2026-07-15 11:58:43 +00:00
EthanHealy01andGitHub 7f01bcdc44 Classifier setup as a processor policy (#7012)
## 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.
2026-07-15 11:04:30 +00:00
EthanHealy01andGitHub 0570c4c4d9 Create-PDF engine: render from a structured document (#7018) 2026-07-14 12:30:33 +00:00
77 changed files with 2768 additions and 3392 deletions
+12
View File
@@ -208,6 +208,18 @@
"moduleName": ".*",
"moduleLicense": "The W3C License"
},
{
"moduleName": "com.google.re2j:re2j",
"moduleLicense": "Go License"
},
{
"moduleName": "com.hubspot:algebra",
"moduleLicense": null
},
{
"moduleName": "com.hubspot.immutables:immutables-exceptions",
"moduleLicense": null
},
{
"moduleName": ".*",
"moduleLicense": "UnRar License"
+14
View File
@@ -66,6 +66,20 @@ dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
// jinjava/jjwt transitively request older Jackson 2 versions; declare the current
// version directly so it is selected consistently (root build.gradle pins are the fallback).
runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") {
// Compile-time-only annotation artifacts (class-retention annotations, not needed at
// runtime) whose declared licences (LGPL / none) fail the licence compatibility check.
exclude group: 'com.google.code.findbugs', module: 'annotations'
exclude group: 'org.derive4j', module: 'derive4j-annotation'
exclude group: 'com.hubspot.immutables', module: 'hubspot-style'
exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings'
}
api 'io.micrometer:micrometer-registry-prometheus'
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
@@ -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<ClassificationLabel> 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<ClassificationLabel> labels) {
this.labels = List.copyOf(labels);
}
/** Build a provider with an explicit label set (tests). */
public static ClassificationLabelProvider withLabels(List<ClassificationLabel> labels) {
return new ClassificationLabelProvider(labels);
}
/** The built-in vocabulary, in file order. */
public List<ClassificationLabel> labels() {
return labels;
}
private static List<ClassificationLabel> 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();
}
}
}
@@ -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<ClassificationLabels> 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<ClassificationLabels> 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<Void> 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();
}
}
@@ -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<ClassificationLabel> labels) {
@@ -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<String> ids = new HashSet<>();
Set<String> 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)");
}
}
}
@@ -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<ClassificationLabels> 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);
}
@@ -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<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
@Override
public Optional<ClassificationLabels> 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;
}
}
@@ -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<ClassificationLabels> 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<ClassificationLabels> 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;
}
}
@@ -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;
}
@@ -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<TeamLabelsEntity, Long> {}
@@ -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.
*
* <p>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<EngineLabel> 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<EngineLabel> resolveAllowedLabels() {
if (labelStore == null) {
return List.of();
}
Long teamId =
policyManagementAuthority == null
? null
: policyManagementAuthority.currentUserTeamId();
Map<String, EngineLabel> byId = new LinkedHashMap<>();
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
collectLabels(labelProvider.labels(), byId);
return List.copyOf(byId.values());
}
@@ -8,12 +8,14 @@ import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Hidden;
@@ -24,18 +26,24 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.model.api.ai.create.AiDocument;
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
* Dispatchable tool that converts an AI-generated document model to a PDF via WeasyPrint.
*
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
* template so sanitization is intentionally skipped.
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as
* structured fields; the HTML is built here from a fixed template.
*/
@Slf4j
@Hidden
@@ -48,6 +56,9 @@ public class CreatePdfAgentController {
private final TempFileManager tempFileManager;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final AiDocumentHtmlRenderer htmlRenderer;
/**
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
@@ -74,32 +85,42 @@ public class CreatePdfAgentController {
value = "/create-pdf-from-html-agent",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Convert AI-generated HTML to a PDF",
summary = "Convert an AI-generated document to a PDF",
description =
"Accepts an HTML document as a plain-text parameter and returns a PDF."
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
+ " plan step; it is not intended for direct client use.")
public ResponseEntity<Resource> createPdfFromHtml(
@RequestParam("htmlContent") String htmlContent,
@RequestParam("filename") String filename)
"Accepts a structured document as a JSON parameter and returns a PDF. This"
+ " endpoint is dispatched by the AI workflow orchestrator as a plan"
+ " step; it is not intended for direct client use.")
public ResponseEntity<Resource> createPdf(
@RequestParam("document") String document, @RequestParam("filename") String filename)
throws Exception {
if (!applicationProperties.getAiEngine().isEnabled()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
AiDocument model;
try {
model = objectMapper.readValue(document, AiDocument.class);
} catch (JacksonException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
String html = htmlRenderer.render(model);
log.info(
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
htmlContent.length());
"[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}",
html.length());
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8);
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getWeasyPrintPath());
command.add("-e");
command.add("utf-8");
command.add("-v");
// SSRF: the HTML is self-contained and the engine validates style colours, so no
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
command.add(htmlFile.getAbsolutePath());
command.add(pdfFile.getAbsolutePath());
@@ -126,8 +147,8 @@ public class CreatePdfAgentController {
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
// which matters for large generated documents.
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
document.save(tempOut.getPath().toFile());
try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
pdDocument.save(tempOut.getPath().toFile());
} catch (Exception e) {
tempOut.close();
throw e;
@@ -0,0 +1,35 @@
package stirling.software.proprietary.model.api.ai.create;
import java.util.List;
import lombok.Data;
@Data
public class AiDocument {
private String title;
private String subtitle;
private String referenceNumber;
private Style style;
private List<Section> sections;
@Data
public static class Style {
private String primaryColor;
private String backgroundColor;
private String bodyTextColor;
}
@Data
public static class Section {
private String type;
private String heading;
private String body;
private List<List<String>> pairs;
private List<String> columns;
private List<List<String>> rows;
private List<String> totalRow;
private List<String> items;
private List<String> signatories;
}
}
@@ -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 {
@@ -0,0 +1,135 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import com.hubspot.jinjava.Jinjava;
import com.hubspot.jinjava.JinjavaConfig;
import stirling.software.proprietary.model.api.ai.create.AiDocument;
/** Renders an {@link AiDocument} to HTML using a Jinja template loaded from the classpath. */
@Component
public class AiDocumentHtmlRenderer {
private static final String TEMPLATE_PATH = "templates/ai/create/document.html.jinja2";
private static final Pattern SAFE_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$");
private final Jinjava jinjava;
private final String template;
public AiDocumentHtmlRenderer() {
JinjavaConfig config =
JinjavaConfig.newBuilder().withNestedInterpretationEnabled(false).build();
this.jinjava = new Jinjava(config);
this.template = loadTemplate();
}
public String render(AiDocument doc) {
return jinjava.render(template, buildContext(doc));
}
private static Map<String, Object> buildContext(AiDocument doc) {
Map<String, Object> context = new LinkedHashMap<>();
context.put("title", doc.getTitle());
context.put("subtitle", doc.getSubtitle());
context.put("reference_number", doc.getReferenceNumber());
AiDocument.Style style = doc.getStyle();
if (style != null) {
context.put("style_primary", safeColor(style.getPrimaryColor()));
context.put("style_background", safeColor(style.getBackgroundColor()));
context.put("style_body", safeColor(style.getBodyTextColor()));
}
List<Map<String, Object>> sections = new ArrayList<>();
if (doc.getSections() != null) {
for (AiDocument.Section section : doc.getSections()) {
if (section != null && section.getType() != null) {
sections.add(buildSection(section));
}
}
}
context.put("sections", sections);
return context;
}
private static Map<String, Object> buildSection(AiDocument.Section section) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("type", section.getType());
node.put("heading", section.getHeading());
switch (section.getType()) {
case "text" -> node.put("paragraphs", paragraphs(section.getBody()));
case "key_value" -> node.put("pairs", pairs(section.getPairs()));
case "line_items" -> {
node.put("columns", orEmpty(section.getColumns()));
node.put("rows", orEmptyRows(section.getRows()));
node.put("total_row", emptyToNull(section.getTotalRow()));
}
case "bullet_list" -> node.put("items", orEmpty(section.getItems()));
case "signature" -> node.put("signatories", orEmpty(section.getSignatories()));
default -> {}
}
return node;
}
private static List<String> paragraphs(String body) {
String text = body == null ? "" : body;
List<String> out = new ArrayList<>();
for (String paragraph : text.split("\n\n")) {
out.add(paragraph.replace("\n", " "));
}
return out;
}
private static List<Map<String, String>> pairs(List<List<String>> pairs) {
List<Map<String, String>> out = new ArrayList<>();
if (pairs != null) {
for (List<String> pair : pairs) {
Map<String, String> node = new LinkedHashMap<>();
node.put("label", pair.isEmpty() ? "" : pair.get(0));
node.put("value", pair.size() < 2 ? "" : pair.get(1));
out.add(node);
}
}
return out;
}
private static List<String> orEmpty(List<String> values) {
return values == null ? List.of() : values;
}
private static List<List<String>> orEmptyRows(List<List<String>> rows) {
return rows == null ? List.of() : rows;
}
private static List<String> emptyToNull(List<String> values) {
return values == null || values.isEmpty() ? null : values;
}
private static String safeColor(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return SAFE_COLOR.matcher(trimmed).matches() ? trimmed : null;
}
private static String loadTemplate() {
try {
return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
{%- autoescape true -%}
<!DOCTYPE html>
<html lang="en">
<head>
@@ -175,18 +176,18 @@
color: var(--color-label);
}
</style>
{%- if doc.style %}
{%- if style_primary or style_background or style_body %}
<style>
:root {
{%- if doc.style.primary_color %}
--color-primary: {{ doc.style.primary_color }};
{%- if style_primary %}
--color-primary: {{ style_primary }};
{%- endif %}
{%- if doc.style.background_color %}
--color-bg: {{ doc.style.background_color }};
{%- if style_background %}
--color-bg: {{ style_background }};
{%- endif %}
{%- if doc.style.body_text_color %}
--color-body: {{ doc.style.body_text_color }};
--color-label: {{ doc.style.body_text_color }};
{%- if style_body %}
--color-body: {{ style_body }};
--color-label: {{ style_body }};
{%- endif %}
}
</style>
@@ -195,16 +196,16 @@
<body>
<div class="doc-header">
<div class="doc-title">{{ doc.title }}</div>
{%- if doc.subtitle %}
<div class="doc-subtitle">{{ doc.subtitle }}</div>
<div class="doc-title">{{ title }}</div>
{%- if subtitle %}
<div class="doc-subtitle">{{ subtitle }}</div>
{%- endif %}
{%- if doc.reference_number %}
<div class="doc-reference">{{ doc.reference_number }}</div>
{%- if reference_number %}
<div class="doc-reference">{{ reference_number }}</div>
{%- endif %}
</div>
{%- for section in doc.sections %}
{%- for section in sections %}
{%- if section.type == "text" %}
<section>
@@ -212,8 +213,8 @@
<h2>{{ section.heading }}</h2>
{%- endif %}
<div class="text-body">
{%- for para in section.body.split('\n\n') %}
<p>{{ para | replace('\n', ' ') }}</p>
{%- for para in section.paragraphs %}
<p>{{ para }}</p>
{%- endfor %}
</div>
</section>
@@ -225,10 +226,10 @@
{%- endif %}
<table class="kv-table">
<tbody>
{%- for label, value in section.pairs %}
{%- for pair in section.pairs %}
<tr>
<td class="kv-label">{{ label }}</td>
<td class="kv-value">{{ value }}</td>
<td class="kv-label">{{ pair.label }}</td>
<td class="kv-value">{{ pair.value }}</td>
</tr>
{%- endfor %}
</tbody>
@@ -299,3 +300,4 @@
</body>
</html>
{%- endautoescape %}
@@ -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<ClassificationLabels> 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<ClassificationLabels> 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<Void> response = controller.resetTeamLabels();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
@@ -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<ClassificationLabel> 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");
}
}
@@ -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<ClassificationLabel> 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());
@@ -166,8 +166,8 @@ class PolicyExecutorTest {
new PipelineStep(
createPdf,
Map.of(
"htmlContent",
"<p>hi</p>",
"document",
"{\"title\":\"PO\",\"sections\":[]}",
"filename",
"purchase-order.pdf"))),
PolicyInputs.of(List.of()),
@@ -0,0 +1,139 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.model.api.ai.create.AiDocument;
class AiDocumentHtmlRendererTest {
private final AiDocumentHtmlRenderer renderer = new AiDocumentHtmlRenderer();
private static AiDocument.Section section(String type) {
AiDocument.Section s = new AiDocument.Section();
s.setType(type);
return s;
}
private static AiDocument document(String title, List<AiDocument.Section> sections) {
AiDocument doc = new AiDocument();
doc.setTitle(title);
doc.setSections(sections);
return doc;
}
@Test
void rendersAllSectionTypes() {
AiDocument.Section text = section("text");
text.setBody("Some prose text.");
AiDocument.Section kv = section("key_value");
kv.setPairs(List.of(List.of("Key", "Value")));
AiDocument.Section items = section("line_items");
items.setColumns(List.of("A", "B"));
items.setRows(List.of(List.of("1", "2")));
AiDocument.Section bullets = section("bullet_list");
bullets.setItems(List.of("item one"));
AiDocument.Section sign = section("signature");
sign.setSignatories(List.of("Alice"));
String html = renderer.render(document("All", List.of(text, kv, items, bullets, sign)));
assertTrue(html.contains("<!DOCTYPE html>"));
assertTrue(html.contains("Some prose text."));
assertTrue(html.contains("Key") && html.contains("Value"));
assertTrue(html.contains("<th>"));
assertTrue(html.contains("item one"));
assertTrue(html.contains("Alice"));
}
@Test
void rendersMarkupCharactersAsText() {
AiDocument.Section text = section("text");
text.setBody("a <b>x</b> & y");
String html = renderer.render(document("Doc", List.of(text)));
assertFalse(html.contains("<b>"));
assertTrue(html.contains("&lt;b&gt;"));
}
@Test
void totalRowRenderedWhenPresent() {
AiDocument.Section items = section("line_items");
items.setColumns(List.of("Item", "Total"));
items.setRows(List.of(List.of("Widget", "$10")));
items.setTotalRow(List.of("Total", "$10"));
assertTrue(
renderer.render(document("Table", List.of(items)))
.contains("<tr class=\"total-row\">"));
}
@Test
void totalRowAbsentWhenNotProvided() {
AiDocument.Section items = section("line_items");
items.setColumns(List.of("Item"));
items.setRows(List.of(List.of("Widget")));
assertFalse(
renderer.render(document("Table", List.of(items)))
.contains("<tr class=\"total-row\">"));
}
@Test
void rendersSubtitleAndReference() {
AiDocument doc = document("My Doc", List.of());
doc.setSubtitle("Subtitle Here");
doc.setReferenceNumber("REF-42");
String html = renderer.render(doc);
assertTrue(html.contains("Subtitle Here"));
assertTrue(html.contains("REF-42"));
}
@Test
void appliesHexColourOverride() {
AiDocument doc = document("Styled", List.of());
AiDocument.Style style = new AiDocument.Style();
style.setPrimaryColor("#ff00ff");
style.setBackgroundColor("#111111");
doc.setStyle(style);
String html = renderer.render(doc);
assertTrue(html.contains("--color-primary: #ff00ff"));
assertTrue(html.contains("--color-bg: #111111"));
}
@Test
void ignoresColourWithDisallowedCharacters() {
AiDocument doc = document("Styled", List.of());
AiDocument.Style style = new AiDocument.Style();
style.setPrimaryColor("rgb(255, 0, 0)");
doc.setStyle(style);
String html = renderer.render(doc);
assertFalse(html.contains("rgb("));
assertTrue(html.contains("<!DOCTYPE html>"));
}
@Test
void ignoresNonHexColour() {
AiDocument doc = document("Styled", List.of());
AiDocument.Style style = new AiDocument.Style();
style.setPrimaryColor("magenta");
style.setBackgroundColor("#fff");
doc.setStyle(style);
String html = renderer.render(doc);
assertFalse(html.contains("--color-primary: magenta"));
assertFalse(html.contains("--color-bg: #fff;"));
}
}
@@ -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;
+9
View File
@@ -36,6 +36,8 @@ ext {
okhttpBomVersion = "5.3.2"
gsonVersion = "2.14.0"
guavaVersion = "33.6.0-jre"
jinjavaVersion = "2.8.3"
jackson2Version = "2.21.2"
bucket4jVersion = "8.19.0"
archunitVersion = "1.4.2"
batikVersion = "1.19"
@@ -222,6 +224,13 @@ subprojects {
resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}"
// CVE-2024-47554: commons-io DoS prevention
resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}"
// Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request older versions);
// pin the family to a current release and keep modules aligned.
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
resolutionStrategy.force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jackson2Version}"
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jackson2Version}"
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jackson2Version}"
// Keep BouncyCastle modules aligned to avoid runtime linkage errors
resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}"
resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}"
-1
View File
@@ -5,7 +5,6 @@ description = "AI Document Engine"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.116.0",
"jinja2>=3.1.0",
"pgvector>=0.3.6",
"psycopg[binary,pool]>=3.2",
"pydantic>=2.0.0",
@@ -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)
+12 -29
View File
@@ -10,7 +10,7 @@ Flow:
4. SectionWriterAgents (smart_model) run in parallel via asyncio.gather.
Each returns a WrittenSections with fully populated DocumentSection objects.
5. The assembler collects sections in plan order → GeneratedDocument.
6. Jinja renders the document to HTML. The LLM never writes HTML.
6. The assembled document is emitted as structured fields. The LLM never writes HTML.
The planner is split into two calls (meta then sections) so each LLM output schema
stays small enough for grammar compilation on all model tiers including Haiku.
@@ -22,9 +22,7 @@ import asyncio
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
@@ -51,8 +49,6 @@ from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
_TEMPLATES_DIR = Path(__file__).parent / "templates"
# ── Token budget ──────────────────────────────────────────────────────────────────────────────────
# Conservative per-section token estimates mapped from planner-assigned depth.
@@ -166,10 +162,13 @@ Analyse the user's request and produce a DocumentMeta with:
document, if the user provides one. Leave empty if the user provides no such context.
- style_primary_color: accent and heading colour. Set ONLY when the user explicitly names a
colour or colour scheme (e.g. "make it red", "use navy blue"). Use CSS named colours
(e.g. "magenta", "navy", "crimson") or hex values. Leave null if no colour is stated.
- style_background_color: page background colour. Set only if explicitly requested.
- style_body_text_color: body text colour. Set only if explicitly requested.
colour or colour scheme (e.g. "make it red", "use navy blue"). Express it as a 6-digit hex
code in #RRGGBB format (map any named colour to its hex value yourself, e.g. "navy"
"#000080"). No other format is accepted. Leave null if no colour is stated.
- style_background_color: page background colour, same #RRGGBB format. Set only if explicitly
requested.
- style_body_text_color: body text colour, same #RRGGBB format. Set only if explicitly
requested.
- cannot_do_reason: set this ONLY when the request is not asking to create a document at all
(e.g. a question, a greeting, an edit request to an existing document). Never set it
@@ -299,15 +298,6 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str:
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
def _build_jinja_env() -> Environment:
return Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
autoescape=True,
trim_blocks=True,
lstrip_blocks=True,
)
def _safe_filename(title: str) -> str:
slug = re.sub(r"[^\w\s-]", "", title.lower())
slug = re.sub(r"[\s_-]+", "-", slug).strip("-")
@@ -320,7 +310,6 @@ def _safe_filename(title: str) -> str:
class PdfCreateAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self._jinja_env = _build_jinja_env()
self._meta_planner: Agent[None, DocumentMeta] = Agent(
model=runtime.smart_model,
@@ -401,14 +390,12 @@ class PdfCreateAgent:
sections=all_sections,
)
# ── Phase 6: render ────────────────────────────────────────────────────
logger.info("[pdf-create] phase 6/6: rendering HTML")
html = self._render(doc)
# ── Phase 6: emit ──────────────────────────────────────────────────────
filename = _safe_filename(plan.title)
logger.info(
"[pdf-create] done — filename=%r html_bytes=%d",
"[pdf-create] done — filename=%r sections=%d",
filename,
len(html),
len(all_sections),
)
return EditPlanResponse(
@@ -417,7 +404,7 @@ class PdfCreateAgent:
ToolOperationStep(
tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT,
parameters=CreatePdfFromHtmlAgentParams(
html_content=html,
document=doc.model_dump_json(),
filename=filename,
),
)
@@ -437,7 +424,3 @@ class PdfCreateAgent:
len(result.output.sections),
)
return result.output
def _render(self, doc: GeneratedDocument) -> str:
template = self._jinja_env.get_template("document.html.jinja2")
return template.render(doc=doc)
+6 -8
View File
@@ -1,14 +1,14 @@
"""Contracts for the PDF Create Agent.
The agent accepts a natural-language prompt and returns a single
CREATE_PDF_FROM_HTML_AGENT plan step carrying the rendered HTML.
CREATE_PDF_FROM_HTML_AGENT plan step carrying the assembled document.
Pipeline:
1. PlannerAgent (smart_model) → DocumentPlan: structured skeleton, no body text.
2. Python chunks the plan by token budget.
3. SectionWriterAgents (smart_model, parallel) → WrittenSections per chunk.
4. Assembler collects sections in plan order → GeneratedDocument.
5. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML.
5. The document is emitted as structured fields. The LLM never writes HTML.
"""
from __future__ import annotations
@@ -81,14 +81,12 @@ type DocumentSection = Annotated[
]
# Named colour or hex only — anything else is dropped so a colour can't inject CSS into the
# <style> block (which would let WeasyPrint fetch an attacker-controlled url() → SSRF).
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]{1,30}$")
# Colours must be a 6-digit hex code (#RRGGBB); anything else is dropped to None.
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
class DocumentStyle(ApiModel):
"""Document colours, inferred by the meta planner and rendered into the engine's Jinja
template (never sent to Java). Unsafe colours are dropped to ``None``."""
"""Document colours, inferred by the meta planner. Non-hex values are dropped to ``None``."""
primary_color: str | None = Field(default=None)
background_color: str | None = Field(default=None)
@@ -103,7 +101,7 @@ class DocumentStyle(ApiModel):
class GeneratedDocument(ApiModel):
"""The full document model passed to Jinja for HTML rendering."""
"""The full document model emitted for rendering."""
title: str
subtitle: str | None = None
@@ -29,7 +29,7 @@ class PdfCommentAgentParams(ApiModel):
class CreatePdfFromHtmlAgentParams(ApiModel):
html_content: str
document: str
filename: str = Field(pattern=r"^.+\.pdf$")
+29 -148
View File
@@ -2,7 +2,7 @@
Coverage:
1. Section model validation (each section type round-trips correctly)
2. Jinja rendering (_render produces valid HTML for each section type)
2. orchestrate() emits the assembled document as structured JSON
3. _safe_filename produces clean slugs
4. _make_chunks groups sections correctly by token budget
5. orchestrate() produces the correct EditPlanResponse via planner + writer mocks
@@ -12,6 +12,8 @@ Coverage:
from __future__ import annotations
import json
import pytest
from conftest import build_app_settings
from pydantic_ai.models.test import TestModel
@@ -64,37 +66,6 @@ def agent(runtime: AppRuntime) -> PdfCreateAgent:
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
def _invoice_doc() -> GeneratedDocument:
return GeneratedDocument(
title="Invoice",
subtitle="Acme Corp",
reference_number="Invoice #INV-001",
sections=[
KeyValueSection(
heading="Details",
pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06"), ("Currency", "USD")],
),
LineItemsSection(
heading="Line Items",
columns=["Description", "Qty", "Unit Price", "Total"],
rows=[
["Consulting services", "10", "$500.00", "$5,000.00"],
["Expenses", "1", "$200.00", "$200.00"],
],
total_row=["Total", "", "", "$5,200.00"],
),
TextSection(
heading="Payment Terms",
body="Payment is due within 30 days.\n\nPlease reference the invoice number.",
),
SignatureSection(
heading="Authorised By",
signatories=["Jane Smith, CEO", "Bob Jones, CFO"],
),
],
)
def _simple_meta() -> DocumentMeta:
return DocumentMeta(
title="Invoice",
@@ -199,82 +170,6 @@ def test_generated_document_optional_fields() -> None:
assert doc.reference_number is None
# ── Jinja rendering ───────────────────────────────────────────────────────────────────────────────
def test_render_produces_html(agent: PdfCreateAgent) -> None:
doc = _invoice_doc()
html = agent._render(doc)
assert "<!DOCTYPE html>" in html
assert "Invoice" in html
assert "INV-001" in html
def test_render_includes_all_section_types(agent: PdfCreateAgent) -> None:
doc = GeneratedDocument(
title="All Sections",
sections=[
TextSection(body="Some prose text."),
KeyValueSection(pairs=[("Key", "Value")]),
LineItemsSection(columns=["A", "B"], rows=[["1", "2"]]),
BulletListSection(items=["item one"]),
SignatureSection(signatories=["Alice"]),
],
)
html = agent._render(doc)
assert "Some prose text." in html
assert "Key" in html and "Value" in html
assert "<th>" in html
assert "item one" in html
assert "Alice" in html
def test_render_escapes_html_in_content(agent: PdfCreateAgent) -> None:
doc = GeneratedDocument(
title="XSS Test",
sections=[TextSection(body="<script>alert('xss')</script>")],
)
html = agent._render(doc)
assert "<script>" not in html
assert "&lt;script&gt;" in html
def test_render_total_row_present(agent: PdfCreateAgent) -> None:
doc = GeneratedDocument(
title="Table",
sections=[
LineItemsSection(
columns=["Item", "Total"],
rows=[["Widget", "$10"]],
total_row=["Total", "$10"],
)
],
)
html = agent._render(doc)
assert "total-row" in html
def test_render_no_total_row_skips_tfoot(agent: PdfCreateAgent) -> None:
doc = GeneratedDocument(
title="Table",
sections=[LineItemsSection(columns=["Item"], rows=[["Widget"]])],
)
html = agent._render(doc)
assert "<tfoot>" not in html
def test_render_subtitle_and_reference(agent: PdfCreateAgent) -> None:
doc = GeneratedDocument(
title="My Doc",
subtitle="Subtitle Here",
reference_number="REF-42",
sections=[TextSection(body="Content.")],
)
html = agent._render(doc)
assert "Subtitle Here" in html
assert "REF-42" in html
# ── _safe_filename ────────────────────────────────────────────────────────────────────────────────
@@ -396,8 +291,9 @@ async def test_orchestrate_returns_plan_step(agent: PdfCreateAgent) -> None:
assert step.tool == AgentToolId.CREATE_PDF_FROM_HTML_AGENT
assert isinstance(step.parameters, CreatePdfFromHtmlAgentParams)
assert step.parameters.filename.endswith(".pdf")
assert "<!DOCTYPE html>" in step.parameters.html_content
assert "Invoice" in step.parameters.html_content
parsed = json.loads(step.parameters.document)
assert parsed["title"] == "Invoice"
assert parsed["sections"]
@pytest.mark.anyio
@@ -468,9 +364,9 @@ async def test_orchestrate_assembles_multiple_chunks(agent: PdfCreateAgent) -> N
result = await agent.orchestrate(_orchestrator_request("Create a multi-chunk doc"))
assert isinstance(result, EditPlanResponse)
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
assert "Introduction text." in html
assert "Details" in html
document = result.steps[0].parameters.document # type: ignore[union-attr]
assert "Introduction text." in document
assert "Details" in document
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
@@ -482,7 +378,7 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
meta = DocumentMeta(
title="Styled Doc",
tone_brief="Professional.",
style_primary_color="magenta",
style_primary_color="#ff00ff",
)
sections = _simple_sections()
written = _written_sections()
@@ -499,50 +395,35 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
result = await agent.orchestrate(_orchestrator_request("Make an invoice, magenta styling"))
assert isinstance(result, EditPlanResponse)
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
assert "magenta" in html
document = result.steps[0].parameters.document # type: ignore[union-attr]
assert json.loads(document)["style"]["primaryColor"] == "#ff00ff"
def test_render_applies_style(agent: PdfCreateAgent) -> None:
"""DocumentStyle fields are injected as CSS custom properties in the rendered HTML."""
doc = GeneratedDocument(
title="Styled",
sections=[TextSection(body="Content.")],
style=DocumentStyle(primary_color="magenta", background_color="#111111"),
)
html = agent._render(doc)
assert "--color-primary: magenta" in html
assert "--color-bg: #111111" in html
def test_document_style_drops_unsafe_colors() -> None:
"""Unsafe colours (not named/hex) are dropped, closing the <style> url() injection."""
safe = DocumentStyle(primary_color="navy", background_color="#1e3a5f", body_text_color="#fff")
def test_document_style_keeps_only_six_digit_hex() -> None:
"""Only #RRGGBB hex is kept; named colours and other formats drop to None."""
safe = DocumentStyle(primary_color="#1e3a5f", background_color="#ffffff", body_text_color="#1A1A1A")
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
"navy",
"#1e3a5f",
"#fff",
"#ffffff",
"#1A1A1A",
)
unsafe = DocumentStyle(
primary_color="red; background: url(http://evil.test/steal)",
background_color="expression(alert(1))",
body_text_color="navy; }",
)
assert unsafe.primary_color is None
assert unsafe.background_color is None
assert unsafe.body_text_color is None
assert DocumentStyle(primary_color="navy").primary_color is None
assert DocumentStyle(primary_color="#fff").primary_color is None
assert DocumentStyle(primary_color="#1e3a5f00").primary_color is None
assert DocumentStyle(primary_color="rgb(255, 0, 0)").primary_color is None
assert DocumentStyle(background_color="teal darken-2").background_color is None
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
assert DocumentStyle(primary_color="navy\n").primary_color is None
assert DocumentStyle(primary_color="#1e3a5f\n").primary_color is None
@pytest.mark.anyio
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
async def test_orchestrate_drops_non_hex_planner_colour(agent: PdfCreateAgent) -> None:
"""A non-hex colour inferred by the meta planner is dropped before the document is emitted."""
meta = DocumentMeta(
title="Doc",
tone_brief="Professional.",
style_primary_color="blue; background: url(http://evil.test/)",
style_primary_color="rgb(0, 0, 255)",
)
sections = _simple_sections()
written = _written_sections()
@@ -559,6 +440,6 @@ async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) ->
result = await agent.orchestrate(_orchestrator_request("make it blue"))
assert isinstance(result, EditPlanResponse)
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
assert "evil.test" not in html
assert "url(" not in html
document = result.steps[0].parameters.document # type: ignore[union-attr]
assert "rgb(" not in document
assert json.loads(document)["style"]["primaryColor"] is None
-2
View File
@@ -604,7 +604,6 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "jinja2" },
{ name = "opentelemetry-sdk" },
{ name = "pgvector" },
{ name = "posthog" },
@@ -631,7 +630,6 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
{ name = "pgvector", specifier = ">=0.3.6" },
{ name = "posthog", specifier = ">=3.0.0" },
@@ -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."
@@ -9056,7 +9046,9 @@ updateBehaviorError = "Could not change update behavior"
updateBehaviorErrorLocked = "This setting is locked by your administrator."
updateBehaviorLockedDescription = "Your administrator has configured how Stirling-PDF handles updates on this machine. Contact them to change this."
updateBehaviorSaved = "Update behavior saved."
versionMismatch = "Warning: A mismatch has been detected between the client version and the AppConfig version. Using different versions can lead to compatibility issues, errors, and security risks. Please ensure that server and client are using the same version."
versionMismatch = "The app frontend ({{frontendVersion}}) and backend ({{backendVersion}}) are running different versions. This usually happens briefly while an update is being applied."
versionMismatchResolution = "Restart Stirling-PDF to finish applying the update. If the warning persists after restarting, reinstall the latest version to bring both components back in sync."
versionMismatchTitle = "Version mismatch"
viewDetails = "View Details"
[settings.general.versionInfo]
@@ -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<Theme>; className?: string }>;
/** Policy category id → outline glyph. */
const POLICY_CATEGORY_ICONS: Record<string, MuiIcon> = {
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<Theme> = { fontSize: "inherit" },
className?: string,
): ReactNode {
const Icon = POLICY_CATEGORY_ICONS[categoryId] ?? FALLBACK_ICON;
return <Icon sx={sx} className={className} />;
}
@@ -11,6 +11,7 @@ import {
Group,
Anchor,
Badge,
Alert,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
@@ -304,14 +305,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
{frontendVersionLabel}
</Text>
</Text>
{mismatchVersion && (
<Text size="sm" c="red" mt={4}>
{t(
"settings.general.updates.versionMismatch",
"Warning: A mismatch has been detected between the client version and the AppConfig version. Using different versions can lead to compatibility issues, errors, and security risks. Please ensure that server and client are using the same version.",
)}
</Text>
)}
</div>
</Group>
)}
@@ -461,6 +454,48 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
/>
</Stack>
)}
{/* Frontend/backend version mismatch notice. Informational, not an
error, and deliberately separated from "Check for Updates" — the
update check only looks for a newer *release* and cannot
reconcile a mismatch between the app's bundled components. The
mismatch is normally transient while an update is being applied,
so the resolution is to restart (or reinstall) the app rather
than to check for updates. */}
{mismatchVersion && (
<Alert
variant="light"
color="yellow"
icon={
<LocalIcon
icon="info-outline-rounded"
width="1.2rem"
height="1.2rem"
/>
}
title={t(
"settings.general.updates.versionMismatchTitle",
"Version mismatch",
)}
>
<Text size="sm">
{t(
"settings.general.updates.versionMismatch",
"The app frontend ({{frontendVersion}}) and backend ({{backendVersion}}) are running different versions. This usually happens briefly while an update is being applied.",
{
frontendVersion: frontendVersionLabel,
backendVersion: config?.appVersion ?? "",
},
)}
</Text>
<Text size="sm" mt={4}>
{t(
"settings.general.updates.versionMismatchResolution",
"Restart Stirling-PDF to finish applying the update. If the warning persists after restarting, reinstall the latest version to bring both components back in sync.",
)}
</Text>
</Alert>
)}
</Stack>
</Paper>
)}
-57
View File
@@ -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);
}
@@ -1,52 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LabelChip } from "@app/ui/LabelChip";
const meta: Meta<typeof LabelChip> = {
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<typeof LabelChip>;
/** 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: () => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", maxWidth: 420 }}>
<LabelChip label="Invoice" icon="receipt-long" count={12} />
<LabelChip label="Contract" icon="handshake" onRemove={() => {}} />
<LabelChip label="Lab report" icon="science" count={2} />
<LabelChip label="Payslip" icon="payments" />
</div>
),
};
-60
View File
@@ -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 (
<span className="sui-labelchip" role="listitem">
{leading ?? (
<span className="sui-labelchip-icon">
<LocalIcon icon={icon || "sell"} width="1rem" />
</span>
)}
<span className="sui-labelchip-name" title={label}>
{label}
</span>
{count != null && count > 0 && (
<span className="sui-labelchip-count">{count}</span>
)}
{onRemove && (
<button
type="button"
className="sui-labelchip-remove"
onClick={onRemove}
aria-label={removeAriaLabel ?? `Remove ${label}`}
>
<CloseIcon sx={{ fontSize: "0.85rem" }} />
</button>
)}
</span>
);
}
+28 -8
View File
@@ -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<Record<ToolEndpoint, string>> = {
/**
* 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<ToolEndpoint | "/api/v1/ai/tools/classify-and-label", string>
> = {
"/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<string, PolicyConfigDef> = {
],
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: [
@@ -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 }) => (
<div className="portal-policysum__cat">
<span className="portal-policysum__icon" aria-hidden>
{policyIcon(entry.category.icon)}
{policyCategoryIcon(entry.category.id)}
</span>
<div className="portal-policysum__cat-text">
<strong>{t(entry.category.label)}</strong>
@@ -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;
}
@@ -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<ReadonlySet<string>>(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 (
<Card>
<div className="classification-summary">
<div className="classification-summary-stats">
<span>
<strong>{DEFAULT_CLASSIFICATION_LABELS.length}</strong>{" "}
{t("policies.labels.labelCount", "labels")}
</span>
<span>
<strong>{LABEL_FAMILIES.length}</strong>{" "}
{t("policies.labels.categoryCount", "categories")}
</span>
</div>
<ul className="classification-categories">
{LABEL_FAMILIES.map((family) => {
const open = expanded.has(family.id);
return (
<li key={family.id} className="classification-category">
<Button
variant="quiet"
fullWidth
justify="between"
className="classification-category-header"
aria-expanded={open}
onClick={() => toggle(family.id)}
leftSection={
<span className="classification-category-lead">
{open ? (
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
) : (
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
)}
<LocalIcon icon={family.icon} width="1.1rem" />
<span className="classification-category-name">
{family.name}
</span>
</span>
}
rightSection={
<span className="classification-category-count">
{family.labels.length}
</span>
}
/>
{open && (
<div className="classification-category-labels">
{family.labels.map((label) => (
<Chip key={label.id} accent="neutral" size="sm">
{t(`classification.labels.${label.id}`, label.name)}
</Chip>
))}
</div>
)}
</li>
);
})}
</ul>
<span className="classification-summary-note">
{t(
"policies.labels.sharedNote",
"These labels are built in and shared across your whole team.",
)}
</span>
</div>
</Card>
);
}
@@ -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) {
<Card
className={
"portal-policies__card" +
(comingSoon ? " portal-policies__card--locked" : "")
(comingSoon || locked ? " portal-policies__card--locked" : "")
}
interactive={openable}
onClick={openable ? () => onOpen(entry) : undefined}
@@ -39,7 +48,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
}
>
<span className="portal-policies__cat-icon" aria-hidden>
{policyIcon(category.icon)}
{policyCategoryIcon(category.id)}
</span>
<div className="portal-policies__card-identity">
@@ -53,6 +62,10 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
<Chip accent="neutral" size="sm">
{t("portal.policies.card.comingSoon")}
</Chip>
) : locked ? (
<Chip accent="neutral" size="sm">
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
</Chip>
) : policy ? (
<div className="portal-policies__card-meta">
<span className="portal-policies__card-statpair">
@@ -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<typeof PolicySetupWizard> = {
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,
},
},
};
@@ -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 <EditOutlinedIcon sx={sx} />;
case "folder":
return <FolderOutlinedIcon sx={sx} />;
case "s3":
return <CloudOutlinedIcon sx={sx} />;
default:
return <StorageOutlinedIcon sx={sx} />;
}
}
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<Step>("workflow");
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
const [tools, setTools] = useState<ToolState[]>(() => {
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<string[]>(policy?.state.scopeTypes ?? []);
@@ -285,7 +335,7 @@ function PolicySetupWizardBody({
title={
<span className="portal-policies__wizard-title">
<span className="portal-policies__cat-icon" aria-hidden>
{policyIcon(category.icon)}
{policyCategoryIcon(category.id)}
</span>
{isEdit
? t("portal.policies.wizard.title.edit", {
@@ -349,7 +399,25 @@ function PolicySetupWizardBody({
/>
)}
{step === "workflow" && (
{step === "workflow" && isClassification && (
<div className="portal-policies__wizard-section">
<p className="portal-policies__wizard-desc">
{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.",
)}
</p>
<h3 className="portal-policies__wizard-heading">
{t(
"portal.policies.wizard.classification.labelsHeading",
"Classification labels",
)}
</h3>
<ClassificationLabelsSection />
</div>
)}
{step === "workflow" && !isClassification && (
<div className="portal-policies__wizard-section">
<p className="portal-policies__wizard-desc">
{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.
<div className="portal-policies__sources">
{availableSources.map((src) => (
// A selectable multi-line tile (icon + name + type + check).
// Uses the shared Button (raw <button> is lint-banned); the tile
// CSS overrides the Button's fixed height for the two-line layout.
<Button
key={src.id}
variant="quiet"
justify="start"
className={
"portal-policies__source" +
(sources.includes(src.id)
? " portal-policies__source--on"
: "")
}
onClick={() => toggleSource(src.id)}
>
<span className="portal-policies__source-icon" aria-hidden>
{sourceTypeMeta(src.type).icon}
</span>
<span className="portal-policies__source-text">
{availableSources.map((src) => {
const on = sources.includes(src.id);
return (
<Button
key={src.id}
variant={on ? "secondary" : "quiet"}
justify="between"
fullWidth
className={
"portal-policies__source" +
(on ? " portal-policies__source--on" : "")
}
// The check keeps its slot when unselected (hidden) so the
// icon + name stay put whether or not the tile is selected.
rightSection={
<CheckIcon
sx={{
fontSize: "1.1rem",
visibility: on ? "visible" : "hidden",
}}
/>
}
onClick={() => toggleSource(src.id)}
aria-pressed={on}
>
<span className="portal-policies__source-label">
{sourceIcon(src.type)}
{src.name}
</span>
<span className="portal-policies__source-desc">
{src.type}
</span>
</span>
</Button>
))}
</Button>
);
})}
</div>
)}
@@ -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<string, string> = {
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] ?? "•";
}
@@ -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<AppConfigShape>(
() => apiClient.local.json<AppConfigShape>("/api/v1/config/app-config"),
[],
);
return {
enabled: Boolean(state.data?.aiEngineEnabled),
loading: state.loading && state.data === null,
};
}
@@ -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 }),
),
];
@@ -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,
+7 -100
View File
@@ -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 {
@@ -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<string | null>(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")}
/>
))}
</div>
@@ -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;
}
@@ -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<string, number>([
["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 (
<div style={{ maxWidth: 420 }}>
<ClassificationCategoryManager
categories={categories}
counts={COUNTS}
onToggleHidden={(id, hidden) =>
setCategories((prev) =>
prev.map((c) => (c.id === id ? { ...c, hidden } : c)),
)
}
/>
</div>
);
}
const meta: Meta<typeof Harness> = {
title: "Policies/ClassificationCategoryManager",
component: Harness,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Harness>;
/** Show/hide the fixed, shared categories in the Files sidebar. */
export const Default: Story = {};
@@ -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<string, number>;
}
export function ClassificationCategoryManager({
categories,
onToggleHidden,
counts,
}: ClassificationCategoryManagerProps) {
const { t } = useTranslation();
return (
<ul className="category-visibility">
{categories.map((category) => {
const count = counts?.get(category.id);
return (
<li
key={category.id}
className={
category.hidden
? "category-visibility-row category-visibility-row--hidden"
: "category-visibility-row"
}
>
<span className="category-visibility-icon">
<LocalIcon icon={category.icon} width="1.1rem" />
</span>
<span className="category-visibility-name">{category.name}</span>
{count !== undefined && (
<span className="category-visibility-count">{count}</span>
)}
<ActionIcon
variant="quiet"
aria-pressed={!category.hidden}
aria-label={
category.hidden
? t("policies.labels.showCategory", "Show category")
: t("policies.labels.hideCategory", "Hide category")
}
onClick={() => onToggleHidden(category.id, !category.hidden)}
>
{category.hidden ? (
<VisibilityOffIcon sx={{ fontSize: "1.1rem" }} />
) : (
<VisibilityIcon sx={{ fontSize: "1.1rem" }} />
)}
</ActionIcon>
</li>
);
})}
</ul>
);
}
@@ -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<ClassificationLabel[]>(teamLabels);
const [open, setOpen] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
// Sync the draft to server truth whenever it changes (load / save / reset).
// Local edits don't change `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 (
<div className="labels-summary">
<p className="pol-section-label">
{t("policies.labels.sectionLabel", "Classification labels")}
</p>
<Card>
{loading ? (
<span className="labels-empty">{t("loading", "Loading…")}</span>
) : (
<div className="labels-summary">
<div className="labels-summary-stats">
<span>
<strong>{teamLabels.length}</strong>{" "}
{t("policies.labels.teamCount", "team labels")}
</span>
</div>
<div className="labels-chips">
{teamLabels.slice(0, SUMMARY_CHIP_COUNT).map((label) => (
<Chip key={label.id} accent="neutral" size="sm">
{label.name}
</Chip>
))}
{teamLabels.length > SUMMARY_CHIP_COUNT && (
<Chip accent="neutral" size="sm">
+{teamLabels.length - SUMMARY_CHIP_COUNT}
</Chip>
)}
</div>
<span className="labels-summary-note">
{isCustom
? t("policies.labels.customNote", "Customized for your team.")
: t(
"policies.labels.defaultNote",
"Using the built-in default, shared with your team.",
)}
</span>
<Button
variant="secondary"
size="sm"
leftSection={<OpenInFullIcon sx={{ fontSize: "1rem" }} />}
onClick={() => setOpen(true)}
style={{ alignSelf: "flex-start" }}
>
{canConfigure
? t("policies.labels.edit", "Edit labels")
: t("policies.labels.view", "View labels")}
</Button>
</div>
)}
</Card>
{!canConfigure && (
<Banner
tone="neutral"
icon={<LockOutlinedIcon sx={{ fontSize: "1rem" }} />}
description={t(
"policies.labels.managedNote",
"Team labels are managed by your team leader.",
)}
/>
)}
{error && !open && <Banner tone="danger" description={error} />}
<LabelsEditorModal
open={open}
onClose={close}
draft={draft}
onDraftChange={setDraft}
onImportFile={onImportFile}
onExport={() => 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}
/>
</div>
);
}
@@ -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 (
<Popover
opened={open}
onChange={setOpen}
onDismiss={() => setOpen(false)}
position="bottom-start"
withArrow
trapFocus
withinPortal
zIndex={Z_INDEX_AUTOMATE_DROPDOWN}
>
<Popover.Target>
<ActionIcon
variant="quiet"
className="labels-icon-pick"
onClick={() => setOpen((o) => !o)}
aria-label={ariaLabel}
aria-haspopup="true"
>
<LocalIcon icon={value || DEFAULT_LABEL_ICON} width="1.15rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="xs" style={{ maxHeight: 280, overflowY: "auto" }}>
<SimpleGrid cols={10} spacing={4} verticalSpacing={4}>
{LABEL_ICON_OPTIONS.map((option) => {
const iconLabel = t(
`policies.labels.iconName.${option.icon}`,
option.label,
);
return (
<Tooltip
key={option.icon}
label={iconLabel}
withArrow
openDelay={300}
>
<ActionIcon
variant="quiet"
className={`labels-icon-option${option.icon === value ? " is-selected" : ""}`}
onClick={() => {
onChange(option.icon);
setOpen(false);
}}
aria-label={t(
"policies.labels.pickIcon",
"Use {{label}} icon",
{
label: iconLabel,
},
)}
aria-pressed={option.icon === value}
>
<LocalIcon icon={option.icon} width="1.25rem" />
</ActionIcon>
</Tooltip>
);
})}
</SimpleGrid>
</Popover.Dropdown>
</Popover>
);
}
@@ -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);
}
@@ -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<string | null>(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 (
<LabelChip
key={key}
label={label.name}
leading={
readOnly ? (
<span className="sui-labelchip-icon">
<LocalIcon icon={label.icon || DEFAULT_LABEL_ICON} width="1rem" />
</span>
) : (
<LabelIconPicker
value={label.icon}
onChange={(icon) => 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 (
<div className="labels-editor">
{!readOnly && (
<div className="labels-add">
<input
className="labels-add-input"
value={pending}
maxLength={MAX_TEXT_LENGTH + 1}
placeholder={
addPlaceholder ??
t("policies.labels.addPlaceholder", "Add a label…")
}
onChange={(e) => {
setPending(e.target.value);
if (addError) setAddError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
/>
<Button
variant="secondary"
size="sm"
leftSection={<AddIcon sx={{ fontSize: "1rem" }} />}
onClick={add}
disabled={!pending.trim()}
>
{t("policies.labels.add", "Add")}
</Button>
</div>
)}
{addError && <p className="labels-add-error">{addError}</p>}
{value.length === 0 ? (
<p className="labels-empty">
{emptyText ?? t("policies.labels.empty", "No labels yet.")}
</p>
) : grouped ? (
<GroupedLabels value={value} renderChip={renderChip} />
) : (
<div className="labels-chips" role="list">
{value.map(renderChip)}
</div>
)}
</div>
);
}
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<string>();
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<ReadonlySet<string>>(
() => 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 (
<div className="labels-groups">
{sections.map(({ category, members }) => {
const isOpen = expanded.has(category.id);
return (
<section key={category.id} className="labels-group">
<Button
variant="quiet"
fullWidth
justify="between"
className="labels-group-header"
aria-expanded={isOpen}
onClick={() => toggle(category.id)}
leftSection={
<>
{isOpen ? (
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
) : (
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
)}
<LocalIcon icon={category.icon} width="1.05rem" />
</>
}
rightSection={
<span className="labels-group-count">{members.length}</span>
}
>
<span className="labels-group-name">{category.name}</span>
</Button>
{isOpen && (
<div className="labels-chips" role="list">
{members.map(renderChip)}
</div>
)}
</section>
);
})}
{ungrouped.length > 0 && (
<section className="labels-group">
<p className="labels-group-ungrouped">
{t("policies.labels.ungrouped", "Ungrouped")}
</p>
<div className="labels-chips" role="list">
{ungrouped.map(renderChip)}
</div>
</section>
)}
</div>
);
}
@@ -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<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so picking the same file twice still fires onChange.
e.target.value = "";
if (file) onImportFile(file);
};
return (
<Modal
open={open}
onClose={onClose}
width="xl"
className="labels-modal"
title={t("policies.labels.modalTitle", "Classification labels")}
subtitle={t(
"policies.labels.modalSubtitle",
"Shared with your whole team. The classifier picks the labels that fit each document.",
)}
footer={
<div className="labels-footer">
<div className="labels-footer-left">
{!readOnly && (
<>
<Button
variant="tertiary"
accent="danger"
size="sm"
leftSection={
<DeleteSweepOutlinedIcon sx={{ fontSize: "1rem" }} />
}
onClick={onClear}
disabled={saving}
>
{t("policies.labels.startFromScratch", "Start from scratch")}
</Button>
<Button
variant="tertiary"
accent="danger"
size="sm"
leftSection={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
onClick={onReset}
disabled={saving}
>
{t("policies.labels.resetToDefault", "Reset to default")}
</Button>
</>
)}
</div>
<div className="labels-footer-right">
<Button variant="tertiary" size="sm" onClick={onClose}>
{readOnly ? t("close", "Close") : t("cancel", "Cancel")}
</Button>
{!readOnly && (
<Button
variant="primary"
size="sm"
onClick={onSave}
disabled={!dirty || saving}
>
{saving
? t("policies.labels.saving", "Saving…")
: t("policies.labels.saveForTeam", "Save for team")}
</Button>
)}
</div>
</div>
}
>
<div className="labels-modal-body">
{error && (
<Banner
tone="danger"
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
description={error}
/>
)}
{!readOnly && (
<div className="labels-toolbar">
<Button
variant="secondary"
size="sm"
leftSection={<FileUploadOutlinedIcon sx={{ fontSize: "1rem" }} />}
onClick={() => fileInput.current?.click()}
>
{t("policies.labels.import", "Import JSON")}
</Button>
<Button
variant="secondary"
size="sm"
leftSection={
<FileDownloadOutlinedIcon sx={{ fontSize: "1rem" }} />
}
onClick={onExport}
>
{t("policies.labels.export", "Export JSON")}
</Button>
<input
ref={fileInput}
type="file"
accept="application/json,.json"
hidden
onChange={handleFile}
/>
</div>
)}
<LabelsEditor
value={draft}
onChange={onDraftChange}
readOnly={readOnly}
grouped
/>
</div>
</Modal>
);
}
@@ -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<string, string[]> = {
// Security: redact PII + watermark + sanitize (strips JS). Which are enabled
// by default comes from the preset's defaultOperations, not this list.
security: ["redact", "watermark", "sanitize"],
// Classification: a single backend step that classifies the document and
// writes the result into its metadata.
classification: ["classify"],
};
/** The configurable tool chain for a category, or null if it has none yet. */
export function getPolicyToolChain(categoryId: string): string[] | null {
return POLICY_TOOL_CHAINS[categoryId] ?? null;
}
@@ -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,
});
}
});
});
@@ -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
@@ -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.<icon>`. */
/** 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<string> = new Set(
LABEL_ICON_OPTIONS.map((option) => option.icon),
);
@@ -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: <LayersIcon sx={ICON_SX} />,
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: <ShieldIcon sx={ICON_SX} />,
icon: policyCategoryIcon("security", ICON_SX),
desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
},
{
id: "classification",
label: "Classification",
icon: <LabelOutlinedIcon sx={ICON_SX} />,
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: <CheckCircleIcon sx={ICON_SX} />,
icon: policyCategoryIcon("compliance", ICON_SX),
desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.",
comingSoon: true,
},
{
id: "routing",
label: "Routing",
icon: <ArrowForwardIcon sx={ICON_SX} />,
icon: policyCategoryIcon("routing", ICON_SX),
desc: "Auto-route documents to the right team, folder, or system.",
comingSoon: true,
},
{
id: "retention",
label: "Retention",
icon: <StorageIcon sx={ICON_SX} />,
icon: policyCategoryIcon("retention", ICON_SX),
desc: "Set how long documents are kept, when to archive, and when to delete.",
comingSoon: true,
},
@@ -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<void>;
}
export function useClassificationLabels(
enabled: boolean,
): UseClassificationLabels {
const [teamLabels, setTeamLabels] = useState<ClassificationLabel[]>(
DEFAULT_CLASSIFICATION_LABELS,
);
const [isCustom, setIsCustom] = useState(false);
const [loading, setLoading] = useState(enabled);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setLoading(true);
void (async () => {
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,
};
}
@@ -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.
@@ -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",
@@ -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<TParams> {
readonly endpoint: AiPolicyEndpoint;
readonly defaultParameters: TParams;
toApi(params: TParams): Record<string, unknown>;
fromApi(api: Record<string, unknown>): 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<Record<string, never>> {
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<Id extends PolicyToolId> =
(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<string, PolicyToolId>(
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<TParams> {
readonly endpoint: string;
readonly defaultParameters: TParams;
toApi(params: TParams): Record<string, unknown>;
fromApi(api: Record<string, unknown>): 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<Id extends PolicyToolId>(step: {
toolId: Id;
params: PolicyParams<Id>;
}): WirePipelineStep {
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
ToolEndpoint,
const op = POLICY_OPERATIONS[step.toolId] as unknown as PolicyOperation<
PolicyParams<Id>
>;
return {
operation: op.endpoint,
parameters: op.toApi(step.params) as Record<string, unknown>,
parameters: op.toApi(step.params),
};
}
@@ -119,13 +157,10 @@ function deserializeStep<Id extends PolicyToolId>(
toolId: Id,
parameters: Record<string, unknown>,
): PolicyToolStepOf<Id> {
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
ToolEndpoint,
const op = POLICY_OPERATIONS[toolId] as unknown as PolicyOperation<
PolicyParams<Id>
>;
// 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<Id>;
}
@@ -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:<n>`. */
/** 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<SidebarCategory, "hidden">[] =
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<string> {
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<SidebarCategory>;
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<string, string[]> {
const map = new Map<string, string[]>();
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<string> {
const keys = new Set<string>();
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();
}
@@ -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);
});
});
@@ -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<ClassificationLabel[] | null> {
const res = await apiClient.get<LabelsPayload | "">(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<ClassificationLabel[]> {
const res = await apiClient.put<LabelsPayload>(endpoint, { labels });
return res.data.labels ?? [];
}
/** The team's stored labels, or `null` when it has none (use the default). */
export function fetchTeamLabels(): Promise<ClassificationLabel[] | null> {
return fetchLabels(TEAM_ENDPOINT);
}
/** Persist the team's labels; returns the stored value. */
export function saveTeamLabels(
labels: ClassificationLabel[],
): Promise<ClassificationLabel[]> {
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<T>(op: () => Promise<T>): Promise<T> {
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<void> {
const existing = await withRetry(fetchTeamLabels);
if (existing != null) return;
await withRetry(() => saveTeamLabels(DEFAULT_CLASSIFICATION_LABELS));
}
@@ -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<ClassificationLabel>): 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<LabelsFileShape>;
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<string>();
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<ClassificationLabel[]> {
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;
}
@@ -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;
@@ -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<string, number>();
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<string, number>();
for (const category of categories) {
const ids = new Set<string>();
@@ -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<string, { display: string; icon?: string }>();
const idByName = new Map<string, string>();
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<ReadonlySet<string>>(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<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [addDraft, setAddDraft] = useState<Record<string, string>>({});
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({
<ActionIcon
variant="quiet"
className="file-sidebar-section-btn file-sidebar-section-btn-external"
onClick={openPicker}
onClick={() => setOpen(true)}
aria-label={t("fileSidebar.customizeGroups", "Customize groups")}
data-testid="customize-groups"
>
@@ -185,12 +74,11 @@ export function FileSidebarGroupControls({
<Modal
open={open}
onClose={() => 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={
<div className="fsg-footer">
@@ -198,9 +86,9 @@ export function FileSidebarGroupControls({
variant="tertiary"
size="sm"
leftSection={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
onClick={resetSidebarCategories}
onClick={resetHiddenCategories}
>
{t("fileSidebar.groupsModal.reset", "Reset to defaults")}
{t("fileSidebar.groupsModal.reset", "Show all")}
</Button>
<Button variant="primary" size="sm" onClick={() => setOpen(false)}>
{t("fileSidebar.groupsModal.done", "Done")}
@@ -209,201 +97,11 @@ export function FileSidebarGroupControls({
}
>
<div className="fsg-body">
<TextInput
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder={t("fileSidebar.groupsModal.search", "Search labels…")}
size="sm"
data-autofocus
<ClassificationCategoryManager
categories={categories}
onToggleHidden={setCategoryHidden}
counts={categoryCounts}
/>
{sortedCategories.map((category) => {
const memberKeys = category.labelKeys.filter((key) =>
matches(labelDisplay(key)),
);
// Show the category if its name matches, or any member label matches.
if (!matches(category.name) && memberKeys.length === 0) return null;
const isExpanded = q !== "" || expanded.has(category.id);
const suggestions = [...vocab.byId.keys()]
.filter((id) => !category.labelKeys.includes(id))
.map((id) => labelDisplay(id));
return (
<section key={category.id} className="fsg-cat">
<div className="fsg-cat-header">
<ActionIcon
variant="quiet"
className="fsg-cat-toggle"
aria-expanded={isExpanded}
aria-label={category.name}
onClick={() => toggleExpanded(category.id)}
>
{isExpanded ? (
<KeyboardArrowDownIcon className="fsg-chevron" />
) : (
<KeyboardArrowRightIcon className="fsg-chevron" />
)}
</ActionIcon>
<LabelIconPicker
value={category.icon}
onChange={(icon) => setCategoryIcon(category.id, icon)}
ariaLabel={t(
"fileSidebar.groupsModal.categoryIconAria",
"Choose an icon for {{name}}",
{ name: category.name },
)}
/>
{renaming === category.id ? (
<TextInput
className="fsg-rename"
size="xs"
value={renameDraft}
autoFocus
onChange={(e) => setRenameDraft(e.currentTarget.value)}
onBlur={() => commitRename(category.id)}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename(category.id);
if (e.key === "Escape") setRenaming(null);
}}
/>
) : (
<Button
variant="quiet"
size="sm"
justify="start"
className="fsg-cat-name"
title={t("fileSidebar.groupsModal.rename", "Rename")}
onClick={() => {
setRenaming(category.id);
setRenameDraft(category.name);
}}
>
{category.name}
</Button>
)}
<span className="fsg-count">
{categoryCounts.get(category.id) ?? 0}
</span>
<ActionIcon
variant="quiet"
className="fsg-cat-action"
aria-label={
category.hidden
? t("fileSidebar.groupsModal.show", "Show category")
: t("fileSidebar.groupsModal.hide", "Hide category")
}
aria-pressed={!category.hidden}
onClick={() =>
setCategoryHidden(category.id, !category.hidden)
}
>
{category.hidden ? (
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
) : (
<VisibilityIcon sx={{ fontSize: "1rem" }} />
)}
</ActionIcon>
<ActionIcon
variant="quiet"
className="fsg-cat-action"
aria-label={t(
"fileSidebar.groupsModal.delete",
"Delete category",
)}
onClick={() => deleteCategory(category.id)}
>
<DeleteOutlineIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</div>
{isExpanded && (
<div className="fsg-cat-body">
<div className="fsg-chips">
{memberKeys.map((key) => (
<LabelChip
key={key}
label={labelDisplay(key)}
icon={labelIcon(key)}
count={labelCounts.get(key)}
onRemove={() =>
removeLabelFromCategory(category.id, key)
}
removeAriaLabel={t(
"fileSidebar.groupsModal.removeLabel",
"Remove {{name}}",
{ name: labelDisplay(key) },
)}
/>
))}
</div>
<div className="fsg-add">
<input
className="fsg-add-input"
list={`fsg-vocab-${category.id}`}
value={addDraft[category.id] ?? ""}
placeholder={t(
"fileSidebar.groupsModal.addLabel",
"Add a label…",
)}
onChange={(e) =>
setAddDraft((prev) => ({
...prev,
[category.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addLabel(category.id);
}
}}
/>
<datalist id={`fsg-vocab-${category.id}`}>
{suggestions.map((name) => (
<option key={name} value={name} />
))}
</datalist>
<Button
variant="secondary"
size="sm"
leftSection={<AddIcon sx={{ fontSize: "0.9rem" }} />}
onClick={() => addLabel(category.id)}
disabled={!(addDraft[category.id] ?? "").trim()}
>
{t("fileSidebar.groupsModal.add", "Add")}
</Button>
</div>
</div>
)}
</section>
);
})}
<div className="fsg-new">
<input
className="fsg-add-input"
value={newCategory}
placeholder={t(
"fileSidebar.groupsModal.newCategory",
"New category name…",
)}
onChange={(e) => setNewCategory(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
createCategory();
}
}}
/>
<Button
variant="secondary"
size="sm"
leftSection={<AddIcon sx={{ fontSize: "0.9rem" }} />}
onClick={createCategory}
disabled={!newCategory.trim()}
>
{t("fileSidebar.groupsModal.createCategory", "Create category")}
</Button>
</div>
</div>
</Modal>
</>
@@ -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();
});
});
@@ -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<Set<string>>(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],
);
}
@@ -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<string, string>();
const iconById = new Map<string, string | undefined>();
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<FileSidebarGroup, "color">[] = [];
// 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" }),
);