Compare commits

..
118 changed files with 7250 additions and 2925 deletions
-12
View File
@@ -208,18 +208,6 @@
"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"
@@ -308,6 +308,106 @@ public class ApplicationProperties {
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
*/
private int longRunningTimeoutSeconds = 600;
/** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */
private int streamTimeoutSeconds = 1800;
/**
* Whether the processor pushes admin/settings-derived AI config (models, keys, RAG, limits)
* to the engine's {@code POST /api/v1/config} on startup and after a save. True lets a
* self-hosted admin drive the engine from the UI. Environment-driven deployments pin this
* false (SaaS does in application-saas.properties) so the engine stays entirely
* env-controlled and the processor never overrides its config.
*/
private boolean pushConfigToEngine = true;
/** Model + provider selection, forwarded to the engine per-request. */
private Models models = new Models();
/** Retrieval-augmented-generation (RAG) knobs, forwarded to the engine per-request. */
private Rag rag = new Rag();
/** Request size / cost guardrails. */
private Limits limits = new Limits();
/** Per-capability on/off switches so an admin can disable individual AI tools. */
private Features features = new Features();
@Data
public static class Models {
/** Provider driving the model strings: 'anthropic', 'openai', 'ollama', or 'custom'. */
private String provider = "anthropic";
/** High-quality tier model name (without provider prefix), e.g. 'claude-haiku-4-5'. */
private String smartModel = "claude-haiku-4-5";
/** Cheap/fast tier model name (without provider prefix). */
private String fastModel = "claude-haiku-4-5";
private int smartMaxTokens = 8192;
private int fastMaxTokens = 2048;
/**
* API key for the selected provider. Secret - masked in the admin API and overridable
* by the engine's own provider env var (e.g. ANTHROPIC_API_KEY) in environment-driven
* deployments. Empty means the engine falls back to its native env credentials.
*/
private String apiKey = "";
/**
* OpenAI-compatible base URL for 'ollama' / 'custom' providers (e.g.
* http://ollama:11434/v1). Ignored for anthropic/openai. SSRF-sensitive - admin only.
*/
private String baseUrl = "";
}
@Data
public static class Rag {
/**
* Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible).
*/
private String embeddingProvider = "voyageai";
/** Embedding model name (without provider prefix), e.g. 'voyage-4'. */
private String embeddingModel = "voyage-4";
/**
* Secret API key for the embedding provider; masked + env-overridable like
* models.apiKey.
*/
private String embeddingApiKey = "";
/**
* OpenAI-compatible base URL for 'ollama' / 'custom' embedding providers (e.g.
* http://ollama:11434/v1). Ignored for voyageai/openai. SSRF-sensitive - admin only.
*/
private String embeddingBaseUrl = "";
/** How many chunks retrieval returns per search. */
private int topK = 20;
/** Per-run cap on knowledge-search tool calls before the agent must answer. */
private int maxSearches = 5;
}
@Data
public static class Limits {
private int maxPages = 200;
private int maxCharacters = 200000;
/** Process-wide cap on concurrent model API calls (engine restart to apply). */
private int modelMaxConcurrency = 32;
}
@Data
public static class Features {
private boolean chat = true;
private boolean documentQuestions = true;
private boolean createPdf = true;
private boolean mathAuditor = true;
private boolean pdfComment = true;
private boolean classify = true;
}
}
/**
@@ -336,7 +336,19 @@ public class ConfigController {
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// AI Engine settings
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
// Per-capability flags let the UI hide individual AI tools an admin has turned off.
ApplicationProperties.AiEngine.Features aiFeatures = aiEngineConfig.getFeatures();
configData.put(
"aiFeatures",
Map.ofEntries(
Map.entry("chat", aiFeatures.isChat()),
Map.entry("documentQuestions", aiFeatures.isDocumentQuestions()),
Map.entry("createPdf", aiFeatures.isCreatePdf()),
Map.entry("mathAuditor", aiFeatures.isMathAuditor()),
Map.entry("pdfComment", aiFeatures.isPdfComment()),
Map.entry("classify", aiFeatures.isClassify())));
// Timestamp TSA settings — single source of truth for presets + admin URLs
ApplicationProperties.Security.Timestamp tsConfig =
@@ -366,6 +366,35 @@ aiEngine:
enabled: false # Set to 'true' to enable the AI engine integration
url: http://localhost:5001 # URL of the Python AI engine
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
longRunningTimeoutSeconds: 600 # Timeout (seconds) for heavy operations like RAG ingestion of large documents
streamTimeoutSeconds: 1800 # SSE stream timeout (seconds) for long-running orchestrator runs
pushConfigToEngine: true # Push admin/settings AI config (models, keys, RAG, limits) to the engine on startup + save. Set false to leave the engine fully env-controlled
models:
provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix)
fastModel: claude-haiku-4-5 # Cheap/fast tier model name (no provider prefix)
smartMaxTokens: 8192 # Max output tokens for the smart tier
fastMaxTokens: 2048 # Max output tokens for the fast tier
apiKey: "" # API key for the selected provider (secret). Empty = engine uses its native env credentials (e.g. ANTHROPIC_API_KEY)
baseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' providers (e.g. http://ollama:11434/v1). Ignored for anthropic/openai
rag:
embeddingProvider: voyageai # Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
embeddingModel: voyage-4 # Embedding model name (no provider prefix)
embeddingApiKey: "" # Secret API key for the embedding provider. Empty = engine uses its native env credentials (e.g. VOYAGE_API_KEY)
embeddingBaseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' embedding providers (e.g. http://ollama:11434/v1). Ignored for voyageai/openai
topK: 20 # Number of chunks retrieval returns per search
maxSearches: 5 # Per-run cap on knowledge-search tool calls before the agent must answer
limits:
maxPages: 200 # Upper bound on PDF pages the engine will process per request
maxCharacters: 200000 # Upper bound on characters of extracted text per request
modelMaxConcurrency: 32 # Process-wide cap on concurrent model API calls (engine restart to apply)
features: # Per-capability switches; turn an individual AI tool off without disabling the whole engine
chat: true # Assistant chat
documentQuestions: true # Ask-questions-about-a-PDF
createPdf: true # Generate a PDF from a natural-language spec
mathAuditor: true # Numerical/formula contradiction auditing
pdfComment: true # AI-authored PDF comments/annotations
classify: true # Automatic document classification/labelling
policies:
# Folder automations can read from and write to the directories you allow here, so treat this as a
@@ -385,6 +414,8 @@ policies:
mcp:
enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired.
scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category
maxRequestBytes: 10485760 # Max size (bytes) of an incoming MCP tool request payload (default 10 MB)
maxInlineResponseBytes: 10485760 # Max size (bytes) of an MCP tool response returned inline before it is rejected (default 10 MB)
allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP.
blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed.
auth:
-14
View File
@@ -66,20 +66,6 @@ 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}"
@@ -1,61 +0,0 @@
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();
}
}
}
@@ -0,0 +1,136 @@
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. 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.
* 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.
*/
public record ClassificationLabels(List<ClassificationLabel> labels) {
@@ -0,0 +1,71 @@
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)");
}
}
}
@@ -0,0 +1,22 @@
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);
}
@@ -0,0 +1,36 @@
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;
}
}
@@ -0,0 +1,76 @@
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;
}
}
@@ -0,0 +1,47 @@
package stirling.software.proprietary.classification.store;
import java.io.Serializable;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JPA row for a team's classification 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;
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.classification.store;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
@@ -7,7 +7,6 @@ import java.util.concurrent.Executor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -28,6 +27,7 @@ import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.TaskManager;
@@ -63,12 +63,11 @@ public class AiEngineController {
private final UserServiceInterface userService;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
* SSE emitter timeout in ms. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
* 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the
* executor. Configurable via {@code stirling.ai.streamTimeoutMs}.
* executor. Derived from {@code aiEngine.streamTimeoutSeconds}.
*/
@Value("${stirling.ai.streamTimeoutMs:1800000}")
private long streamTimeoutMs;
private final long streamTimeoutMs;
public AiEngineController(
AiEngineClient aiEngineClient,
@@ -78,6 +77,7 @@ public class AiEngineController {
TaskManager taskManager,
JobOwnershipService jobOwnershipService,
AiEngineEndpointResolver endpointResolver,
ApplicationProperties applicationProperties,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
@@ -87,6 +87,8 @@ public class AiEngineController {
this.jobOwnershipService = jobOwnershipService;
this.endpointResolver = endpointResolver;
this.userService = userService;
this.streamTimeoutMs =
applicationProperties.getAiEngine().getStreamTimeoutSeconds() * 1000L;
}
private String currentUserId() {
@@ -31,10 +31,12 @@ 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.AiFeatureGate;
import stirling.software.proprietary.service.PdfContentExtractor;
import tools.jackson.databind.JsonNode;
@@ -45,7 +47,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 built-in label set, and stores the engine's JSON
* engine to classify the document against the caller's team 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.
@@ -67,13 +69,18 @@ public class ClassifyLabelController {
private final PdfContentExtractor pdfContentExtractor;
private final PdfMetadataService pdfMetadataService;
private final AiEngineClient aiEngineClient;
private final AiFeatureGate aiFeatureGate;
private final ObjectMapper objectMapper;
private final UserServiceInterface userService;
/**
* The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}.
* 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.
*/
private final ClassificationLabelProvider labelProvider;
private final ClassificationLabelStore labelStore;
private final PolicyManagementAuthority policyManagementAuthority;
public ClassifyLabelController(
CustomPDFDocumentFactory pdfDocumentFactory,
@@ -81,17 +88,21 @@ public class ClassifyLabelController {
PdfContentExtractor pdfContentExtractor,
PdfMetadataService pdfMetadataService,
AiEngineClient aiEngineClient,
AiFeatureGate aiFeatureGate,
ObjectMapper objectMapper,
ClassificationLabelProvider labelProvider,
@Autowired(required = false) UserServiceInterface userService) {
@Autowired(required = false) UserServiceInterface userService,
@Autowired(required = false) ClassificationLabelStore labelStore,
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.tempFileManager = tempFileManager;
this.pdfContentExtractor = pdfContentExtractor;
this.pdfMetadataService = pdfMetadataService;
this.aiEngineClient = aiEngineClient;
this.aiFeatureGate = aiFeatureGate;
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)
@@ -104,14 +115,15 @@ public class ClassifyLabelController {
+ " intended for direct client use.")
public ResponseEntity<Resource> classifyAndLabel(
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
aiFeatureGate.requireClassify();
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
String fileName = safeFileName(fileInput.getOriginalFilename());
List<EngineLabel> allowed = resolveAllowedLabels();
if (allowed.isEmpty()) {
// 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);
// 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);
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
}
@@ -168,13 +180,24 @@ public class ClassifyLabelController {
}
/**
* 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.
* 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.
*/
private List<EngineLabel> resolveAllowedLabels() {
if (labelStore == null) {
return List.of();
}
Long teamId =
policyManagementAuthority == null
? null
: policyManagementAuthority.currentUserTeamId();
Map<String, EngineLabel> byId = new LinkedHashMap<>();
collectLabels(labelProvider.labels(), byId);
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
return List.copyOf(byId.values());
}
@@ -8,14 +8,12 @@ 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;
@@ -26,24 +24,18 @@ 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 document model to a PDF via WeasyPrint.
* Dispatchable tool that converts an AI-generated HTML string 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 engine supplies the document as
* structured fields; the HTML is built here from a fixed template.
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
* template so sanitization is intentionally skipped.
*/
@Slf4j
@Hidden
@@ -56,9 +48,6 @@ 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
@@ -85,42 +74,32 @@ public class CreatePdfAgentController {
value = "/create-pdf-from-html-agent",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Convert an AI-generated document to a PDF",
summary = "Convert AI-generated HTML to a PDF",
description =
"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)
"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)
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 document to PDF via WeasyPrint — html_bytes={}",
html.length());
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
htmlContent.length());
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8);
Files.writeString(htmlFile.getPath(), htmlContent, 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());
@@ -147,8 +126,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 pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
pdDocument.save(tempOut.getPath().toFile());
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
document.save(tempOut.getPath().toFile());
} catch (Exception e) {
tempOut.close();
throw e;
@@ -1,35 +0,0 @@
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,7 +37,8 @@ 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.integration.repository",
"stirling.software.proprietary.classification.store"
})
@EntityScan({
"stirling.software.proprietary.security.model",
@@ -49,7 +50,8 @@ 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.integration.model",
"stirling.software.proprietary.classification.store"
})
public class DatabaseConfig {
@@ -8,6 +8,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -45,6 +46,7 @@ import stirling.software.common.util.RegexPatternUtils;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
import stirling.software.proprietary.service.AiEngineConfigSync;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@@ -58,6 +60,7 @@ public class AdminSettingsController {
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final ApplicationContext applicationContext;
private final AiEngineConfigSync aiEngineConfigSync;
// Track settings that have been modified but not yet applied (require restart)
private static final ConcurrentHashMap<String, Object> pendingChanges =
@@ -172,6 +175,28 @@ public class AdminSettingsController {
.body(Map.of("error", "No settings provided to update"));
}
// Work on a mutable copy so the removal below works even if the caller passed an
// immutable map, then drop masked sensitive values so a UI round-trip never overwrites
// a real secret (e.g. an API key) with the "********" placeholder from the GET
// response.
settings = new LinkedHashMap<>(settings);
settings.entrySet()
.removeIf(
e -> {
if (!"********".equals(e.getValue())) {
return false;
}
String key = e.getKey();
String leaf =
key.contains(".")
? key.substring(key.lastIndexOf('.') + 1)
: key;
return isSensitiveFieldWithPath(leaf, key);
});
if (settings.isEmpty()) {
return ResponseEntity.ok(Map.of("message", "No changed settings to update."));
}
// Validate all settings first before applying any changes
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
@@ -206,6 +231,10 @@ public class AdminSettingsController {
pendingChanges.put(key, value != null ? value : "");
}
// If AI settings changed, push them to the engine live so model/RAG/limit changes
// apply without waiting for a processor restart.
maybePushAiEngineLive();
return ResponseEntity.ok(
Map.of(
"message",
@@ -600,6 +629,23 @@ public class AdminSettingsController {
}
}
/**
* Forward any pending {@code aiEngine.*} changes to the AI engine immediately. The running bean
* overlaid with these pending values is the current settings.yml state; {@link
* AiEngineConfigSync} no-ops unless AI is enabled and an engine-relevant key changed.
*/
private void maybePushAiEngineLive() {
Map<String, Object> aiEnginePending = new HashMap<>();
for (Map.Entry<String, Object> entry : pendingChanges.entrySet()) {
if (entry.getKey().startsWith("aiEngine.")) {
aiEnginePending.put(entry.getKey(), entry.getValue());
}
}
if (!aiEnginePending.isEmpty()) {
aiEngineConfigSync.pushLiveAfterSave(aiEnginePending);
}
}
private Object getSectionData(String sectionName) {
if (sectionName == null || sectionName.trim().isEmpty()) {
return null;
@@ -843,8 +889,13 @@ public class AdminSettingsController {
return true;
}
// Check for fields containing 'password' or 'secret'
return lowerField.contains("password") || lowerField.contains("secret");
// Substring match for secret-bearing field names. Covers provider credentials such as
// aiEngine.models.apiKey and aiEngine.rag.embeddingApiKey (which the exact-name set would
// miss) so keys are never returned to the client in cleartext.
return lowerField.contains("password")
|| lowerField.contains("secret")
|| lowerField.contains("apikey")
|| lowerField.contains("token");
}
/** Create a masked representation for sensitive fields */
@@ -1,135 +0,0 @@
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);
}
}
}
@@ -0,0 +1,181 @@
package stirling.software.proprietary.service;
import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AiEngine;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Pushes the admin-configured AI settings (model provider/name, per-provider API key, RAG choices,
* limits) to the Python engine so a self-hosted deployment can drive the engine's model and API key
* from the Stirling settings UI. Pushed on processor startup and again live whenever AI settings
* are saved (so model/RAG/limit changes apply without a restart). The engine applies it live
* (rebuilds its models) and caches it, so it self-restores on its own reboot. Empty
* key/baseUrl/model fields mean "keep the engine's own environment credential".
*
* <p>Gated by {@code aiEngine.pushConfigToEngine} (default true). Environment-driven deployments
* pin it false (SaaS does so in application-saas.properties) so the engine stays entirely
* env-controlled and the processor never pushes settings-derived config to it. Best-effort and
* non-blocking: a slow or unreachable engine never delays or fails Stirling startup.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiEngineConfigSync {
private static final int MAX_ATTEMPTS = 5;
private static final long RETRY_DELAY_MS = 3000L;
private final ApplicationProperties applicationProperties;
private final AiEngineClient aiEngineClient;
private final ObjectMapper objectMapper;
@EventListener(ApplicationReadyEvent.class)
public void pushConfigOnStartup() {
AiEngine cfg = applicationProperties.getAiEngine();
if (!cfg.isEnabled()) {
return;
}
if (!cfg.isPushConfigToEngine()) {
log.debug(
"Skipping AI engine config push: aiEngine.pushConfigToEngine is disabled"
+ " (the engine is configured from its own environment)");
return;
}
// Engine may still be booting; push on a virtual thread with a few retries so we never
// block or crash Stirling startup when the engine is slow or briefly unreachable.
Thread.ofVirtual().name("ai-engine-config-sync").start(() -> pushWithRetries(cfg));
}
/**
* Push AI settings to the engine immediately after an admin save so model/RAG/limit changes
* reach the engine without waiting for a processor restart. {@code pendingAiEngine} are the
* pending {@code aiEngine.*} dot-notation changes; the running bean overlaid with these is the
* current settings.yml state. No-op unless AI is enabled and an engine-relevant key changed.
*/
public void pushLiveAfterSave(Map<String, Object> pendingAiEngine) {
// Gate on the RUNNING bean: AiEngineClient refuses calls while the bean is disabled, so
// pushing on a pending-but-not-restarted enable would always fail. The post-restart
// startup push covers first-time enablement.
AiEngine cfg = applicationProperties.getAiEngine();
if (pendingAiEngine == null
|| pendingAiEngine.isEmpty()
|| !cfg.isPushConfigToEngine()
|| !cfg.isEnabled()) {
return;
}
boolean engineRelevant =
pendingAiEngine.keySet().stream().anyMatch(AiEngineConfigSync::isEngineRelevantKey);
if (!engineRelevant) {
return;
}
ObjectNode node = buildConfigNode(cfg);
pendingAiEngine.forEach((k, v) -> overlayIfEngineRelevant(node, k, v));
String body = node.toString();
Thread.ofVirtual().name("ai-engine-config-live-push").start(() -> pushOnce(body));
}
private void pushOnce(String body) {
try {
aiEngineClient.post("/api/v1/config", body, null);
log.info("Pushed AI engine configuration after settings change");
} catch (Exception e) {
log.warn(
"Live AI engine config push failed: {} (will re-sync on next restart)",
e.getMessage());
}
}
// Only models/rag/limits are forwarded to the engine; enabled/url/timeouts/features are
// processor-side and don't need a live engine push.
private static boolean isEngineRelevantKey(String key) {
return key.startsWith("aiEngine.models.")
|| key.startsWith("aiEngine.rag.")
|| key.startsWith("aiEngine.limits.");
}
private void overlayIfEngineRelevant(ObjectNode node, String key, Object value) {
if (!isEngineRelevantKey(key)) {
return;
}
String[] parts = key.substring("aiEngine.".length()).split("\\.");
ObjectNode parent = node;
for (int i = 0; i < parts.length - 1; i++) {
JsonNode child = parent.get(parts[i]);
parent = (child instanceof ObjectNode on) ? on : parent.putObject(parts[i]);
}
parent.set(parts[parts.length - 1], objectMapper.valueToTree(value));
}
private void pushWithRetries(AiEngine cfg) {
String body = buildConfigNode(cfg).toString();
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
aiEngineClient.post("/api/v1/config", body, null);
log.info("Pushed AI engine configuration on startup (attempt {})", attempt);
return;
} catch (Exception e) {
log.warn(
"AI engine config push failed (attempt {}/{}): {}",
attempt,
MAX_ATTEMPTS,
e.getMessage());
if (attempt < MAX_ATTEMPTS) {
try {
Thread.sleep(RETRY_DELAY_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
}
}
}
log.warn(
"Giving up pushing AI engine configuration after {} attempts; the engine will use"
+ " its own environment configuration until the next restart.",
MAX_ATTEMPTS);
}
private ObjectNode buildConfigNode(AiEngine cfg) {
AiEngine.Models m = cfg.getModels();
AiEngine.Rag r = cfg.getRag();
AiEngine.Limits l = cfg.getLimits();
ObjectNode root = objectMapper.createObjectNode();
ObjectNode models = root.putObject("models");
models.put("provider", m.getProvider());
models.put("smartModel", m.getSmartModel());
models.put("fastModel", m.getFastModel());
models.put("smartMaxTokens", m.getSmartMaxTokens());
models.put("fastMaxTokens", m.getFastMaxTokens());
models.put("apiKey", m.getApiKey());
models.put("baseUrl", m.getBaseUrl());
ObjectNode rag = root.putObject("rag");
rag.put("embeddingProvider", r.getEmbeddingProvider());
rag.put("embeddingModel", r.getEmbeddingModel());
rag.put("embeddingApiKey", r.getEmbeddingApiKey());
rag.put("embeddingBaseUrl", r.getEmbeddingBaseUrl());
rag.put("topK", r.getTopK());
rag.put("maxSearches", r.getMaxSearches());
ObjectNode limits = root.putObject("limits");
limits.put("maxPages", l.getMaxPages());
limits.put("maxCharacters", l.getMaxCharacters());
limits.put("modelMaxConcurrency", l.getModelMaxConcurrency());
return root;
}
}
@@ -0,0 +1,58 @@
package stirling.software.proprietary.service;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AiEngine.Features;
/**
* Central gate for the per-capability AI feature switches ({@code aiEngine.features.*}). Each AI
* capability entry point calls the matching {@code require*} method so an admin who turns a feature
* off in the settings UI gets a clean 503 instead of the request silently reaching the engine. All
* checks also fail closed when the AI engine itself is disabled.
*/
@Component
@RequiredArgsConstructor
public class AiFeatureGate {
private final ApplicationProperties applicationProperties;
private Features features() {
return applicationProperties.getAiEngine().getFeatures();
}
private void require(boolean featureEnabled, String feature) {
if (!applicationProperties.getAiEngine().isEnabled() || !featureEnabled) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI feature '" + feature + "' is disabled");
}
}
public void requireChat() {
require(features().isChat(), "chat");
}
public void requireDocumentQuestions() {
require(features().isDocumentQuestions(), "documentQuestions");
}
public void requireCreatePdf() {
require(features().isCreatePdf(), "createPdf");
}
public void requireMathAuditor() {
require(features().isMathAuditor(), "mathAuditor");
}
public void requirePdfComment() {
require(features().isPdfComment(), "pdfComment");
}
public void requireClassify() {
require(features().isClassify(), "classify");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
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);
}
}
@@ -0,0 +1,146 @@
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,6 +14,7 @@ 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;
@@ -26,9 +27,12 @@ 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.AiFeatureGate;
import stirling.software.proprietary.service.PdfContentExtractor;
import tools.jackson.databind.JsonNode;
@@ -39,16 +43,23 @@ 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 AiFeatureGate aiFeatureGate;
@Mock private PolicyManagementAuthority policyManagementAuthority;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private InProcessClassificationLabelStore labelStore;
private ClassifyLabelController controller;
private void withLabels(List<ClassificationLabel> labels) {
@BeforeEach
void setUp() {
labelStore = new InProcessClassificationLabelStore();
controller =
new ClassifyLabelController(
pdfDocumentFactory,
@@ -56,9 +67,11 @@ class ClassifyLabelControllerTest {
pdfContentExtractor,
pdfMetadataService,
aiEngineClient,
aiFeatureGate,
objectMapper,
ClassificationLabelProvider.withLabels(labels),
null);
null,
labelStore,
policyManagementAuthority);
}
private void stubSinglePageDocument() throws Exception {
@@ -88,7 +101,12 @@ class ClassifyLabelControllerTest {
@Test
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
labelStore.save(
TEAM,
new ClassificationLabels(
List.of(new ClassificationLabel("invoice", "Invoice", null))),
"admin");
stubSinglePageDocument();
@@ -103,12 +121,16 @@ class ClassifyLabelControllerTest {
}
@Test
void classifyAndLabel_sendsLabelIdsAndNames() throws Exception {
withLabels(
List.of(
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
new ClassificationLabel("contract", "Contract", null),
new ClassificationLabel("timesheet", "Timesheet", null)));
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");
stubSinglePageDocument();
@@ -133,13 +155,13 @@ class ClassifyLabelControllerTest {
}
@Test
void classifyAndLabel_skipsClassificationWhenNoLabels() throws Exception {
withLabels(List.of());
void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception {
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
stubSinglePageDocument();
// 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.
// 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.
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(
"document",
"{\"title\":\"PO\",\"sections\":[]}",
"htmlContent",
"<p>hi</p>",
"filename",
"purchase-order.pdf"))),
PolicyInputs.of(List.of()),
@@ -1,10 +1,13 @@
package stirling.software.proprietary.security.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -26,6 +29,7 @@ import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
import stirling.software.proprietary.service.AiEngineConfigSync;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@@ -37,6 +41,7 @@ class AdminSettingsControllerTest {
private ApplicationProperties applicationProperties;
private ObjectMapper objectMapper;
private ApplicationContext applicationContext;
private AiEngineConfigSync aiEngineConfigSync;
private AdminSettingsController controller;
@@ -45,9 +50,13 @@ class AdminSettingsControllerTest {
applicationProperties = new ApplicationProperties();
objectMapper = JsonMapper.builder().build();
applicationContext = org.mockito.Mockito.mock(ApplicationContext.class);
aiEngineConfigSync = org.mockito.Mockito.mock(AiEngineConfigSync.class);
controller =
new AdminSettingsController(
applicationProperties, objectMapper, applicationContext);
applicationProperties,
objectMapper,
applicationContext,
aiEngineConfigSync);
clearPendingChanges();
}
@@ -277,6 +286,51 @@ class AdminSettingsControllerTest {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}
@Test
@DisplayName("drops a masked ******** secret so a UI round-trip can't overwrite a real key")
void dropsMaskedSecretValue() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
Map<String, Object> settings = new HashMap<>();
settings.put("aiEngine.models.apiKey", "********");
settings.put("ui.appName", "My App");
request.setSettings(settings);
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// The masked secret is stripped; only the real change is persisted.
mocked.verify(
() ->
GeneralUtils.updateSettingsTransactional(
argThat(
(Map<String, Object> m) ->
!m.containsKey("aiEngine.models.apiKey")
&& m.containsKey("ui.appName"))));
}
}
@Test
@DisplayName("forwards only aiEngine.* pending keys to the engine live-push")
void forwardsOnlyAiEngineKeysToLivePush() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
Map<String, Object> settings = new HashMap<>();
settings.put("aiEngine.models.provider", "ollama");
settings.put("ui.appName", "My App");
request.setSettings(settings);
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
controller.updateSettings(request);
verify(aiEngineConfigSync)
.pushLiveAfterSave(
argThat(
(Map<String, Object> m) ->
m.containsKey("aiEngine.models.provider")
&& !m.containsKey("ui.appName")));
}
}
}
@Nested
@@ -1,139 +0,0 @@
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,150 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AiEngine;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* The config-push bridge is a self-hosted-only feature: it lets the admin UI drive the engine's
* model/key/RAG/limit config. Environment-driven deployments pin {@code
* aiEngine.pushConfigToEngine} false (SaaS does so in application-saas.properties) so the engine
* stays entirely env-controlled - these tests lock in that the processor stays silent then while
* still pushing when it is enabled.
*/
class AiEngineConfigSyncTest {
private ApplicationProperties applicationProperties;
private AiEngineClient aiEngineClient;
private AiEngineConfigSync sync;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getAiEngine().setEnabled(true);
applicationProperties.getAiEngine().setPushConfigToEngine(true);
aiEngineClient = mock(AiEngineClient.class);
ObjectMapper objectMapper = JsonMapper.builder().build();
sync = new AiEngineConfigSync(applicationProperties, aiEngineClient, objectMapper);
}
@Test
void startupPushSkippedWhenPushDisabled() throws Exception {
applicationProperties.getAiEngine().setPushConfigToEngine(false);
sync.pushConfigOnStartup();
// Returns synchronously before spawning the push thread, so no interaction ever happens.
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void startupPushSkippedWhenDisabled() throws Exception {
applicationProperties.getAiEngine().setEnabled(false);
sync.pushConfigOnStartup();
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void startupPushSentWhenEnabledAndPushOn() throws Exception {
sync.pushConfigOnStartup();
// Push runs on a virtual thread; wait for the single POST to /api/v1/config.
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
}
@Test
void livePushSkippedWhenPushDisabled() throws Exception {
applicationProperties.getAiEngine().setPushConfigToEngine(false);
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
@Test
void livePushSentForEngineRelevantChangeWhenPushOn() throws Exception {
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
}
@Test
void startupPushSerialisesTheEngineWireContract() throws Exception {
// Distinct values so a dropped/renamed field is detectable. Mirrors
// engine/tests/fixtures/processor_config_push.json - keep the two in sync; the engine's
// test_config_contract.py validates that same shape on the receiving side.
AiEngine ai = applicationProperties.getAiEngine();
ai.getModels().setProvider("ollama");
ai.getModels().setSmartModel("smart-model-x");
ai.getModels().setFastModel("fast-model-x");
ai.getModels().setSmartMaxTokens(1111);
ai.getModels().setFastMaxTokens(2222);
ai.getModels().setApiKey("provider-key-abc");
ai.getModels().setBaseUrl("http://engine.example/v1");
ai.getRag().setEmbeddingProvider("custom");
ai.getRag().setEmbeddingModel("embed-model-x");
ai.getRag().setEmbeddingApiKey("embed-key-abc");
ai.getRag().setEmbeddingBaseUrl("http://embed.example/v1");
ai.getRag().setTopK(33);
ai.getRag().setMaxSearches(7);
ai.getLimits().setMaxPages(111);
ai.getLimits().setMaxCharacters(222222);
ai.getLimits().setModelMaxConcurrency(9);
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
sync.pushConfigOnStartup();
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull());
JsonNode root = JsonMapper.builder().build().readTree(body.getValue());
JsonNode models = root.get("models");
assertEquals("ollama", models.get("provider").asText());
assertEquals("smart-model-x", models.get("smartModel").asText());
assertEquals("fast-model-x", models.get("fastModel").asText());
assertEquals(1111, models.get("smartMaxTokens").asInt());
assertEquals(2222, models.get("fastMaxTokens").asInt());
assertEquals("provider-key-abc", models.get("apiKey").asText());
assertEquals("http://engine.example/v1", models.get("baseUrl").asText());
JsonNode rag = root.get("rag");
assertEquals("custom", rag.get("embeddingProvider").asText());
assertEquals("embed-model-x", rag.get("embeddingModel").asText());
assertEquals("embed-key-abc", rag.get("embeddingApiKey").asText());
assertEquals("http://embed.example/v1", rag.get("embeddingBaseUrl").asText());
assertEquals(33, rag.get("topK").asInt());
assertEquals(7, rag.get("maxSearches").asInt());
JsonNode limits = root.get("limits");
assertEquals(111, limits.get("maxPages").asInt());
assertEquals(222222, limits.get("maxCharacters").asInt());
assertEquals(9, limits.get("modelMaxConcurrency").asInt());
}
@Test
void livePushSkippedForNonEngineRelevantChange() throws Exception {
// features.* is processor-side only; no engine push is warranted.
sync.pushLiveAfterSave(Map.of("aiEngine.features.chat", false));
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
}
}
@@ -0,0 +1,54 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
/**
* Every AI capability entry point calls the matching {@code require*} before reaching the engine.
* These lock in fail-closed behaviour: a 503 when the engine is disabled OR the individual feature
* flag is off, and a clean pass only when both are on.
*/
class AiFeatureGateTest {
private ApplicationProperties props;
private AiFeatureGate gate;
@BeforeEach
void setUp() {
props = new ApplicationProperties();
props.getAiEngine().setEnabled(true); // features default all-on
gate = new AiFeatureGate(props);
}
@Test
void passesWhenEngineEnabledAndFeatureOn() {
assertDoesNotThrow(() -> gate.requireClassify());
assertDoesNotThrow(() -> gate.requireChat());
}
@Test
void throws503WhenFeatureFlagOff() {
props.getAiEngine().getFeatures().setClassify(false);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> gate.requireClassify());
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@Test
void throws503WhenEngineDisabledEvenIfFeatureOn() {
props.getAiEngine().setEnabled(false); // feature flag still true
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> gate.requireChat());
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
}
@@ -17,7 +17,6 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.locks.Lock;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.core.Authentication;
@@ -32,8 +31,6 @@ import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.filter.OncePerRequestFilter;
import com.google.common.util.concurrent.Striped;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -63,13 +60,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
public static final String BEARER_PREFIX = "Bearer ";
public static final String ANON_PREFIX = "anon_";
// Per-Supabase-id provisioning locks for the first-login create path (see
// getOrCreateUser). Striped so memory stays fixed regardless of how many users are
// seen; distinct ids only contend on a hash collision, which is harmless. The
// fast path for already-provisioned users never acquires one.
private static final int PROVISIONING_LOCK_STRIPES = 256;
private final Striped<Lock> provisioningLocks = Striped.lock(PROVISIONING_LOCK_STRIPES);
private final TeamService teamService;
private final UserService userService;
private final SupabaseUserService supabaseUserService;
@@ -232,33 +222,18 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
// If not present, the JWT references a Supabase user this server hasn't synced.
SupabaseUser supabaseUser = supabaseUserService.getUser(supabaseId);
// Fast path: an already-provisioned user is resolved with a single indexed
// lookup and no locking, so returning users see no behaviour or latency change.
// Resolve to a local User by supabase_id.
Optional<User> linkedUser = userService.findBySupabaseId(supabaseId);
if (linkedUser.isPresent()) {
return resolveExistingUser(linkedUser.get(), supabaseUser, jwt);
User user = linkedUser.get();
if (ANONYMOUS.toString().equalsIgnoreCase(user.getAuthenticationType())
&& !supabaseUser.isAnonymous()) {
user = upgradeAnonymousUser(user, supabaseUser, jwt);
}
return user;
}
// First-login slow path. A freshly signed-in user (notably an auto-anonymous
// guest) has no local row yet, and the SPA fires many authenticated requests in
// parallel the instant a token exists — all would miss the lookup above and race
// to INSERT the same supabase_auth_id, so all but one hit the unique constraint
// (Postgres 23505). Serialise provisioning per Supabase id within this instance
// and re-check under the lock: the first request creates the row, the rest find
// it and reuse it, so no duplicate INSERT is even attempted. The create path in
// createUser() keeps its own catch as a backstop for the rarer cross-instance
// race, where two nodes each get the very first request at the same moment.
Lock lock = provisioningLocks.get(supabaseId);
lock.lock();
try {
Optional<User> recheck = userService.findBySupabaseId(supabaseId);
if (recheck.isPresent()) {
return resolveExistingUser(recheck.get(), supabaseUser, jwt);
}
return createUser(jwt, supabaseId, email, appMetadata);
} finally {
lock.unlock();
}
return createUser(jwt, supabaseId, email, appMetadata);
} catch (UserNotFoundException e) {
throw new InvalidBearerTokenException("User not found", e);
} catch (InvalidBearerTokenException e) {
@@ -272,19 +247,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
}
}
/**
* Resolve an already-linked local user, upgrading a still-anonymous row to the real
* provider/email once the Supabase account is no longer anonymous. Shared by the fast-path
* lookup and the under-lock re-check so both resolve identically.
*/
private User resolveExistingUser(User user, SupabaseUser supabaseUser, Jwt jwt) {
if (ANONYMOUS.toString().equalsIgnoreCase(user.getAuthenticationType())
&& !supabaseUser.isAnonymous()) {
return upgradeAnonymousUser(user, supabaseUser, jwt);
}
return user;
}
/** Promote a local anonymous user to the real provider+email carried on the JWT. */
@Transactional
protected User upgradeAnonymousUser(User user, SupabaseUser supabaseUser, Jwt jwt) {
@@ -1,6 +1,12 @@
# Stirling-PDF SaaS profile. Pure multi-tenant cloud.
# Activated when the :saas module is on the classpath.
# ---------- AI engine ----------
# The SaaS AI backend is env-driven and reached through the saas AiProxyController, so the
# processor must never push settings-derived config to it. Pin the config-push off; the engine
# stays entirely environment-controlled.
aiEngine.pushConfigToEngine=false
# ---------- Datasource ----------
system.datasource.enableCustomDatabase=true
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
@@ -1,10 +0,0 @@
-- 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;
@@ -514,11 +514,7 @@ class SupabaseAuthenticationFilterMoreTest {
User winner = newUser("winner@example.com");
winner.setSupabaseId(supabaseId);
// Fast-path miss, then a miss on the under-lock re-check too — i.e. a
// cross-instance race the in-instance lock can't cover — so createUser is
// attempted; its INSERT loses and the catch fetches the committed winner.
when(userService.findBySupabaseId(supabaseId))
.thenReturn(Optional.empty())
.thenReturn(Optional.empty())
.thenReturn(Optional.of(winner));
when(userService.saveUser(any(User.class)))
@@ -2,26 +2,18 @@ package stirling.software.saas.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -179,82 +171,6 @@ class SupabaseAuthenticationFilterTest {
.isInstanceOf(EnhancedJwtAuthenticationToken.class);
}
@Test
void concurrentFirstLoginCreatesUserExactlyOnce() throws Exception {
// Reproduces the first-login stampede: a brand-new user's SPA fires many
// authenticated requests in parallel before the local row exists. Without the
// per-Supabase-id provisioning lock every request would attempt the INSERT and all
// but one would hit the users_supabase_auth_id_key unique constraint. The lock must
// collapse this to a single create while every request still authenticates.
UUID supabaseId = UUID.randomUUID();
Jwt jwt = jwtFor(supabaseId, "frank@example.com", false, "google");
when(jwtDecoder.decode("token")).thenReturn(jwt);
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUserMatching(supabaseId, "frank@example.com", false));
// findBySupabaseId reflects the real DB: empty until the winning save commits, then
// returns the created row for every subsequent (and re-checked) lookup.
AtomicReference<User> created = new AtomicReference<>();
when(userService.findBySupabaseId(supabaseId))
.thenAnswer(inv -> Optional.ofNullable(created.get()));
when(userService.saveUser(any(User.class)))
.thenAnswer(
inv -> {
User u = inv.getArgument(0);
created.set(u);
return u;
});
int threads = 16;
ExecutorService pool = Executors.newFixedThreadPool(threads);
try {
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch go = new CountDownLatch(1);
List<Future<Boolean>> results = new ArrayList<>();
for (int i = 0; i < threads; i++) {
results.add(
pool.submit(
() -> {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI("/api/v1/something");
req.setMethod("POST");
req.addHeader("Authorization", "Bearer token");
SecurityContextHolder.clearContext();
ready.countDown();
go.await();
filter.doFilter(
req,
new MockHttpServletResponse(),
new MockFilterChain());
boolean authed =
SecurityContextHolder.getContext().getAuthentication()
instanceof EnhancedJwtAuthenticationToken;
SecurityContextHolder.clearContext();
return authed;
}));
}
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
go.countDown();
int authenticated = 0;
for (Future<Boolean> f : results) {
if (Boolean.TRUE.equals(f.get(10, TimeUnit.SECONDS))) {
authenticated++;
}
}
assertThat(authenticated)
.as("every concurrent request authenticates")
.isEqualTo(threads);
} finally {
pool.shutdownNow();
}
// The guard collapses the stampede to a single create; the losers reuse the row.
verify(userService, times(1)).saveUser(any(User.class));
verify(supabaseUserService, times(1)).createSupabaseUser(eq(supabaseId), any(), eq(false));
verify(saasTeamService, times(1)).ensurePersonalTeam(any(User.class));
}
@Test
void appleProviderClassifiedAsOauth2NotWeb() throws Exception {
UUID supabaseId = UUID.randomUUID();
-9
View File
@@ -36,8 +36,6 @@ 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"
@@ -224,13 +222,6 @@ 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}"
+5
View File
@@ -88,3 +88,8 @@ STIRLING_LOG_FILE=
# Use when diagnosing worker stalls: a hung call shows a "Request" line with
# no matching "Response" line. Noisy; leave off in normal use.
STIRLING_HTTP_DEBUG=false
# Allow the Java processor to push admin-configured AI settings (model,
# credentials, limits) to POST /api/v1/config at startup. Set false in
# environment-driven deployments so the environment is the single source of truth.
#STIRLING_ALLOW_CONFIG_PUSH=true
+2
View File
@@ -4,7 +4,9 @@ version = "0.1.0"
description = "AI Document Engine"
requires-python = ">=3.13"
dependencies = [
"cryptography>=44.0.0",
"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 — its
# fixed built-in label set — so the engine holds no vocabulary of its own.
# The caller (the backend) always supplies the allowed vocabulary — the
# team's stored labels — 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)
@@ -15,6 +15,7 @@ from __future__ import annotations
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.output_mode import output_retries
from stirling.contracts import (
MathAuditorToolReportArtifact,
OrchestratorRequest,
@@ -68,6 +69,10 @@ class MathIntentClassifier:
self._agent: Agent[None, _MathIntentDecision] = Agent(
model=runtime.fast_model,
output_type=_MathIntentDecision,
# Local models emit valid structured output only intermittently (they
# sometimes wrap the JSON in prose); a few extra retries make this
# hot-path classifier reliable. No-op for real providers.
retries=output_retries(runtime.settings.chat_provider),
system_prompt=_MATH_INTENT_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
+85 -2
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import assert_never
from typing import Literal, assert_never
from pydantic import ConfigDict, Field
from pydantic_ai import Agent
from pydantic_ai.output import ToolOutput
from pydantic_ai.output import NativeOutput, ToolOutput
from pydantic_ai.tools import RunContext
from stirling.agents.output_mode import output_retries, uses_tool_output
from stirling.agents.pdf_create import PdfCreateAgent
from stirling.agents.pdf_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
@@ -27,6 +29,7 @@ from stirling.contracts import (
format_file_names,
)
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
@@ -38,6 +41,38 @@ class OrchestratorDeps:
request: OrchestratorRequest
# Enum-style routing used for Ollama/custom (OpenAI-compatible local) models. The
# function-ToolOutput delegates below take no arguments (they read everything from
# ctx), but local models reliably try to pass the user's message as tool args, which
# the zero-arg delegate schemas reject. Having the model pick a capability by name and
# dispatching in Python (via the same _run_* methods used on resume) sidesteps that.
_RouteCapability = Literal["pdf_edit", "pdf_question", "user_spec", "pdf_review", "pdf_create", "unsupported"]
class _RouteDecision(ApiModel):
# Local models routinely add stray tool args (the filename, the question echoed back)
# and send null for optional fields; tolerate both so routing never fails on cosmetics.
model_config = ConfigDict(extra="ignore")
capability: _RouteCapability
message: str | None = Field(
default=None,
description="Only for capability='unsupported': a short, helpful message to show the user.",
)
_ROUTER_SYSTEM_PROMPT = (
"You are the top-level router. Choose exactly one capability that best handles the request:\n"
"- pdf_edit: modify or convert one or more attached PDFs.\n"
"- pdf_question: answer questions about the contents of the attached PDFs.\n"
"- user_spec: create or define an agent spec.\n"
"- pdf_review: return the PDF with review comments/annotations attached.\n"
"- pdf_create: generate a NEW document from scratch (invoice, report, letter) - no input file.\n"
"- unsupported: none of the above fit, or the user asks about the assistant itself; put a "
"helpful message in 'message'.\n"
"Respond with the capability and (only for unsupported) a message."
)
class OrchestratorAgent:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
@@ -86,6 +121,9 @@ class OrchestratorAgent:
description="Return this when none of the delegate outputs fit the request.",
),
],
# Local models (Ollama/custom) pick a delegate less reliably; give the
# routing a few extra validation retries. No-op for real providers.
retries=output_retries(runtime.settings.chat_provider),
deps_type=OrchestratorDeps,
system_prompt=(
"You are the top-level orchestrator. "
@@ -103,6 +141,24 @@ class OrchestratorAgent:
),
model_settings=runtime.fast_model_settings,
)
# Local models can't drive the zero-arg function-delegate routing above, so
# route them by capability name and dispatch in Python instead.
self._route_via_enum = uses_tool_output(runtime.settings.chat_provider)
# The router has no tools of its own, so NativeOutput (a direct structured
# response) works on Ollama here - unlike tool-using agents, which need
# ToolOutput. A single output tool would tempt a local model to answer in
# plain text and never call it ("include your response in a tool call").
self._router = (
Agent(
model=runtime.fast_model,
output_type=NativeOutput([_RouteDecision]),
retries=output_retries(runtime.settings.chat_provider),
system_prompt=_ROUTER_SYSTEM_PROMPT,
model_settings=runtime.fast_model_settings,
)
if self._route_via_enum
else None
)
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
logger.info(
@@ -114,6 +170,8 @@ class OrchestratorAgent:
)
if request.resume_with is not None:
return await self._resume(request, request.resume_with)
if self._router is not None:
return await self._route_and_dispatch(request)
result = await self.agent.run(
self._build_prompt(request),
deps=OrchestratorDeps(runtime=self.runtime, request=request),
@@ -121,6 +179,31 @@ class OrchestratorAgent:
logger.info("[orchestrator] routed -> %s", type(result.output).__name__)
return result.output
async def _route_and_dispatch(self, request: OrchestratorRequest) -> OrchestratorResponse:
"""Local-model routing: pick a capability by name, then dispatch in Python."""
assert self._router is not None
result = await self._router.run(self._build_prompt(request))
decision = result.output
logger.info("[orchestrator] enum-routed -> %s", decision.capability)
match decision.capability:
case "pdf_edit":
return await self._run_pdf_edit(request)
case "pdf_question":
return await self._run_pdf_question(request)
case "user_spec":
return await self._run_agent_draft(request)
case "pdf_review":
return await self._run_pdf_review(request)
case "pdf_create":
return await self._run_pdf_create(request)
case "unsupported":
return UnsupportedCapabilityResponse(
capability="orchestrate",
message=decision.message or "I can't help with that request.",
)
case _ as unreachable:
assert_never(unreachable)
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
"""Fast-path to get back to the correct endpoint without having to call AI.
+46
View File
@@ -0,0 +1,46 @@
"""Provider-aware structured-output strategy.
OpenAI-compatible local models (Ollama, and 'custom' OpenAI-compatible endpoints)
block tool-calling whenever a native json-schema ``response_format`` is set: the
API constrains every completion to the output schema, so the model can never emit
a tool-call turn. Agents that combine structured output with tools (RAG search,
whole-document read, etc.) therefore get an ungrounded answer - the model fills the
schema directly instead of calling a retrieval tool.
Delivering the structured result via a tool call (:class:`ToolOutput`) sidesteps
this: it's all tool-calling, which these endpoints handle, so the model can call a
retrieval tool and then the output tool. Real providers (Anthropic, OpenAI) keep
:class:`NativeOutput`, which is unaffected and preferred there.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from pydantic_ai.output import NativeOutput, ToolOutput
# Providers whose OpenAI-compatible endpoint needs tool-delivered structured output.
_TOOL_OUTPUT_PROVIDERS = frozenset({"ollama", "custom"})
def uses_tool_output(chat_provider: str) -> bool:
return chat_provider in _TOOL_OUTPUT_PROVIDERS
def structured_output(output_types: Sequence[Any], *, chat_provider: str) -> Any:
"""Pick a structured-output spec compatible with the active chat provider.
Ollama/custom deliver each output variant via a :class:`ToolOutput`; every other
provider uses a single :class:`NativeOutput` over the variants.
"""
types = list(output_types)
if uses_tool_output(chat_provider):
return [ToolOutput(t) for t in types]
return NativeOutput(types)
def output_retries(chat_provider: str, *, native: int = 1, tool: int = 6) -> int:
"""Local models delivering via ToolOutput need more output-validation retries -
small models produce valid complex/nested structured output only intermittently."""
return tool if uses_tool_output(chat_provider) else native
+29 -12
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. The assembled document is emitted as structured fields. The LLM never writes HTML.
6. Jinja renders the document to HTML. 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,7 +22,9 @@ 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
@@ -49,6 +51,8 @@ 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.
@@ -162,13 +166,10 @@ 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"). 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.
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.
- 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
@@ -298,6 +299,15 @@ 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("-")
@@ -310,6 +320,7 @@ 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,
@@ -390,12 +401,14 @@ class PdfCreateAgent:
sections=all_sections,
)
# ── Phase 6: emit ──────────────────────────────────────────────────────
# ── Phase 6: render ────────────────────────────────────────────────────
logger.info("[pdf-create] phase 6/6: rendering HTML")
html = self._render(doc)
filename = _safe_filename(plan.title)
logger.info(
"[pdf-create] done — filename=%r sections=%d",
"[pdf-create] done — filename=%r html_bytes=%d",
filename,
len(all_sections),
len(html),
)
return EditPlanResponse(
@@ -404,7 +417,7 @@ class PdfCreateAgent:
ToolOperationStep(
tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT,
parameters=CreatePdfFromHtmlAgentParams(
document=doc.model_dump_json(),
html_content=html,
filename=filename,
),
)
@@ -424,3 +437,7 @@ 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)
@@ -1,4 +1,3 @@
{%- autoescape true -%}
<!DOCTYPE html>
<html lang="en">
<head>
@@ -176,18 +175,18 @@
color: var(--color-label);
}
</style>
{%- if style_primary or style_background or style_body %}
{%- if doc.style %}
<style>
:root {
{%- if style_primary %}
--color-primary: {{ style_primary }};
{%- if doc.style.primary_color %}
--color-primary: {{ doc.style.primary_color }};
{%- endif %}
{%- if style_background %}
--color-bg: {{ style_background }};
{%- if doc.style.background_color %}
--color-bg: {{ doc.style.background_color }};
{%- endif %}
{%- if style_body %}
--color-body: {{ style_body }};
--color-label: {{ style_body }};
{%- if doc.style.body_text_color %}
--color-body: {{ doc.style.body_text_color }};
--color-label: {{ doc.style.body_text_color }};
{%- endif %}
}
</style>
@@ -196,16 +195,16 @@
<body>
<div class="doc-header">
<div class="doc-title">{{ title }}</div>
{%- if subtitle %}
<div class="doc-subtitle">{{ subtitle }}</div>
<div class="doc-title">{{ doc.title }}</div>
{%- if doc.subtitle %}
<div class="doc-subtitle">{{ doc.subtitle }}</div>
{%- endif %}
{%- if reference_number %}
<div class="doc-reference">{{ reference_number }}</div>
{%- if doc.reference_number %}
<div class="doc-reference">{{ doc.reference_number }}</div>
{%- endif %}
</div>
{%- for section in sections %}
{%- for section in doc.sections %}
{%- if section.type == "text" %}
<section>
@@ -213,8 +212,8 @@
<h2>{{ section.heading }}</h2>
{%- endif %}
<div class="text-body">
{%- for para in section.paragraphs %}
<p>{{ para }}</p>
{%- for para in section.body.split('\n\n') %}
<p>{{ para | replace('\n', ' ') }}</p>
{%- endfor %}
</div>
</section>
@@ -226,10 +225,10 @@
{%- endif %}
<table class="kv-table">
<tbody>
{%- for pair in section.pairs %}
{%- for label, value in section.pairs %}
<tr>
<td class="kv-label">{{ pair.label }}</td>
<td class="kv-value">{{ pair.value }}</td>
<td class="kv-label">{{ label }}</td>
<td class="kv-value">{{ value }}</td>
</tr>
{%- endfor %}
</tbody>
@@ -300,4 +299,3 @@
</body>
</html>
{%- endautoescape %}
+10 -2
View File
@@ -3,10 +3,10 @@ from __future__ import annotations
import logging
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents.contradiction import ContradictionCapability, ContradictionDetector
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
from stirling.agents.output_mode import output_retries, structured_output
from stirling.agents.shared import ChunkedReasoner, WholeDocReaderCapability
from stirling.contracts import (
AiFile,
@@ -196,9 +196,17 @@ class PdfQuestionAgent:
files=request.files,
principals=principals,
)
# Ollama/custom (OpenAI-compatible local) models block tool-calling under a
# native json-schema response format, so deliver the structured result via a
# tool call instead - otherwise the model never calls the retrieval tools and
# answers ungrounded. Real providers keep NativeOutput. See agents.output_mode.
provider = self.runtime.settings.chat_provider
agent = Agent(
model=self.runtime.smart_model,
output_type=NativeOutput([PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse]),
output_type=structured_output(
[PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse], chat_provider=provider
),
retries=output_retries(provider),
system_prompt=PDF_QUESTION_SYSTEM_PROMPT,
# pydantic-ai accepts a list of (string-or-callable) instruction sources;
# it resolves each at run time and concatenates them for the model.
+59 -27
View File
@@ -3,28 +3,20 @@ from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi import Depends, FastAPI, Request
from pydantic_ai import Agent
from pydantic_ai.models import Model
from pydantic_ai.models.instrumented import InstrumentationSettings
from stirling.agents import (
DocumentClassifierAgent,
ExecutionPlanningAgent,
OrchestratorAgent,
PdfEditAgent,
PdfQuestionAgent,
UserSpecAgent,
)
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.api.bootstrap import apply_app_state, build_app_state
from stirling.api.dependencies import enforce_required_user_id
from stirling.api.engine_auth import EngineSharedSecretMiddleware
from stirling.api.middleware import UserIdMiddleware
from stirling.api.routes import (
agent_capabilities_router,
agent_draft_router,
config_router,
document_classifier_router,
document_router,
execution_router,
@@ -34,10 +26,12 @@ from stirling.api.routes import (
pdf_edit_router,
pdf_question_router,
)
from stirling.api.routes.config import CONFIG_APPLY_ERRORS, resolve_and_apply
from stirling.config import AppSettings, load_settings
from stirling.config.config_cache import load_config
from stirling.contracts import HealthResponse
from stirling.documents import DocumentService
from stirling.services import build_runtime, setup_posthog_tracking
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import setup_posthog_tracking
logger = logging.getLogger(__name__)
@@ -83,22 +77,52 @@ def _load_startup_settings(fast_api: FastAPI) -> AppSettings:
return load_settings()
def _restore_cached_config(
settings: AppSettings,
) -> tuple[AppSettings, Model | None, Model | None, EmbeddingService | None]:
"""Restore the last-applied pushed config from the encrypted on-disk cache.
Returns the effective settings plus the pre-built smart/fast models and
embedder to inject into the initial app state. Falls back to the env settings
(all-None) when config push is disabled, no cache exists, or the cached config
can't be applied (bad/unavailable model) - the cache never crashes boot.
"""
if not settings.allow_config_push:
return settings, None, None, None
cached = load_config()
if cached is None:
return settings, None, None, None
try:
effective, smart_model, fast_model, embedder, notes = resolve_and_apply(settings, cached)
except CONFIG_APPLY_ERRORS:
logger.warning("Cached AI config could not be applied; falling back to env settings", exc_info=True)
return settings, None, None, None
logger.info(
"Restored cached AI config: smart_model=%s fast_model=%s%s",
effective.smart_model_name,
effective.fast_model_name,
f"; {'; '.join(notes)}" if notes else "",
)
return effective, smart_model, fast_model, embedder
@asynccontextmanager
async def lifespan(fast_api: FastAPI):
# Load env vars on startup so we can immediately crash if required env vars aren't set
settings = _load_startup_settings(fast_api)
runtime = build_runtime(settings)
fast_api.state.settings = settings
fast_api.state.runtime = runtime
fast_api.state.orchestrator_agent = OrchestratorAgent(runtime)
fast_api.state.pdf_edit_agent = PdfEditAgent(runtime)
fast_api.state.pdf_question_agent = PdfQuestionAgent(runtime)
fast_api.state.user_spec_agent = UserSpecAgent(runtime)
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
fast_api.state.math_auditor_agent = MathAuditorAgent(runtime)
fast_api.state.pdf_comment_agent = PdfCommentAgent(runtime)
fast_api.state.document_classifier_agent = DocumentClassifierAgent(runtime)
tracer_provider = setup_posthog_tracking(settings)
# Precedence: env < persisted cache < live push. Restore the last-applied pushed
# config unless config push is disabled (then env is the single source of truth).
effective, smart_model, fast_model, embedder = _restore_cached_config(settings)
app_state = build_app_state(
effective,
fast_model=fast_model,
smart_model=smart_model,
embedder=embedder,
)
fast_api.state.settings = effective
apply_app_state(fast_api.state, app_state)
runtime = app_state.runtime
tracer_provider = setup_posthog_tracking(effective)
if tracer_provider:
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
reaper_task = asyncio.create_task(
@@ -135,10 +159,18 @@ app.include_router(ledger_router, dependencies=_user_gate)
app.include_router(pdf_comments_router, dependencies=_user_gate)
app.include_router(agent_capabilities_router, dependencies=_user_gate)
app.include_router(document_classifier_router, dependencies=_user_gate)
# Config push is a system/admin sync from the Java processor with no X-User-Id, so
# it is guarded by the X-Engine-Auth shared secret (global middleware) and the
# allow_config_push flag only, deliberately NOT the per-user identity gate.
app.include_router(config_router)
@app.get("/health", response_model=HealthResponse)
async def healthcheck(settings: Annotated[AppSettings, Depends(load_settings)]) -> HealthResponse:
async def healthcheck(http_request: Request) -> HealthResponse:
# Report the LIVE config (env < cache < push) held on app.state, not the
# boot-time env cache, so an admin "Test connection" check shows the model
# actually in use after a config push. Falls back to env if state isn't up yet.
settings: AppSettings = getattr(http_request.app.state, "settings", None) or load_settings()
return HealthResponse(
status="ok",
smart_model=settings.smart_model_name,
+86
View File
@@ -0,0 +1,86 @@
"""Assemble the runtime + every agent into a single swappable bundle.
Both the lifespan startup and the config-push endpoint build the same object
graph: one :class:`AppRuntime` plus one agent instance per flow, each of which
captures ``runtime.smart_model``/``runtime.fast_model`` at construction. To change
models at runtime the whole bundle has to be rebuilt and swapped into
``app.state`` atomically, which is what :func:`build_app_state` /
:func:`apply_app_state` exist for.
"""
from __future__ import annotations
from dataclasses import dataclass, fields
from typing import Any
from pydantic_ai.models import Model
from stirling.agents import (
DocumentClassifierAgent,
ExecutionPlanningAgent,
OrchestratorAgent,
PdfEditAgent,
PdfQuestionAgent,
UserSpecAgent,
)
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.config import AppSettings
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import AppRuntime, build_runtime
@dataclass(frozen=True)
class AppState:
"""Every object the lifespan assigns onto ``fast_api.state``."""
runtime: AppRuntime
orchestrator_agent: OrchestratorAgent
pdf_edit_agent: PdfEditAgent
pdf_question_agent: PdfQuestionAgent
user_spec_agent: UserSpecAgent
execution_planning_agent: ExecutionPlanningAgent
math_auditor_agent: MathAuditorAgent
pdf_comment_agent: PdfCommentAgent
document_classifier_agent: DocumentClassifierAgent
def build_app_state(
settings: AppSettings,
*,
documents: DocumentService | None = None,
fast_model: Model | None = None,
smart_model: Model | None = None,
embedder: EmbeddingService | None = None,
) -> AppState:
"""Build the runtime and every agent from ``settings``.
The keyword arguments are threaded straight to :func:`build_runtime` so a
config-push rebuild can reuse the live document store and inject already
validated models (and, on a cache-restore boot, a pre-built embedder); all
None reproduces the original startup path.
"""
runtime = build_runtime(
settings,
documents=documents,
fast_model=fast_model,
smart_model=smart_model,
embedder=embedder,
)
return AppState(
runtime=runtime,
orchestrator_agent=OrchestratorAgent(runtime),
pdf_edit_agent=PdfEditAgent(runtime),
pdf_question_agent=PdfQuestionAgent(runtime),
user_spec_agent=UserSpecAgent(runtime),
execution_planning_agent=ExecutionPlanningAgent(runtime),
math_auditor_agent=MathAuditorAgent(runtime),
pdf_comment_agent=PdfCommentAgent(runtime),
document_classifier_agent=DocumentClassifierAgent(runtime),
)
def apply_app_state(state: Any, app_state: AppState) -> None:
"""Copy every field of ``app_state`` onto a Starlette ``app.state`` object."""
for field in fields(app_state):
setattr(state, field.name, getattr(app_state, field.name))
@@ -1,5 +1,6 @@
from .agent_capabilities import router as agent_capabilities_router
from .agent_drafts import router as agent_draft_router
from .config import router as config_router
from .document_classifier import router as document_classifier_router
from .documents import router as document_router
from .execution import router as execution_router
@@ -12,6 +13,7 @@ from .pdf_questions import router as pdf_question_router
__all__ = [
"agent_capabilities_router",
"agent_draft_router",
"config_router",
"document_classifier_router",
"document_router",
"execution_router",
+251
View File
@@ -0,0 +1,251 @@
from __future__ import annotations
import ipaddress
import logging
from fastapi import APIRouter, HTTPException, Request, status
from openai import OpenAIError
from pydantic_ai.exceptions import UserError
from pydantic_ai.models import Model
from stirling.api.bootstrap import apply_app_state, build_app_state
from stirling.config import AppSettings
from stirling.config.config_cache import save_config
from stirling.contracts import ConfigApplyResponse, ConfigPushRequest
from stirling.documents import EmbeddingService
from stirling.services import AppRuntime
from stirling.services.runtime import _build_model, validate_structured_output_support
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/config", tags=["config"])
# Model/provider construction + validation failures. The HTTP route maps these to a
# 400 (no swap); boot catches them to fall back to env when a cached config is bad.
CONFIG_APPLY_ERRORS = (ValueError, UserError, OpenAIError)
_REINDEX_NOTE = (
"Embedding model changed; existing indexed documents were embedded with the previous model and "
"must be re-indexed. If the embedding dimensionality changed, re-ingest before searching."
)
def _strip_provider_prefix(model_name: str) -> str:
"""Drop a leading ``provider:`` from an env model string ("anthropic:x" -> "x").
Only used when falling back to the env-configured name under an explicit
pushed provider; a bare pushed name (which may itself contain ``:`` such as
"llama3:8b") is used verbatim and never passed through here.
"""
_, sep, rest = model_name.partition(":")
return rest if sep else model_name
def _compose_embedding_model(provider: str, model: str) -> str:
"""Compose the engine's ``provider:model`` embedding string from pushed parts."""
provider = provider.strip()
return f"{provider}:{model}" if provider else model
def _split_embedding_ref(ref: str) -> tuple[str, str]:
"""Split an env embedding string ("voyageai:voyage-4") into (provider, model)."""
provider, sep, model = ref.partition(":")
return (provider, model) if sep else ("", ref)
def _keep(pushed: int | None, current: int) -> int:
"""Return the pushed value, or the current one when the push omitted it."""
return pushed if pushed is not None else current
def _is_loopback_client(request: Request) -> bool:
"""True when the request originates from the local host (127.0.0.0/8, ::1)."""
client = request.client
if client is None:
return False
try:
return ipaddress.ip_address(client.host).is_loopback
except ValueError:
return client.host == "localhost"
def resolve_and_apply(
current: AppSettings,
request: ConfigPushRequest,
) -> tuple[AppSettings, Model, Model, EmbeddingService | None, list[str]]:
"""Resolve a pushed config against the running settings.
Builds and validates the smart/fast models, resolves the scalar overrides,
and builds a new embedder when the embedding config changed. Empty/None pushed
fields keep the current value. Returns the effective settings, the two built
models, an optional new embedder (None when the embedding config is unchanged),
and human-readable notes.
Raises one of :data:`CONFIG_APPLY_ERRORS` if a chosen model/embedder fails to
build or validate; callers decide whether that becomes a 400 (HTTP route) or an
env fallback (boot). It never swaps any live state - the caller owns that.
"""
models = request.models
rag = request.rag
limits = request.limits
notes: list[str] = []
provider = models.provider.strip()
api_key = models.api_key
base_url = models.base_url
use_explicit_provider = bool(provider or api_key or base_url)
if use_explicit_provider:
smart_name = models.smart_model or _strip_provider_prefix(current.smart_model_name)
fast_name = models.fast_model or _strip_provider_prefix(current.fast_model_name)
else:
# No provider/credentials pushed: keep the fully env-driven model strings.
smart_name = models.smart_model or current.smart_model_name
fast_name = models.fast_model or current.fast_model_name
def _build(bare: str) -> Model:
if use_explicit_provider:
return _build_model(bare, provider=provider or None, api_key=api_key or None, base_url=base_url or None)
return _build_model(bare)
smart_model = _build(smart_name)
fast_model = _build(fast_name)
validate_structured_output_support(smart_model, smart_name)
validate_structured_output_support(fast_model, fast_name)
# Scalars: None / empty keep the current value.
smart_max_tokens = _keep(models.smart_max_tokens, current.smart_model_max_tokens)
fast_max_tokens = _keep(models.fast_max_tokens, current.fast_model_max_tokens)
top_k = _keep(rag.top_k, current.rag_default_top_k)
max_searches = _keep(rag.max_searches, current.rag_max_searches)
max_pages = _keep(limits.max_pages, current.max_pages)
max_characters = _keep(limits.max_characters, current.max_characters)
model_max_concurrency = _keep(limits.model_max_concurrency, current.model_max_concurrency)
# Embedding: any non-empty embedding field triggers a rebuild; empty fields fall
# back to the running provider/model/creds so a partial push never clobbers env.
embedding_changed = bool(
rag.embedding_provider.strip() or rag.embedding_model.strip() or rag.embedding_api_key or rag.embedding_base_url
)
rag_embedding_model = current.rag_embedding_model
new_embedder: EmbeddingService | None = None
if embedding_changed:
current_provider, current_model = _split_embedding_ref(current.rag_embedding_model)
embed_provider = rag.embedding_provider.strip() or current_provider
embed_model = rag.embedding_model.strip() or current_model
rag_embedding_model = _compose_embedding_model(embed_provider, embed_model)
new_embedder = EmbeddingService(
model_name=embed_model,
chunk_size=current.rag_chunk_size,
chunk_overlap=current.rag_chunk_overlap,
provider=embed_provider or None,
api_key=rag.embedding_api_key or None,
base_url=rag.embedding_base_url or None,
)
notes.append(_REINDEX_NOTE)
effective = current.model_copy(
update={
"chat_provider": provider,
"smart_model_name": smart_name,
"fast_model_name": fast_name,
"smart_model_max_tokens": smart_max_tokens,
"fast_model_max_tokens": fast_max_tokens,
"rag_embedding_model": rag_embedding_model,
"rag_default_top_k": top_k,
"rag_max_searches": max_searches,
"max_pages": max_pages,
"max_characters": max_characters,
"model_max_concurrency": model_max_concurrency,
}
)
return effective, smart_model, fast_model, new_embedder, notes
@router.post("", response_model=ConfigApplyResponse)
async def apply_config(request: ConfigPushRequest, http_request: Request) -> ConfigApplyResponse:
"""Apply admin-pushed AI settings by rebuilding the runtime + agents in place.
Gated by the X-Engine-Auth shared secret (global middleware) plus the
``allow_config_push`` flag. Empty credential/model fields keep the engine's
env-configured values. Returns 403 when config push is disabled, 400 (without
swapping state) when a chosen model fails to build/validate. On success the
config is also persisted (encrypted) so it survives an engine restart.
"""
app = http_request.app
current: AppSettings = app.state.settings
if not current.allow_config_push:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Config push is disabled on this deployment (STIRLING_ALLOW_CONFIG_PUSH is false).",
)
# Secure-by-default for this sensitive endpoint. When a shared secret is set the
# global middleware has already authenticated the caller (only the processor has the
# secret). When NO secret is set, only trust a loopback caller - a remote party must
# never be able to push a config unauthenticated, since a pushed base_url/model could
# repoint the engine to exfiltrate document content.
if not current.engine_shared_secret and not _is_loopback_client(http_request):
client_host = http_request.client.host if http_request.client else "unknown"
logger.warning("Rejected config push from non-local caller %s with no shared secret set", client_host)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Config push from a non-local caller requires STIRLING_ENGINE_SHARED_SECRET"
" to be set on both the engine and the processor."
),
)
runtime: AppRuntime = app.state.runtime
try:
effective, smart_model, fast_model, new_embedder, notes = resolve_and_apply(current, request)
except CONFIG_APPLY_ERRORS as exc:
# Reject without touching the running config.
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
# Rebuild runtime + every agent, reusing the existing document store, then
# swap the whole bundle onto app.state so in-flight lookups see one config.
new_state = build_app_state(
effective,
documents=runtime.documents,
fast_model=fast_model,
smart_model=smart_model,
)
app.state.settings = effective
apply_app_state(app.state, new_state)
# Retune retrieval breadth on the reused store without rebuilding it.
runtime.documents.default_top_k = effective.rag_default_top_k
if new_embedder is not None:
# Swap the embedder onto the reused DocumentService so we never tear down
# the live vector store / connection pool.
runtime.documents.embedder = new_embedder
# Persist the applied config (encrypted) so it is restored on the next boot.
try:
save_config(request)
except OSError:
logger.warning("Applied AI config but failed to persist the encrypted cache", exc_info=True)
notes.append("Config applied but could not be persisted; it will not survive an engine restart.")
logger.info(
"Applied pushed AI config: provider=%s smart_model=%s fast_model=%s top_k=%s",
request.models.provider.strip() or "<env>",
effective.smart_model_name,
effective.fast_model_name,
effective.rag_default_top_k,
)
return ConfigApplyResponse(
status="applied",
provider=request.models.provider.strip(),
smart_model=effective.smart_model_name,
fast_model=effective.fast_model_name,
smart_max_tokens=effective.smart_model_max_tokens,
fast_max_tokens=effective.fast_model_max_tokens,
rag_embedding_model=effective.rag_embedding_model,
rag_top_k=effective.rag_default_top_k,
rag_max_searches=effective.rag_max_searches,
max_pages=effective.max_pages,
max_characters=effective.max_characters,
model_max_concurrency=effective.model_max_concurrency,
notes=notes,
)
+114
View File
@@ -0,0 +1,114 @@
"""Persistent, encrypted cache of the last-applied config-push.
The engine otherwise holds pushed config in RAM only, so a restart reverts to the
env values until the processor pushes again. We persist the last-applied
:class:`ConfigPushRequest` to ``<engine data dir>/ai_config_cache.enc`` (Fernet
encrypted) and reload it on boot so the effective config survives restarts.
Key derivation: when ``STIRLING_ENGINE_SHARED_SECRET`` is set, the Fernet key is
HKDF-SHA256 over that secret (constant salt/info) so no key material touches disk.
Otherwise a random Fernet key is generated once and stored in a sibling
``ai_config_cache.key`` (0600, best-effort) with a one-time warning.
"""
from __future__ import annotations
import base64
import logging
import os
import stat
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from stirling.config.settings import ENGINE_ROOT, load_settings
from stirling.contracts import ConfigPushRequest
logger = logging.getLogger(__name__)
_CACHE_FILENAME = "ai_config_cache.enc"
_KEY_FILENAME = "ai_config_cache.key"
# Constant salt/info so the same shared secret always derives the same Fernet key.
_HKDF_SALT = b"stirling-ai-config-cache/v1/salt"
_HKDF_INFO = b"stirling-ai-config-cache/v1/fernet-key"
_keyfile_warned = False
def _default_data_dir() -> Path:
"""The engine data dir (where the sqlite store lives by default)."""
return ENGINE_ROOT / "data"
def _shared_secret() -> str:
return load_settings().engine_shared_secret
def _derive_key_from_secret(secret: str) -> bytes:
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=_HKDF_SALT, info=_HKDF_INFO)
return base64.urlsafe_b64encode(hkdf.derive(secret.encode("utf-8")))
def _chmod_600(path: Path) -> None:
# Best-effort owner-only perms; silently ignored where unsupported (e.g. Windows).
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
def _load_or_create_keyfile(data_dir: Path) -> bytes:
global _keyfile_warned
key_path = data_dir / _KEY_FILENAME
if key_path.exists():
return key_path.read_bytes().strip()
key = Fernet.generate_key()
data_dir.mkdir(parents=True, exist_ok=True)
key_path.write_bytes(key)
_chmod_600(key_path)
if not _keyfile_warned:
logger.warning(
"STIRLING_ENGINE_SHARED_SECRET is not set; encrypting the AI config cache with a local"
" keyfile at %s (0600, best-effort). This is modest protection only - set a shared secret"
" for HKDF key derivation in any deployment where the cache must be strongly protected.",
key_path,
)
_keyfile_warned = True
return key
def _fernet(data_dir: Path) -> Fernet:
secret = _shared_secret()
if secret:
return Fernet(_derive_key_from_secret(secret))
return Fernet(_load_or_create_keyfile(data_dir))
def save_config(request: ConfigPushRequest, *, data_dir: Path | None = None) -> None:
"""Encrypt and persist the last-applied pushed config, overwriting any prior file."""
data_dir = data_dir or _default_data_dir()
data_dir.mkdir(parents=True, exist_ok=True)
payload = request.model_dump_json(by_alias=True).encode("utf-8")
token = _fernet(data_dir).encrypt(payload)
(data_dir / _CACHE_FILENAME).write_bytes(token)
def load_config(*, data_dir: Path | None = None) -> ConfigPushRequest | None:
"""Load + decrypt the persisted pushed config.
Returns None (never raises) when the cache is absent, corrupt, or was written
under a different key, so a bad cache can never crash boot.
"""
data_dir = data_dir or _default_data_dir()
cache_path = data_dir / _CACHE_FILENAME
if not cache_path.exists():
return None
try:
token = cache_path.read_bytes()
payload = _fernet(data_dir).decrypt(token)
return ConfigPushRequest.model_validate_json(payload)
except (InvalidToken, ValueError, OSError) as exc:
logger.warning("Ignoring unreadable AI config cache at %s: %s", cache_path, exc)
return None
+11
View File
@@ -25,6 +25,12 @@ class AppSettings(BaseSettings):
smart_model_name: str = Field(validation_alias="STIRLING_SMART_MODEL")
fast_model_name: str = Field(validation_alias="STIRLING_FAST_MODEL")
# Provider backing the active chat models. Empty for env/native providers; set to
# 'ollama'/'custom' by a config push. Agents that combine structured output with
# tools read this to pick a tool-compatible output strategy, because OpenAI-compatible
# local models (Ollama) block tool calls when a native json-schema response format is
# set, so their structured result must be delivered via a tool call instead.
chat_provider: str = Field(default="")
smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS")
fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS")
# Process-wide ceiling on concurrent model API calls, shared by both model
@@ -123,6 +129,11 @@ class AppSettings(BaseSettings):
engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET")
engine_require_auth: bool = Field(default=False, validation_alias="STIRLING_ENGINE_REQUIRE_AUTH")
# When true, the Java processor may push admin-configured AI settings (model,
# credentials, limits) to POST /api/v1/config at startup. Turn this off in
# environment-driven deployments so the environment is the single source of truth.
allow_config_push: bool = Field(default=True, validation_alias="STIRLING_ALLOW_CONFIG_PUSH")
def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None:
"""Configure the ``stirling`` logger hierarchy."""
+12
View File
@@ -29,6 +29,13 @@ from .common import (
format_conversation_history,
format_file_names,
)
from .config import (
ConfigApplyResponse,
ConfigLimitsSection,
ConfigModelsSection,
ConfigPushRequest,
ConfigRagSection,
)
from .contradiction import (
Claim,
Contradiction,
@@ -139,6 +146,11 @@ __all__ = [
"Claim",
"CommentSpec",
"CompletedExecutionAction",
"ConfigApplyResponse",
"ConfigLimitsSection",
"ConfigModelsSection",
"ConfigPushRequest",
"ConfigRagSection",
"Contradiction",
"ContradictionReport",
"ContradictionSeverity",
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from pydantic import ConfigDict, Field
from stirling.models import ApiModel
# Tolerate unknown fields so a newer processor pushing a field this engine version
# doesn't know is ignored rather than rejecting the whole push. Overrides only the
# ``extra`` policy from ApiModel; the camelCase alias generator is inherited.
_TOLERANT = ConfigDict(extra="ignore")
class ConfigModelsSection(ApiModel):
"""Model provider + credentials pushed by the Java processor.
Field names on the wire are camelCase (``smartModel`` etc.) via the
:class:`ApiModel` alias generator. Empty ``provider``/``apiKey``/``baseUrl``
or empty model names mean "keep the engine's env-configured value".
"""
model_config = _TOLERANT
provider: str = ""
smart_model: str = ""
fast_model: str = ""
smart_max_tokens: int | None = None
fast_max_tokens: int | None = None
api_key: str = ""
base_url: str = ""
class ConfigRagSection(ApiModel):
model_config = _TOLERANT
embedding_provider: str = ""
embedding_model: str = ""
embedding_api_key: str = ""
# OpenAI-compatible endpoint URL for ollama/custom embedding providers.
# Empty keeps the engine's env value, same convention as the other fields.
embedding_base_url: str = ""
top_k: int | None = None
max_searches: int | None = None
class ConfigLimitsSection(ApiModel):
model_config = _TOLERANT
max_pages: int | None = None
max_characters: int | None = None
model_max_concurrency: int | None = None
class ConfigPushRequest(ApiModel):
"""Admin-configured AI settings pushed at processor startup."""
model_config = _TOLERANT
models: ConfigModelsSection = Field(default_factory=ConfigModelsSection)
rag: ConfigRagSection = Field(default_factory=ConfigRagSection)
limits: ConfigLimitsSection = Field(default_factory=ConfigLimitsSection)
class ConfigApplyResponse(ApiModel):
"""Summary of the effective config after a push. Never echoes credentials."""
status: str
provider: str
smart_model: str
fast_model: str
smart_max_tokens: int
fast_max_tokens: int
rag_embedding_model: str
rag_top_k: int
rag_max_searches: int
max_pages: int
max_characters: int
model_max_concurrency: int
notes: list[str] = Field(default_factory=list)
+8 -6
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 assembled document.
CREATE_PDF_FROM_HTML_AGENT plan step carrying the rendered HTML.
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. The document is emitted as structured fields. The LLM never writes HTML.
5. Jinja renders GeneratedDocument HTML. The LLM never writes HTML.
"""
from __future__ import annotations
@@ -81,12 +81,14 @@ type DocumentSection = Annotated[
]
# 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}$")
# 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}$")
class DocumentStyle(ApiModel):
"""Document colours, inferred by the meta planner. Non-hex values are dropped to ``None``."""
"""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``."""
primary_color: str | None = Field(default=None)
background_color: str | None = Field(default=None)
@@ -101,7 +103,7 @@ class DocumentStyle(ApiModel):
class GeneratedDocument(ApiModel):
"""The full document model emitted for rendering."""
"""The full document model passed to Jinja for HTML rendering."""
title: str
subtitle: str | None = None
+38 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from pydantic_ai import Embedder
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
from pydantic_ai.providers.openai import OpenAIProvider
from stirling.documents.chunker import chunk_text
from stirling.documents.store import Document
@@ -12,6 +14,37 @@ from stirling.documents.store import Document
DEFAULT_EMBED_BATCH_SIZE = 256
def _build_embedder(
model_name: str,
*,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> Embedder:
"""Construct an :class:`Embedder` for ``model_name``.
With no explicit ``provider``/``api_key``/``base_url`` this keeps the original
behaviour: ``model_name`` is the ``provider:model`` string form (e.g.
"voyageai:voyage-4") and credentials come from the provider's native env var.
With an explicit provider (config-push path) ``model_name`` is the bare model
without a prefix. ``voyageai``/``openai`` compose the same string form; the
OpenAI-compatible ``ollama``/``custom`` build an OpenAI embedding model against
``base_url`` (Ollama ignores the key but the SDK still needs a non-empty one).
"""
if not provider and not api_key and not base_url:
return Embedder(model_name)
provider_name = (provider or "").lower()
key = api_key or None
if provider_name in ("voyageai", "openai"):
return Embedder(f"{provider_name}:{model_name}")
if provider_name in ("ollama", "custom"):
openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama")
return Embedder(OpenAIEmbeddingModel(model_name, provider=openai_provider))
raise ValueError(f"Unsupported embedding provider {provider!r}.")
class EmbeddingService:
"""Wraps Pydantic AI's Embedder to provide document chunking and embedding."""
@@ -21,8 +54,12 @@ class EmbeddingService:
chunk_size: int = 512,
chunk_overlap: int = 64,
embed_batch_size: int = DEFAULT_EMBED_BATCH_SIZE,
*,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> None:
self._embedder = Embedder(model_name)
self._embedder = _build_embedder(model_name, provider=provider, api_key=api_key, base_url=base_url)
self._chunk_size = chunk_size
self._chunk_overlap = chunk_overlap
self._embed_batch_size = embed_batch_size
+19
View File
@@ -43,6 +43,25 @@ class DocumentService:
self._store = store
self._default_top_k = default_top_k
@property
def default_top_k(self) -> int:
return self._default_top_k
@default_top_k.setter
def default_top_k(self, value: int) -> None:
# Lets a config-push retune retrieval breadth without rebuilding the store.
self._default_top_k = value
@property
def embedder(self) -> EmbeddingService:
return self._embedder
@embedder.setter
def embedder(self, value: EmbeddingService) -> None:
# Lets a config-push swap the embedding model while keeping the live store
# (and its connection pool). Existing vectors need re-indexing afterwards.
self._embedder = value
async def ingest(
self,
collection: FileId,
@@ -29,7 +29,7 @@ class PdfCommentAgentParams(ApiModel):
class CreatePdfFromHtmlAgentParams(ApiModel):
document: str
html_content: str
filename: str = Field(pattern=r"^.+\.pdf$")
+156 -27
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
@@ -12,8 +14,10 @@ from pydantic_ai import RunContext
from pydantic_ai.messages import ModelMessage, ModelResponse
from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse, infer_model
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.models.wrapper import WrapperModel
from pydantic_ai.providers.anthropic import AnthropicProvider
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.settings import ModelSettings
from stirling.config import ENGINE_ROOT, AppSettings, DocumentsBackend
@@ -49,6 +53,79 @@ def _build_anthropic_http_client() -> httpx.AsyncClient:
)
# Placeholder used when no Anthropic key is configured; lets the engine boot so a
# deployment that only uses another provider (e.g. Ollama via the admin UI / config
# push) isn't blocked. Anthropic calls then fail with a clear 401 instead.
_UNCONFIGURED_ANTHROPIC_KEY = "unconfigured"
_warned_missing_anthropic_key = False
def _anthropic_provider(explicit_key: str | None = None) -> AnthropicProvider:
"""Build the Anthropic provider, tolerating a missing key at startup.
pydantic-ai's ``AnthropicProvider`` raises at construction when no key is
available, which would crash the whole engine at boot even for deployments
that never call Anthropic (Ollama/OpenAI-configured, or awaiting a config
push that supplies credentials). We fall back to a placeholder key and warn
once so startup succeeds; the placeholder only surfaces as a clear
provider-side auth error if an Anthropic call is actually made.
"""
http_client = _build_anthropic_http_client()
key = explicit_key or os.environ.get("ANTHROPIC_API_KEY")
if key:
return AnthropicProvider(api_key=key, http_client=http_client)
global _warned_missing_anthropic_key
if not _warned_missing_anthropic_key:
_warned_missing_anthropic_key = True
logger.warning(
"ANTHROPIC_API_KEY is not set - the engine will start, but Anthropic model "
"calls will fail until a key is provided or a different provider is configured "
"(admin AI settings / config push)."
)
return AnthropicProvider(api_key=_UNCONFIGURED_ANTHROPIC_KEY, http_client=http_client)
class _NullContentCoercingTransport(httpx.AsyncHTTPTransport):
"""Coerce assistant ``content: null`` to ``""`` in outgoing OpenAI chat requests.
OpenAI's schema uses ``content: null`` for assistant messages that carry only
tool calls, but Ollama's OpenAI-compatible endpoint rejects it ("invalid message
content type: <nil>"), which intermittently breaks multi-turn tool conversations
(RAG search + tool-delivered output). Rewriting null -> "" makes them acceptable
without changing semantics. Scoped to the ollama/custom clients only.
"""
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
if request.headers.get("content-type", "").startswith("application/json") and request.content:
try:
body = json.loads(request.content)
except ValueError:
return await super().handle_async_request(request)
messages = body.get("messages")
if isinstance(messages, list):
changed = False
for message in messages:
if isinstance(message, dict) and message.get("content", "") is None:
message["content"] = ""
changed = True
if changed:
new_body = json.dumps(body).encode("utf-8")
headers = [(k, v) for k, v in request.headers.raw if k.lower() != b"content-length"]
request = httpx.Request(
method=request.method,
url=request.url,
headers=headers,
content=new_body,
extensions=request.extensions,
)
return await super().handle_async_request(request)
def _openai_compat_http_client() -> httpx.AsyncClient:
"""httpx client for Ollama/custom OpenAI-compatible endpoints (null-content fix)."""
return httpx.AsyncClient(transport=_NullContentCoercingTransport())
class ConcurrencyLimitedModel(WrapperModel):
"""Caps in-flight model API calls with a semaphore shared across the process."""
@@ -131,45 +208,97 @@ def _build_document_store(settings: AppSettings) -> DocumentStore:
assert_never(settings.documents_backend)
def _build_documents(settings: AppSettings) -> DocumentService:
"""Build the document service used by per-request RAG capabilities."""
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
embedder = EmbeddingService(
model_name=settings.rag_embedding_model,
chunk_size=settings.rag_chunk_size,
chunk_overlap=settings.rag_chunk_overlap,
)
def _build_documents(settings: AppSettings, embedder: EmbeddingService | None = None) -> DocumentService:
"""Build the document service used by per-request RAG capabilities.
``embedder`` lets a cache-restore boot inject an already-built embedder (e.g.
an OpenAI-compatible ollama/custom one whose base_url the plain
``settings.rag_embedding_model`` string can't encode); None builds from settings.
"""
if embedder is None:
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
embedder = EmbeddingService(
model_name=settings.rag_embedding_model,
chunk_size=settings.rag_chunk_size,
chunk_overlap=settings.rag_chunk_overlap,
)
store = _build_document_store(settings)
return DocumentService(embedder=embedder, store=store, default_top_k=settings.rag_default_top_k)
def build_runtime(settings: AppSettings) -> AppRuntime:
fast_model = _build_model(settings.fast_model_name)
smart_model = _build_model(settings.smart_model_name)
validate_structured_output_support(fast_model, settings.fast_model_name)
validate_structured_output_support(smart_model, settings.smart_model_name)
def build_runtime(
settings: AppSettings,
*,
documents: DocumentService | None = None,
fast_model: Model | None = None,
smart_model: Model | None = None,
embedder: EmbeddingService | None = None,
) -> AppRuntime:
"""Assemble the shared runtime.
``documents``/``fast_model``/``smart_model`` let a config-push rebuild reuse
the live vector-store connection pool and inject already-built (and already
validated) models instead of reconstructing them from the model-name strings.
``embedder`` injects a pre-built embedder when building a fresh document store
(cache-restore boot); it is ignored when ``documents`` is supplied. All None
(the lifespan path) preserves the original startup behaviour.
"""
fast = fast_model if fast_model is not None else _build_model(settings.fast_model_name)
smart = smart_model if smart_model is not None else _build_model(settings.smart_model_name)
validate_structured_output_support(fast, settings.fast_model_name)
validate_structured_output_support(smart, settings.smart_model_name)
# One semaphore across both tiers: the cap protects the provider account
# and process resources, which the tiers share.
model_semaphore = asyncio.Semaphore(settings.model_max_concurrency)
return AppRuntime(
settings=settings,
fast_model=ConcurrencyLimitedModel(fast_model, model_semaphore),
smart_model=ConcurrencyLimitedModel(smart_model, model_semaphore),
documents=_build_documents(settings),
fast_model=ConcurrencyLimitedModel(fast, model_semaphore),
smart_model=ConcurrencyLimitedModel(smart, model_semaphore),
documents=documents if documents is not None else _build_documents(settings, embedder),
)
def _build_model(model_name: str) -> Model:
"""Construct a model, injecting our keepalive-free httpx client for
Anthropic models so workers don't pick up stale pooled connections.
def _build_model(
model_name: str,
*,
provider: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> Model:
"""Construct a model for ``model_name``.
Other providers fall back to ``infer_model`` defaults; the stale-pool
issue is specific to the Cloudflare-fronted Anthropic API in our
observations and the fix doesn't necessarily apply elsewhere.
With no explicit ``provider``/``api_key``/``base_url`` this keeps the original
env-driven behaviour: Anthropic-prefixed names get our keepalive-free httpx
client (so workers don't pick up stale pooled connections) and everything
else falls back to ``infer_model``.
When the caller passes an explicit provider (config-push path), ``model_name``
is the bare model without a ``provider:`` prefix and credentials come from the
call. An empty ``api_key``/``base_url`` is treated as unset so the provider
reads its native environment variable (e.g. ANTHROPIC_API_KEY / OPENAI_API_KEY).
"""
if model_name.startswith("anthropic:"):
bare_name = model_name.removeprefix("anthropic:")
provider = AnthropicProvider(http_client=_build_anthropic_http_client())
return AnthropicModel(bare_name, provider=provider)
return infer_model(model_name)
if not provider and not api_key and not base_url:
if model_name.startswith("anthropic:"):
bare_name = model_name.removeprefix("anthropic:")
return AnthropicModel(bare_name, provider=_anthropic_provider())
return infer_model(model_name)
provider_name = (provider or "").lower()
key = api_key or None
if provider_name == "anthropic":
return AnthropicModel(model_name, provider=_anthropic_provider(key))
if provider_name == "openai":
openai_provider = OpenAIProvider(api_key=key) if key else OpenAIProvider()
return OpenAIChatModel(model_name, provider=openai_provider)
if provider_name in ("ollama", "custom"):
# OpenAI-compatible endpoint. Ollama ignores the key but the SDK still
# requires a non-empty one, so default to a harmless placeholder. The custom
# http client coerces null assistant content so multi-turn tool calls work.
openai_provider = OpenAIProvider(
base_url=base_url or None,
api_key=key or "ollama",
http_client=_openai_compat_http_client(),
)
return OpenAIChatModel(model_name, provider=openai_provider)
raise ValueError(f"Unsupported model provider {provider!r}.")
+148 -29
View File
@@ -2,7 +2,7 @@
Coverage:
1. Section model validation (each section type round-trips correctly)
2. orchestrate() emits the assembled document as structured JSON
2. Jinja rendering (_render produces valid HTML for each section type)
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,8 +12,6 @@ Coverage:
from __future__ import annotations
import json
import pytest
from conftest import build_app_settings
from pydantic_ai.models.test import TestModel
@@ -66,6 +64,37 @@ 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",
@@ -170,6 +199,82 @@ 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 ────────────────────────────────────────────────────────────────────────────────
@@ -291,9 +396,8 @@ 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")
parsed = json.loads(step.parameters.document)
assert parsed["title"] == "Invoice"
assert parsed["sections"]
assert "<!DOCTYPE html>" in step.parameters.html_content
assert "Invoice" in step.parameters.html_content
@pytest.mark.anyio
@@ -364,9 +468,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)
document = result.steps[0].parameters.document # type: ignore[union-attr]
assert "Introduction text." in document
assert "Details" in document
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
assert "Introduction text." in html
assert "Details" in html
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
@@ -378,7 +482,7 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
meta = DocumentMeta(
title="Styled Doc",
tone_brief="Professional.",
style_primary_color="#ff00ff",
style_primary_color="magenta",
)
sections = _simple_sections()
written = _written_sections()
@@ -395,35 +499,50 @@ 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)
document = result.steps[0].parameters.document # type: ignore[union-attr]
assert json.loads(document)["style"]["primaryColor"] == "#ff00ff"
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
assert "magenta" in html
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")
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")
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
"navy",
"#1e3a5f",
"#ffffff",
"#1A1A1A",
"#fff",
)
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
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
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
assert DocumentStyle(primary_color="#1e3a5f\n").primary_color is None
assert DocumentStyle(primary_color="navy\n").primary_color is None
@pytest.mark.anyio
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."""
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
meta = DocumentMeta(
title="Doc",
tone_brief="Professional.",
style_primary_color="rgb(0, 0, 255)",
style_primary_color="blue; background: url(http://evil.test/)",
)
sections = _simple_sections()
written = _written_sections()
@@ -440,6 +559,6 @@ async def test_orchestrate_drops_non_hex_planner_colour(agent: PdfCreateAgent) -
result = await agent.orchestrate(_orchestrator_request("make it blue"))
assert isinstance(result, EditPlanResponse)
document = result.steps[0].parameters.document # type: ignore[union-attr]
assert "rgb(" not in document
assert json.loads(document)["style"]["primaryColor"] is None
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
assert "evil.test" not in html
assert "url(" not in html
+24
View File
@@ -0,0 +1,24 @@
{
"models": {
"provider": "ollama",
"smartModel": "smart-model-x",
"fastModel": "fast-model-x",
"smartMaxTokens": 1111,
"fastMaxTokens": 2222,
"apiKey": "provider-key-abc",
"baseUrl": "http://engine.example/v1"
},
"rag": {
"embeddingProvider": "custom",
"embeddingModel": "embed-model-x",
"embeddingApiKey": "embed-key-abc",
"embeddingBaseUrl": "http://embed.example/v1",
"topK": 33,
"maxSearches": 7
},
"limits": {
"maxPages": 111,
"maxCharacters": 222222,
"modelMaxConcurrency": 9
}
}
+73
View File
@@ -0,0 +1,73 @@
"""Tests for the encrypted config cache (stirling.config.config_cache)."""
from __future__ import annotations
from pathlib import Path
import pytest
from stirling.config import config_cache
from stirling.contracts import ConfigPushRequest
def _sample() -> ConfigPushRequest:
return ConfigPushRequest.model_validate(
{
"models": {
"provider": "anthropic",
"smartModel": "claude-haiku-4-5",
"fastModel": "claude-haiku-4-5",
"smartMaxTokens": 8192,
"fastMaxTokens": 2048,
"apiKey": "secret-key-value",
"baseUrl": "",
},
"rag": {
"embeddingProvider": "voyageai",
"embeddingModel": "voyage-4",
"embeddingApiKey": "embed-secret",
"embeddingBaseUrl": "",
"topK": 20,
"maxSearches": 5,
},
"limits": {"maxPages": 200, "maxCharacters": 200000, "modelMaxConcurrency": 32},
}
)
def test_roundtrip_with_shared_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "the-shared-secret")
req = _sample()
config_cache.save_config(req, data_dir=tmp_path)
# HKDF-from-secret path: no keyfile is written.
assert not (tmp_path / "ai_config_cache.key").exists()
assert config_cache.load_config(data_dir=tmp_path) == req
# Secrets are encrypted at rest.
assert b"secret-key-value" not in (tmp_path / "ai_config_cache.enc").read_bytes()
def test_roundtrip_with_keyfile_when_no_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "")
req = _sample()
config_cache.save_config(req, data_dir=tmp_path)
# No shared secret: a random keyfile is generated and reused for decrypt.
assert (tmp_path / "ai_config_cache.key").exists()
assert config_cache.load_config(data_dir=tmp_path) == req
def test_load_missing_returns_none(tmp_path: Path) -> None:
assert config_cache.load_config(data_dir=tmp_path) is None
def test_load_corrupt_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "a-secret")
(tmp_path / "ai_config_cache.enc").write_bytes(b"not-a-valid-fernet-token")
assert config_cache.load_config(data_dir=tmp_path) is None
def test_wrong_key_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "secret-a")
config_cache.save_config(_sample(), data_dir=tmp_path)
# A different secret derives a different key -> decrypt fails, returns None.
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "secret-b")
assert config_cache.load_config(data_dir=tmp_path) is None
+80
View File
@@ -0,0 +1,80 @@
"""Wire-contract test for the processor -> engine config push.
The Java processor (AiEngineConfigSync.buildConfigNode) serialises the admin AI settings as
camelCase JSON and POSTs them to /api/v1/config. Because ConfigPushRequest uses extra="ignore"
(so version skew never 422s), a field the engine can no longer map is silently dropped to its
default instead of erroring. This test pins the exact camelCase contract: every field in the
shared fixture must round-trip onto the model, so a rename on either side fails loudly.
The same fixture (tests/fixtures/processor_config_push.json) is the contract the Java
AiEngineConfigSyncTest asserts its serialiser produces - keep the two in sync.
"""
from __future__ import annotations
import json
from pathlib import Path
from stirling.contracts import ConfigPushRequest
FIXTURE = Path(__file__).parent / "fixtures" / "processor_config_push.json"
def _load() -> dict:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
def test_processor_contract_round_trips_every_field() -> None:
payload = _load()
req = ConfigPushRequest.model_validate(payload)
m = payload["models"]
assert req.models.provider == m["provider"]
assert req.models.smart_model == m["smartModel"]
assert req.models.fast_model == m["fastModel"]
assert req.models.smart_max_tokens == m["smartMaxTokens"]
assert req.models.fast_max_tokens == m["fastMaxTokens"]
assert req.models.api_key == m["apiKey"]
assert req.models.base_url == m["baseUrl"]
r = payload["rag"]
assert req.rag.embedding_provider == r["embeddingProvider"]
assert req.rag.embedding_model == r["embeddingModel"]
assert req.rag.embedding_api_key == r["embeddingApiKey"]
assert req.rag.embedding_base_url == r["embeddingBaseUrl"]
assert req.rag.top_k == r["topK"]
assert req.rag.max_searches == r["maxSearches"]
limits = payload["limits"]
assert req.limits.max_pages == limits["maxPages"]
assert req.limits.max_characters == limits["maxCharacters"]
assert req.limits.model_max_concurrency == limits["modelMaxConcurrency"]
def test_processor_contract_has_no_unmapped_keys() -> None:
"""Guard the fixture itself: every wire key must map to a model field (none silently
absorbed by extra="ignore"). If the processor adds a field, extend the contract here."""
payload = _load()
expected = {
"models": {
"provider",
"smartModel",
"fastModel",
"smartMaxTokens",
"fastMaxTokens",
"apiKey",
"baseUrl",
},
"rag": {
"embeddingProvider",
"embeddingModel",
"embeddingApiKey",
"embeddingBaseUrl",
"topK",
"maxSearches",
},
"limits": {"maxPages", "maxCharacters", "modelMaxConcurrency"},
}
assert set(payload) == set(expected)
for section, keys in expected.items():
assert set(payload[section]) == keys
+249
View File
@@ -0,0 +1,249 @@
"""Tests for the config-push endpoint (POST /api/v1/config).
These drive the real lifespan (via ``with TestClient(app)``) so ``app.state`` is
populated, then exercise the gate + model-swap behaviour against the in-memory
"test" runtime built by :func:`build_app_settings`.
"""
from __future__ import annotations
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
import pytest
from conftest import build_app_settings
from fastapi.testclient import TestClient
from stirling.api import app
from stirling.config import AppSettings, config_cache, load_settings
from stirling.contracts import ConfigPushRequest
@pytest.fixture(autouse=True)
def _isolate_config_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Point the encrypted config cache at a per-test tmp dir so persistence never
touches the real engine/data dir or leaks between tests."""
monkeypatch.setattr(config_cache, "_default_data_dir", lambda: tmp_path)
@contextmanager
def _client(
settings_factory: Callable[[], AppSettings],
*,
client_addr: tuple[str, int] = ("127.0.0.1", 12345),
) -> Iterator[TestClient]:
"""Enter a TestClient whose lifespan builds app.state from ``settings_factory``.
Defaults to a loopback client address so the config endpoint's local-caller gate
(which blocks remote unauthenticated pushes) allows the request through.
"""
previous = app.dependency_overrides.get(load_settings)
app.dependency_overrides[load_settings] = settings_factory
try:
with TestClient(app, client=client_addr) as client:
yield client
finally:
if previous is None:
app.dependency_overrides.pop(load_settings, None)
else:
app.dependency_overrides[load_settings] = previous
def _anthropic_push() -> dict[str, object]:
return {
"models": {
"provider": "anthropic",
"smartModel": "claude-haiku-4-5",
"fastModel": "claude-haiku-4-5",
"smartMaxTokens": 4096,
"fastMaxTokens": 1024,
"apiKey": "test-key-not-a-real-secret",
"baseUrl": "",
},
"rag": {
"embeddingProvider": "",
"embeddingModel": "",
"embeddingApiKey": "",
"embeddingBaseUrl": "",
"topK": 7,
"maxSearches": 3,
},
"limits": {"maxPages": 50, "maxCharacters": 12345, "modelMaxConcurrency": 8},
}
def test_config_push_forbidden_when_disabled() -> None:
def factory() -> AppSettings:
return build_app_settings().model_copy(update={"allow_config_push": False})
with _client(factory) as client:
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 403
def test_config_push_from_non_local_caller_without_secret_returns_403() -> None:
"""Secure-by-default: with no shared secret, a remote caller cannot push a config."""
with _client(build_app_settings, client_addr=("203.0.113.9", 4444)) as client:
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 403
assert "STIRLING_ENGINE_SHARED_SECRET" in response.json()["detail"]
def test_config_push_applies_model_and_limits() -> None:
with _client(build_app_settings) as client:
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 200
body = response.json()
# Wire summary is camelCase and never echoes the api key.
assert body["smartModel"] == "claude-haiku-4-5"
assert body["fastModel"] == "claude-haiku-4-5"
assert body["smartMaxTokens"] == 4096
assert body["ragTopK"] == 7
assert body["maxPages"] == 50
assert body["modelMaxConcurrency"] == 8
assert "test-key-not-a-real-secret" not in response.text
# State was swapped: the running settings now reflect the push.
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
assert app.state.settings.max_pages == 50
assert app.state.runtime.documents.default_top_k == 7
def test_config_push_unsupported_provider_returns_400_without_swap() -> None:
with _client(build_app_settings) as client:
before = app.state.runtime
payload = _anthropic_push()
payload["models"]["provider"] = "nonsense-provider" # type: ignore[index]
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 400
# Running runtime is untouched when the push is rejected.
assert app.state.runtime is before
assert app.state.settings.smart_model_name == "test"
def test_config_push_unsupported_model_returns_400() -> None:
"""A model that fails structured-output validation is rejected with 400."""
with _client(build_app_settings) as client:
before = app.state.runtime
with patch(
"stirling.api.routes.config.validate_structured_output_support",
side_effect=ValueError("Unsupported model foo. This model does not support structured outputs."),
):
response = client.post("/api/v1/config", json=_anthropic_push())
assert response.status_code == 400
assert "does not support structured outputs" in response.json()["detail"]
assert app.state.runtime is before
def test_config_push_ollama_embedding_rebuilds_embedder() -> None:
"""A pushed ollama/custom embedding provider builds an OpenAI-compatible
embedder and swaps it onto the reused DocumentService without tearing down
the store; the response carries a re-index note."""
with _client(build_app_settings) as client:
store_before = app.state.runtime.documents
embedder_before = app.state.runtime.documents.embedder
payload = _anthropic_push()
payload["rag"] = {
"embeddingProvider": "ollama",
"embeddingModel": "nomic-embed-text",
"embeddingApiKey": "",
"embeddingBaseUrl": "http://localhost:11434/v1",
"topK": 9,
"maxSearches": 4,
}
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 200
body = response.json()
assert body["ragEmbeddingModel"] == "ollama:nomic-embed-text"
assert any("re-index" in note for note in body["notes"])
docs = app.state.runtime.documents
# Same store object reused (connection pool intact), embedder swapped.
assert docs is store_before
assert docs.embedder is not embedder_before
assert docs.default_top_k == 9
def test_config_push_unsupported_embedding_provider_returns_400() -> None:
with _client(build_app_settings) as client:
embedder_before = app.state.runtime.documents.embedder
payload = _anthropic_push()
payload["rag"] = {
"embeddingProvider": "totally-bogus",
"embeddingModel": "x",
"embeddingApiKey": "",
"embeddingBaseUrl": "",
"topK": None,
"maxSearches": None,
}
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 400
# Embedder untouched when the push is rejected.
assert app.state.runtime.documents.embedder is embedder_before
def test_config_push_empty_models_keep_env_value() -> None:
"""An empty models block keeps the engine's current (env) models but still
applies pushed limits."""
with _client(build_app_settings) as client:
payload = _anthropic_push()
payload["models"] = {
"provider": "",
"smartModel": "",
"fastModel": "",
"smartMaxTokens": None,
"fastMaxTokens": None,
"apiKey": "",
"baseUrl": "",
}
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 200
# Env "test" model preserved for both tiers; limits still applied.
assert app.state.settings.smart_model_name == "test"
assert app.state.settings.fast_model_name == "test"
assert app.state.settings.max_pages == 50
def test_config_push_ignores_unknown_fields() -> None:
"""A newer processor pushing fields this engine doesn't know must not 422;
unknown fields are ignored and the rest of the push still applies."""
with _client(build_app_settings) as client:
payload = _anthropic_push()
payload["futureTopLevelField"] = {"anything": 1}
payload["models"]["experimentalFlag"] = True # type: ignore[index]
response = client.post("/api/v1/config", json=payload)
assert response.status_code == 200
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
assert app.state.settings.max_pages == 50
def test_boot_restores_cached_config() -> None:
"""A persisted config is decrypted and applied on boot, overriding env."""
config_cache.save_config(ConfigPushRequest.model_validate(_anthropic_push()))
with _client(build_app_settings):
# Env model is "test"; the cache pushed claude-haiku-4-5 + limits.
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
assert app.state.settings.fast_model_name == "claude-haiku-4-5"
assert app.state.settings.max_pages == 50
assert app.state.settings.smart_model_max_tokens == 4096
assert app.state.runtime.documents.default_top_k == 7
def test_boot_ignores_cache_when_push_disabled() -> None:
"""With allow_config_push false, env wins and the cache is ignored."""
config_cache.save_config(ConfigPushRequest.model_validate(_anthropic_push()))
def factory() -> AppSettings:
return build_app_settings().model_copy(update={"allow_config_push": False})
with _client(factory):
assert app.state.settings.smart_model_name == "test"
assert app.state.settings.max_pages == 200
def test_boot_proceeds_on_corrupt_cache(tmp_path: Path) -> None:
"""A corrupt cache file is ignored and boot falls back to env, never crashing."""
(tmp_path / "ai_config_cache.enc").write_bytes(b"not-a-valid-fernet-token")
with _client(build_app_settings):
assert app.state.settings.smart_model_name == "test"
assert app.state.settings.max_pages == 200
+4
View File
@@ -603,7 +603,9 @@ name = "engine"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "jinja2" },
{ name = "opentelemetry-sdk" },
{ name = "pgvector" },
{ name = "posthog" },
@@ -629,7 +631,9 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=44.0.0" },
{ 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" },
@@ -692,6 +692,160 @@ manualLinks = "Manual downloads: click the links and place the files into the te
noLanguages = "No tessdata languages found in the configured directory."
permissionNotice = "The tessdata path is not writable. Downloads will be opened in the browser; please save the .traineddata files manually into the tessdata folder."
# AI engine admin settings (AI nav group)
[admin.settings.ai.documents]
description = "Configure the embedding model and retrieval settings used to answer questions over documents. Applied to the AI engine when saved."
title = "Documents & RAG"
[admin.settings.ai.documents.embeddingApiKey]
description = "Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments."
generic = "Embedding API key"
openai = "OpenAI API key"
voyageai = "VoyageAI API key"
[admin.settings.ai.documents.embeddingBaseUrl]
description = "Base URL of the OpenAI-compatible / Ollama embeddings endpoint, e.g. http://ollama:11434/v1. Must point at a trusted internal endpoint (SSRF-sensitive)."
label = "Embedding base URL"
[admin.settings.ai.documents.embeddingModel]
description = "Embedding model name. Free text; suggestions are hints only."
label = "Embedding model"
[admin.settings.ai.documents.embeddingProvider]
description = "Provider used to turn document text into vector embeddings."
label = "Embedding provider"
[admin.settings.ai.documents.maxSearches]
description = "Maximum number of retrieval searches the agent may run per request."
label = "Max searches"
[admin.settings.ai.documents.reindexNote]
body = "Changing the embedding model invalidates previously-embedded documents. A full engine restart and re-index of existing documents is required for search to work correctly."
title = "Re-index required"
[admin.settings.ai.documents.topK]
description = "Number of most-relevant chunks retrieved per search."
label = "Top K"
[admin.settings.ai.general]
description = "Connect Stirling to the Python AI engine and choose which AI capabilities are exposed. Changes apply on restart."
title = "AI Engine"
[admin.settings.ai.general.capabilities]
description = "Turn individual AI features on or off. Disabled features are hidden in the app."
title = "Capabilities"
[admin.settings.ai.general.enabled]
description = "Master switch. When off, no AI tools, agents, or engine calls are available."
label = "Enable AI"
[admin.settings.ai.general.features.chat]
description = "Conversational assistant for working with PDFs."
label = "Chat assistant"
[admin.settings.ai.general.features.classify]
description = "Automatically categorise documents by type or content."
label = "Document classification"
[admin.settings.ai.general.features.createPdf]
description = "Generate a new PDF (e.g. from HTML) via an AI agent."
label = "Create PDF from prompt"
[admin.settings.ai.general.features.documentQuestions]
description = "Ask questions and get answers grounded in an uploaded document."
label = "Document questions"
[admin.settings.ai.general.features.mathAuditor]
description = "Review documents for mathematical and numerical errors."
label = "Math auditor"
[admin.settings.ai.general.features.pdfComment]
description = "Add AI-authored review comments and annotations to a PDF."
label = "PDF comment agent"
[admin.settings.ai.general.longRunningTimeoutSeconds]
description = "Timeout for heavier agent operations such as document generation."
label = "Long-running timeout (seconds)"
[admin.settings.ai.general.note]
body = "The AI engine runs as a separate service. Its shared secret"
body2 = "is set via a container environment variable. Provider API keys can be entered on these pages or supplied as engine environment variables; keys entered here are pushed to the engine when saved."
title = "About the AI engine"
[admin.settings.ai.general.streamTimeoutSeconds]
description = "Timeout for streamed (token-by-token) chat responses."
label = "Stream timeout (seconds)"
[admin.settings.ai.general.test]
button = "Test connection"
failBody = "The AI engine did not respond. Check the URL, that the engine container is running, and that AI is enabled (a restart is needed after enabling)."
failTitle = "AI engine unreachable"
okBody = "The AI engine responded to a health check."
okTitle = "AI engine reachable"
[admin.settings.ai.general.timeoutSeconds]
description = "Timeout for standard AI requests to the engine."
label = "Request timeout (seconds)"
[admin.settings.ai.general.url]
description = "Internal URL of the Python AI engine, e.g. http://stirling-pdf-engine:5001."
label = "AI engine URL"
[admin.settings.ai.limits]
description = "Guardrails for how much work AI requests may do and how many run concurrently. Applied to the AI engine when saved."
title = "Limits & Performance"
[admin.settings.ai.limits.maxCharacters]
description = "Guardrail: reject AI requests whose extracted text exceeds this length."
label = "Max characters per request"
[admin.settings.ai.limits.maxPages]
description = "Guardrail: reject AI requests over this many PDF pages."
label = "Max pages per request"
[admin.settings.ai.limits.modelMaxConcurrency]
description = "Maximum simultaneous in-flight model calls across the whole engine."
label = "Model max concurrency"
[admin.settings.ai.models]
description = "Choose the LLM provider and the smart/fast models the AI engine uses. Applied to the AI engine when saved."
title = "Models & Providers"
[admin.settings.ai.models.apiKey]
anthropic = "Anthropic API key"
description = "Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments."
generic = "API key"
openai = "OpenAI API key"
[admin.settings.ai.models.baseUrl]
description = "Base URL of the OpenAI-compatible / Ollama endpoint, e.g. http://ollama:11434/v1."
label = "Provider base URL"
warning = "The base URL must point at a trusted internal endpoint. The engine will make server-side requests to it, so an untrusted value is SSRF-sensitive."
[admin.settings.ai.models.fastMaxTokens]
description = "Maximum output tokens for the fast model."
label = "Fast model max tokens"
[admin.settings.ai.models.fastModel]
description = "Cheaper, faster model for lightweight tasks. Free text; suggestions are hints only."
label = "Fast model"
[admin.settings.ai.models.provider]
description = "Which LLM provider the engine talks to."
label = "Provider"
[admin.settings.ai.models.smartMaxTokens]
description = "Maximum output tokens for the smart model."
label = "Smart model max tokens"
[admin.settings.ai.models.smartModel]
description = "High-capability model for complex reasoning. Free text; suggestions are hints only."
label = "Smart model"
[admin.settings.ai.saved]
body = "Changes are pushed to the AI engine automatically."
title = "AI settings saved"
[admin.settings.badge]
clickToUpgrade = "Click to view plan details"
@@ -3734,9 +3888,20 @@ 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"
reset = "Show all"
subtitle = "Show or hide categories in the files sidebar."
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”."
title = "Sidebar categories"
[filesPage]
@@ -6003,11 +6168,31 @@ summaryMore = "{{first}}, {{second}} and {{more}} more"
summaryTwo = "{{first}} and {{second}}"
[policies.labels]
categoryCount = "categories"
hideCategory = "Hide category"
labelCount = "labels"
sharedNote = "These labels are built in and shared across your whole team."
showCategory = "Show category"
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"
[policies.pii]
account = "Account numbers (labelled)"
@@ -7420,11 +7605,6 @@ 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."
@@ -7449,13 +7629,6 @@ 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."
@@ -7550,7 +7723,6 @@ title = "No activity yet"
[portal.policies.endpoints]
addWatermark = "Watermark"
autoRedact = "Redact PII"
classifyAndLabel = "Classify"
compressPdf = "Compress"
flatten = "Flatten"
ocrPdf = "OCR"
@@ -7593,10 +7765,6 @@ 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"
@@ -7621,10 +7789,6 @@ 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."
@@ -8942,6 +9106,13 @@ title = "Signature Order"
[settings]
close = "Close"
[settings.ai]
documents = "Documents & RAG"
general = "General"
limits = "Limits & Performance"
models = "Models & Providers"
title = "AI"
[settings.configuration]
advanced = "Advanced"
database = "Database"
@@ -1,36 +0,0 @@
// 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} />;
}
@@ -32,6 +32,10 @@ export const VALID_NAV_KEYS = [
"adminEndpoints",
"adminStorageSharing",
"adminMcp",
"adminAiGeneral",
"adminAiModels",
"adminAiDocuments",
"adminAiLimits",
"help",
"legal",
"backendThirdPartyLicenses",
+57
View File
@@ -0,0 +1,57 @@
/* 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);
}
@@ -0,0 +1,52 @@
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
@@ -0,0 +1,60 @@
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>
);
}
+8 -28
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,21 +133,14 @@ export interface CatalogueEntry {
/* Endpoint display labels */
/* ──────────────────────────────────────────────────────────────────────── */
/**
* 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>
> = {
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, 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(
@@ -177,6 +170,7 @@ 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,
@@ -185,20 +179,14 @@ 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,
@@ -206,6 +194,7 @@ 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,
@@ -213,6 +202,7 @@ 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,
@@ -274,16 +264,6 @@ 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 { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import { policyIcon } from "@portal/components/policies/policyIcons";
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>
{policyCategoryIcon(entry.category.id)}
{policyIcon(entry.category.icon)}
</span>
<div className="portal-policysum__cat-text">
<strong>{t(entry.category.label)}</strong>
@@ -1,53 +0,0 @@
/* 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;
}
@@ -1,97 +0,0 @@
// 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,28 +1,19 @@
import { useTranslation } from "react-i18next";
import { Card, Chip, StatusBadge } from "@app/ui";
import type { CatalogueEntry } from "@portal/api/policies";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import { policyIcon } from "@portal/components/policies/policyIcons";
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,
locked = false,
lockedLabel,
}: PolicyCategoryCardProps) {
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
const { t } = useTranslation();
const { category, config, policy } = entry;
const comingSoon = category.comingSoon === true;
const openable = !comingSoon && !locked;
const openable = !comingSoon;
const status = policy?.state.status;
const enforces = config.rules.map((r) => t(r)).join(" · ");
@@ -30,7 +21,7 @@ export function PolicyCategoryCard({
<Card
className={
"portal-policies__card" +
(comingSoon || locked ? " portal-policies__card--locked" : "")
(comingSoon ? " portal-policies__card--locked" : "")
}
interactive={openable}
onClick={openable ? () => onOpen(entry) : undefined}
@@ -48,7 +39,7 @@ export function PolicyCategoryCard({
}
>
<span className="portal-policies__cat-icon" aria-hidden>
{policyCategoryIcon(category.id)}
{policyIcon(category.icon)}
</span>
<div className="portal-policies__card-identity">
@@ -62,10 +53,6 @@ export function PolicyCategoryCard({
<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,9 +8,6 @@ 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",
@@ -50,14 +47,3 @@ 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,10 +1,5 @@
import { useMemo, useState, type ReactNode } from "react";
import { useMemo, useState } 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,
@@ -34,27 +29,12 @@ import {
import { fetchSources } from "@portal/api/sources";
import { useAsync } from "@portal/hooks/useAsync";
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
import { policyIcon } from "@portal/components/policies/policyIcons";
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
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;
@@ -139,13 +119,6 @@ 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[] {
@@ -206,18 +179,9 @@ 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[]>(() => {
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 [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
const [fieldValues, setFieldValues] = useState(() =>
resolveFieldValues(entry),
);
@@ -226,25 +190,11 @@ function PolicySetupWizardBody({
);
const sourcesAsync = useAsync(() => fetchSources(), []);
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]);
const availableSources = useMemo(
() =>
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
[sourcesAsync.data],
);
// 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 ?? []);
@@ -335,7 +285,7 @@ function PolicySetupWizardBody({
title={
<span className="portal-policies__wizard-title">
<span className="portal-policies__cat-icon" aria-hidden>
{policyCategoryIcon(category.id)}
{policyIcon(category.icon)}
</span>
{isEdit
? t("portal.policies.wizard.title.edit", {
@@ -399,25 +349,7 @@ function PolicySetupWizardBody({
/>
)}
{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 && (
{step === "workflow" && (
<div className="portal-policies__wizard-section">
<p className="portal-policies__wizard-desc">
{t(
@@ -516,38 +448,35 @@ 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) => {
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}
>
{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">
<span className="portal-policies__source-label">
{sourceIcon(src.type)}
{src.name}
</span>
</Button>
);
})}
<span className="portal-policies__source-desc">
{src.type}
</span>
</span>
</Button>
))}
</div>
)}
@@ -0,0 +1,25 @@
/**
* 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] ?? "•";
}
@@ -1,26 +0,0 @@
// 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,
};
}
@@ -1,10 +0,0 @@
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,7 +12,6 @@ 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";
@@ -33,7 +32,6 @@ export const handlers = [
...teamSaasHandlers,
...agentsHandlers,
...policiesHandlers,
...classificationHandlers,
...documentsHandlers,
...sdkComponentsHandlers,
...editorDeployHandlers,
+101 -8
View File
@@ -266,23 +266,116 @@
}
/* 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: flex;
flex-direction: column;
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;
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 {
display: inline-flex;
align-items: center;
gap: 0.5rem;
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;
}
.portal-policies__link {
@@ -20,7 +20,6 @@ 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() {
@@ -35,18 +34,6 @@ 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),
@@ -70,11 +57,6 @@ 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);
}
@@ -175,8 +157,6 @@ export function Policies() {
key={entry.category.id}
entry={entry}
onOpen={openEntry}
locked={isLocked(entry)}
lockedLabel={t("portal.policies.card.requiresAiEngine")}
/>
))}
</div>
@@ -1,37 +0,0 @@
/* 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;
}
@@ -1,46 +0,0 @@
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 = {};
@@ -1,68 +0,0 @@
// 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>
);
}
@@ -0,0 +1,176 @@
/**
* 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>
);
}
@@ -0,0 +1,92 @@
/**
* 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>
);
}
@@ -0,0 +1,183 @@
/* 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);
}
@@ -0,0 +1,258 @@
// 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>
);
}
@@ -0,0 +1,175 @@
/**
* 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>
);
}
@@ -0,0 +1,19 @@
/**
* 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;
}
@@ -17,6 +17,10 @@ import AdminPlanSection from "@app/components/shared/config/configSections/Admin
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
import AdminMcpSection from "@app/components/shared/config/configSections/AdminMcpSection";
import AdminAiGeneralSection from "@app/components/shared/config/configSections/AdminAiGeneralSection";
import AdminAiModelsSection from "@app/components/shared/config/configSections/AdminAiModelsSection";
import AdminAiDocumentsSection from "@app/components/shared/config/configSections/AdminAiDocumentsSection";
import AdminAiLimitsSection from "@app/components/shared/config/configSections/AdminAiLimitsSection";
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
@@ -163,6 +167,45 @@ export const useConfigNavSections = (
],
});
// AI
sections.push({
title: t("settings.ai.title", "AI"),
items: [
{
key: "adminAiGeneral",
label: t("settings.ai.general", "General"),
icon: "smart-toy-rounded",
component: <AdminAiGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminAiModels",
label: t("settings.ai.models", "Models & Providers"),
icon: "psychology-rounded",
component: <AdminAiModelsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminAiDocuments",
label: t("settings.ai.documents", "Documents & RAG"),
icon: "description-rounded",
component: <AdminAiDocumentsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminAiLimits",
label: t("settings.ai.limits", "Limits & Performance"),
icon: "speed-rounded",
component: <AdminAiLimitsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Security & Authentication
sections.push({
title: t("settings.securityAuth.title", "Security & Authentication"),
@@ -0,0 +1,343 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
NumberInput,
PasswordInput,
Autocomplete,
Select,
TextInput,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import {
AiEngineSettingsData,
AiEngineRag,
AiEngineApiResponse,
EMBEDDING_MODEL_SUGGESTIONS,
} from "@app/components/shared/config/configSections/aiEngineSettings";
export default function AdminAiDocumentsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
// Track edits to the masked secret so we never send "********" back.
const [embeddingApiKeyDirty, setEmbeddingApiKeyDirty] = useState(false);
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<AiEngineSettingsData>({
sectionName: "aiEngine",
fetchTransformer: async (): Promise<
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
> => {
const response = await apiClient.get<AiEngineApiResponse>(
"/api/v1/admin/settings/section/aiEngine",
);
return response.data || {};
},
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
saveTransformer: (s: AiEngineSettingsData) => {
const deltaSettings: Record<string, unknown> = {
"aiEngine.rag.embeddingProvider":
s.rag?.embeddingProvider ?? "voyageai",
"aiEngine.rag.embeddingModel": s.rag?.embeddingModel ?? "",
"aiEngine.rag.embeddingBaseUrl": s.rag?.embeddingBaseUrl ?? "",
"aiEngine.rag.topK": s.rag?.topK ?? 0,
"aiEngine.rag.maxSearches": s.rag?.maxSearches ?? 0,
};
if (embeddingApiKeyDirty) {
deltaSettings["aiEngine.rag.embeddingApiKey"] =
s.rag?.embeddingApiKey ?? "";
}
return { sectionData: {}, deltaSettings };
},
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
setEmbeddingApiKeyDirty(false);
markSaved();
// Engine-facing values are pushed to the AI engine live on save; no restart needed.
alert({
alertType: "success",
title: t("admin.settings.ai.saved.title", "AI settings saved"),
body: t(
"admin.settings.ai.saved.body",
"Changes are pushed to the AI engine automatically.",
),
});
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setEmbeddingApiKeyDirty(false);
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const setRag = (patch: Partial<AiEngineRag>) =>
setSettings({ ...settings, rag: { ...(settings.rag || {}), ...patch } });
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const embeddingProvider = settings.rag?.embeddingProvider || "voyageai";
const embeddingSuggestions =
EMBEDDING_MODEL_SUGGESTIONS[embeddingProvider] || [];
const showEmbeddingBaseUrl =
embeddingProvider === "ollama" || embeddingProvider === "custom";
// Ollama's embeddings endpoint needs no API key, mirroring the Models page.
const showEmbeddingApiKey = embeddingProvider !== "ollama";
const embeddingApiKeyLabel =
embeddingProvider === "voyageai"
? t(
"admin.settings.ai.documents.embeddingApiKey.voyageai",
"VoyageAI API key",
)
: embeddingProvider === "openai"
? t(
"admin.settings.ai.documents.embeddingApiKey.openai",
"OpenAI API key",
)
: t(
"admin.settings.ai.documents.embeddingApiKey.generic",
"Embedding API key",
);
const embeddingApiKeyPlaceholder =
embeddingProvider === "voyageai"
? "pa-..."
: embeddingProvider === "openai"
? "sk-..."
: "";
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.ai.documents.title", "Documents & RAG")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.ai.documents.description",
"Configure the embedding model and retrieval settings used to answer questions over documents. Applied to the AI engine when saved.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Select
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.documents.embeddingProvider.label",
"Embedding provider",
)}
</span>
<PendingBadge
show={isFieldPending("rag.embeddingProvider")}
/>
</Group>
}
description={t(
"admin.settings.ai.documents.embeddingProvider.description",
"Provider used to turn document text into vector embeddings.",
)}
data={[
{ value: "voyageai", label: "VoyageAI" },
{ value: "openai", label: "OpenAI" },
{ value: "ollama", label: "Ollama" },
{ value: "custom", label: "Custom (OpenAI-compatible)" },
]}
value={embeddingProvider}
onChange={(v) => setRag({ embeddingProvider: v || "voyageai" })}
allowDeselect={false}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Autocomplete
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.documents.embeddingModel.label",
"Embedding model",
)}
</span>
<PendingBadge show={isFieldPending("rag.embeddingModel")} />
</Group>
}
description={t(
"admin.settings.ai.documents.embeddingModel.description",
"Embedding model name. Free text; suggestions are hints only.",
)}
data={embeddingSuggestions}
value={settings.rag?.embeddingModel || ""}
onChange={(value) => setRag({ embeddingModel: value })}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
{showEmbeddingApiKey && (
<PasswordInput
label={
<Group gap="xs">
<span>{embeddingApiKeyLabel}</span>
<PendingBadge
show={isFieldPending("rag.embeddingApiKey")}
/>
</Group>
}
description={t(
"admin.settings.ai.documents.embeddingApiKey.description",
"Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments.",
)}
value={settings.rag?.embeddingApiKey || ""}
onChange={(e) => {
setEmbeddingApiKeyDirty(true);
setRag({ embeddingApiKey: e.target.value });
}}
placeholder={embeddingApiKeyPlaceholder}
/>
)}
{showEmbeddingBaseUrl && (
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.documents.embeddingBaseUrl.label",
"Embedding base URL",
)}
</span>
<PendingBadge
show={isFieldPending("rag.embeddingBaseUrl")}
/>
</Group>
}
description={t(
"admin.settings.ai.documents.embeddingBaseUrl.description",
"Base URL of the OpenAI-compatible / Ollama embeddings endpoint, e.g. http://ollama:11434/v1. Must point at a trusted internal endpoint (SSRF-sensitive).",
)}
value={settings.rag?.embeddingBaseUrl || ""}
onChange={(e) => setRag({ embeddingBaseUrl: e.target.value })}
placeholder="http://ollama:11434/v1"
/>
)}
<NumberInput
label={
<Group gap="xs">
<span>
{t("admin.settings.ai.documents.topK.label", "Top K")}
</span>
<PendingBadge show={isFieldPending("rag.topK")} />
</Group>
}
description={t(
"admin.settings.ai.documents.topK.description",
"Number of most-relevant chunks retrieved per search.",
)}
value={settings.rag?.topK ?? 0}
onChange={(value) => setRag({ topK: Number(value) })}
min={1}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.documents.maxSearches.label",
"Max searches",
)}
</span>
<PendingBadge show={isFieldPending("rag.maxSearches")} />
</Group>
}
description={t(
"admin.settings.ai.documents.maxSearches.description",
"Maximum number of retrieval searches the agent may run per request.",
)}
value={settings.rag?.maxSearches ?? 0}
onChange={(value) => setRag({ maxSearches: Number(value) })}
min={0}
/>
</Stack>
</Paper>
<Alert
variant="light"
color="orange"
title={t(
"admin.settings.ai.documents.reindexNote.title",
"Re-index required",
)}
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
>
<Text size="xs">
{t(
"admin.settings.ai.documents.reindexNote.body",
"Changing the embedding model invalidates previously-embedded documents. A full engine restart and re-index of existing documents is required for search to work correctly.",
)}
</Text>
</Alert>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,500 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
TextInput,
NumberInput,
Switch,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
Code,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import { Button } from "@app/ui/Button";
import {
AiEngineSettingsData,
AiEngineFeatures,
AiEngineApiResponse,
} from "@app/components/shared/config/configSections/aiEngineSettings";
export default function AdminAiGeneralSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<AiEngineSettingsData>({
sectionName: "aiEngine",
fetchTransformer: async (): Promise<
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
> => {
const response = await apiClient.get<AiEngineApiResponse>(
"/api/v1/admin/settings/section/aiEngine",
);
return response.data || {};
},
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
saveTransformer: (s: AiEngineSettingsData) => ({
sectionData: {},
deltaSettings: {
"aiEngine.enabled": s.enabled ?? false,
"aiEngine.url": s.url ?? "",
"aiEngine.timeoutSeconds": s.timeoutSeconds ?? 0,
"aiEngine.longRunningTimeoutSeconds": s.longRunningTimeoutSeconds ?? 0,
"aiEngine.streamTimeoutSeconds": s.streamTimeoutSeconds ?? 0,
"aiEngine.features.chat": s.features?.chat ?? false,
"aiEngine.features.documentQuestions":
s.features?.documentQuestions ?? false,
"aiEngine.features.createPdf": s.features?.createPdf ?? false,
"aiEngine.features.mathAuditor": s.features?.mathAuditor ?? false,
"aiEngine.features.pdfComment": s.features?.pdfComment ?? false,
"aiEngine.features.classify": s.features?.classify ?? false,
},
}),
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
markSaved();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const [testingConnection, setTestingConnection] = useState(false);
// Probe the RUNNING configuration (the Java bean), not unsaved form values -
// after enabling AI or changing the URL, save + restart first, then test.
const handleTestConnection = async () => {
setTestingConnection(true);
try {
await apiClient.get("/api/v1/ai/health");
alert({
alertType: "success",
title: t(
"admin.settings.ai.general.test.okTitle",
"AI engine reachable",
),
body: t(
"admin.settings.ai.general.test.okBody",
"The AI engine responded to a health check.",
),
});
} catch (error) {
const detail =
(error as { response?: { data?: { message?: string } } })?.response
?.data?.message ||
t(
"admin.settings.ai.general.test.failBody",
"The AI engine did not respond. Check the URL, that the engine container is running, and that AI is enabled (a restart is needed after enabling).",
);
alert({
alertType: "error",
title: t(
"admin.settings.ai.general.test.failTitle",
"AI engine unreachable",
),
body: detail,
});
} finally {
setTestingConnection(false);
}
};
const setFeatures = (patch: Partial<AiEngineFeatures>) =>
setSettings({
...settings,
features: { ...(settings.features || {}), ...patch },
});
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const enabled = settings.enabled || false;
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.ai.general.title", "AI Engine")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.ai.general.description",
"Connect Stirling to the Python AI engine and choose which AI capabilities are exposed. Changes apply on restart.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t("admin.settings.ai.general.enabled.label", "Enable AI")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.ai.general.enabled.description",
"Master switch. When off, no AI tools, agents, or engine calls are available.",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={enabled}
onChange={(e) =>
setSettings({ ...settings, enabled: e.target.checked })
}
/>
<PendingBadge show={isFieldPending("enabled")} />
</Group>
</Group>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.ai.general.url.label", "AI engine URL")}
</span>
<PendingBadge show={isFieldPending("url")} />
</Group>
}
description={t(
"admin.settings.ai.general.url.description",
"Internal URL of the Python AI engine, e.g. http://stirling-pdf-engine:5001.",
)}
value={settings.url || ""}
onChange={(e) =>
setSettings({ ...settings, url: e.target.value })
}
placeholder="http://stirling-pdf-engine:5001"
disabled={!enabled}
/>
<Group justify="flex-end">
<Button
variant="secondary"
size="sm"
loading={testingConnection}
onClick={handleTestConnection}
>
{t("admin.settings.ai.general.test.button", "Test connection")}
</Button>
</Group>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.general.timeoutSeconds.label",
"Request timeout (seconds)",
)}
</span>
<PendingBadge show={isFieldPending("timeoutSeconds")} />
</Group>
}
description={t(
"admin.settings.ai.general.timeoutSeconds.description",
"Timeout for standard AI requests to the engine.",
)}
value={settings.timeoutSeconds ?? 0}
onChange={(value) =>
setSettings({ ...settings, timeoutSeconds: Number(value) })
}
min={1}
disabled={!enabled}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.general.longRunningTimeoutSeconds.label",
"Long-running timeout (seconds)",
)}
</span>
<PendingBadge
show={isFieldPending("longRunningTimeoutSeconds")}
/>
</Group>
}
description={t(
"admin.settings.ai.general.longRunningTimeoutSeconds.description",
"Timeout for heavier agent operations such as document generation.",
)}
value={settings.longRunningTimeoutSeconds ?? 0}
onChange={(value) =>
setSettings({
...settings,
longRunningTimeoutSeconds: Number(value),
})
}
min={1}
disabled={!enabled}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.general.streamTimeoutSeconds.label",
"Stream timeout (seconds)",
)}
</span>
<PendingBadge show={isFieldPending("streamTimeoutSeconds")} />
</Group>
}
description={t(
"admin.settings.ai.general.streamTimeoutSeconds.description",
"Timeout for streamed (token-by-token) chat responses.",
)}
value={settings.streamTimeoutSeconds ?? 0}
onChange={(value) =>
setSettings({
...settings,
streamTimeoutSeconds: Number(value),
})
}
min={1}
disabled={!enabled}
/>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm">
{t(
"admin.settings.ai.general.capabilities.title",
"Capabilities",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.ai.general.capabilities.description",
"Turn individual AI features on or off. Disabled features are hidden in the app.",
)}
</Text>
</div>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.chat.label",
"Chat assistant",
)}
description={t(
"admin.settings.ai.general.features.chat.description",
"Conversational assistant for working with PDFs.",
)}
checked={settings.features?.chat ?? false}
onChange={(checked) => setFeatures({ chat: checked })}
pending={isFieldPending("features.chat")}
disabled={!enabled}
/>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.documentQuestions.label",
"Document questions",
)}
description={t(
"admin.settings.ai.general.features.documentQuestions.description",
"Ask questions and get answers grounded in an uploaded document.",
)}
checked={settings.features?.documentQuestions ?? false}
onChange={(checked) =>
setFeatures({ documentQuestions: checked })
}
pending={isFieldPending("features.documentQuestions")}
disabled={!enabled}
/>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.createPdf.label",
"Create PDF from prompt",
)}
description={t(
"admin.settings.ai.general.features.createPdf.description",
"Generate a new PDF (e.g. from HTML) via an AI agent.",
)}
checked={settings.features?.createPdf ?? false}
onChange={(checked) => setFeatures({ createPdf: checked })}
pending={isFieldPending("features.createPdf")}
disabled={!enabled}
/>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.mathAuditor.label",
"Math auditor",
)}
description={t(
"admin.settings.ai.general.features.mathAuditor.description",
"Review documents for mathematical and numerical errors.",
)}
checked={settings.features?.mathAuditor ?? false}
onChange={(checked) => setFeatures({ mathAuditor: checked })}
pending={isFieldPending("features.mathAuditor")}
disabled={!enabled}
/>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.pdfComment.label",
"PDF comment agent",
)}
description={t(
"admin.settings.ai.general.features.pdfComment.description",
"Add AI-authored review comments and annotations to a PDF.",
)}
checked={settings.features?.pdfComment ?? false}
onChange={(checked) => setFeatures({ pdfComment: checked })}
pending={isFieldPending("features.pdfComment")}
disabled={!enabled}
/>
<FeatureSwitch
label={t(
"admin.settings.ai.general.features.classify.label",
"Document classification",
)}
description={t(
"admin.settings.ai.general.features.classify.description",
"Automatically categorise documents by type or content.",
)}
checked={settings.features?.classify ?? false}
onChange={(checked) => setFeatures({ classify: checked })}
pending={isFieldPending("features.classify")}
disabled={!enabled}
/>
</Stack>
</Paper>
<Alert
variant="light"
color="blue"
title={t(
"admin.settings.ai.general.note.title",
"About the AI engine",
)}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="xs">
{t(
"admin.settings.ai.general.note.body",
"The AI engine runs as a separate service. Its shared secret",
)}{" "}
<Code>STIRLING_ENGINE_SHARED_SECRET</Code>{" "}
{t(
"admin.settings.ai.general.note.body2",
"is set via a container environment variable. Provider API keys can be entered on these pages or supplied as engine environment variables; keys entered here are pushed to the engine when saved.",
)}
</Text>
</Alert>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
interface FeatureSwitchProps {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
pending: boolean;
disabled: boolean;
}
function FeatureSwitch({
label,
description,
checked,
onChange,
pending,
disabled,
}: FeatureSwitchProps) {
return (
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{label}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{description}
</Text>
</div>
<Group gap="xs">
<Switch
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
<PendingBadge show={pending} />
</Group>
</Group>
);
}
@@ -0,0 +1,196 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { NumberInput, Stack, Paper, Text, Loader, Group } from "@mantine/core";
import { alert } from "@app/components/toast";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import {
AiEngineSettingsData,
AiEngineLimits,
AiEngineApiResponse,
} from "@app/components/shared/config/configSections/aiEngineSettings";
export default function AdminAiLimitsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<AiEngineSettingsData>({
sectionName: "aiEngine",
fetchTransformer: async (): Promise<
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
> => {
const response = await apiClient.get<AiEngineApiResponse>(
"/api/v1/admin/settings/section/aiEngine",
);
return response.data || {};
},
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
saveTransformer: (s: AiEngineSettingsData) => ({
sectionData: {},
deltaSettings: {
"aiEngine.limits.maxPages": s.limits?.maxPages ?? 0,
"aiEngine.limits.maxCharacters": s.limits?.maxCharacters ?? 0,
"aiEngine.limits.modelMaxConcurrency":
s.limits?.modelMaxConcurrency ?? 0,
},
}),
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
markSaved();
// Engine-facing values are pushed to the AI engine live on save; no restart needed.
alert({
alertType: "success",
title: t("admin.settings.ai.saved.title", "AI settings saved"),
body: t(
"admin.settings.ai.saved.body",
"Changes are pushed to the AI engine automatically.",
),
});
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const setLimits = (patch: Partial<AiEngineLimits>) =>
setSettings({
...settings,
limits: { ...(settings.limits || {}), ...patch },
});
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.ai.limits.title", "Limits & Performance")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.ai.limits.description",
"Guardrails for how much work AI requests may do and how many run concurrently. Applied to the AI engine when saved.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.limits.maxPages.label",
"Max pages per request",
)}
</span>
<PendingBadge show={isFieldPending("limits.maxPages")} />
</Group>
}
description={t(
"admin.settings.ai.limits.maxPages.description",
"Guardrail: reject AI requests over this many PDF pages.",
)}
value={settings.limits?.maxPages ?? 0}
onChange={(value) => setLimits({ maxPages: Number(value) })}
min={1}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.limits.maxCharacters.label",
"Max characters per request",
)}
</span>
<PendingBadge show={isFieldPending("limits.maxCharacters")} />
</Group>
}
description={t(
"admin.settings.ai.limits.maxCharacters.description",
"Guardrail: reject AI requests whose extracted text exceeds this length.",
)}
value={settings.limits?.maxCharacters ?? 0}
onChange={(value) => setLimits({ maxCharacters: Number(value) })}
min={1}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.limits.modelMaxConcurrency.label",
"Model max concurrency",
)}
</span>
<PendingBadge
show={isFieldPending("limits.modelMaxConcurrency")}
/>
</Group>
}
description={t(
"admin.settings.ai.limits.modelMaxConcurrency.description",
"Maximum simultaneous in-flight model calls across the whole engine.",
)}
value={settings.limits?.modelMaxConcurrency ?? 0}
onChange={(value) =>
setLimits({ modelMaxConcurrency: Number(value) })
}
min={1}
/>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,361 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
TextInput,
NumberInput,
PasswordInput,
Autocomplete,
Select,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import {
AiEngineSettingsData,
AiEngineModels,
AiEngineApiResponse,
MODEL_SUGGESTIONS,
} from "@app/components/shared/config/configSections/aiEngineSettings";
export default function AdminAiModelsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
// Track whether the user actually edited the masked secret. If not, we omit
// it from the delta entirely so we never send "********" back to the server.
const [apiKeyDirty, setApiKeyDirty] = useState(false);
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<AiEngineSettingsData>({
sectionName: "aiEngine",
fetchTransformer: async (): Promise<
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
> => {
const response = await apiClient.get<AiEngineApiResponse>(
"/api/v1/admin/settings/section/aiEngine",
);
return response.data || {};
},
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
saveTransformer: (s: AiEngineSettingsData) => {
const deltaSettings: Record<string, unknown> = {
"aiEngine.models.provider": s.models?.provider ?? "anthropic",
"aiEngine.models.smartModel": s.models?.smartModel ?? "",
"aiEngine.models.fastModel": s.models?.fastModel ?? "",
"aiEngine.models.smartMaxTokens": s.models?.smartMaxTokens ?? 0,
"aiEngine.models.fastMaxTokens": s.models?.fastMaxTokens ?? 0,
"aiEngine.models.baseUrl": s.models?.baseUrl ?? "",
};
// Only include the secret when the user typed a new value.
if (apiKeyDirty) {
deltaSettings["aiEngine.models.apiKey"] = s.models?.apiKey ?? "";
}
return { sectionData: {}, deltaSettings };
},
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
setApiKeyDirty(false);
markSaved();
// Engine-facing values are pushed to the AI engine live on save; no restart needed.
alert({
alertType: "success",
title: t("admin.settings.ai.saved.title", "AI settings saved"),
body: t(
"admin.settings.ai.saved.body",
"Changes are pushed to the AI engine automatically.",
),
});
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setApiKeyDirty(false);
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const setModels = (patch: Partial<AiEngineModels>) =>
setSettings({
...settings,
models: { ...(settings.models || {}), ...patch },
});
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const provider = settings.models?.provider || "anthropic";
const showApiKey = provider !== "ollama";
const showBaseUrl = provider === "ollama" || provider === "custom";
const modelSuggestions = MODEL_SUGGESTIONS[provider] || [];
const apiKeyLabel =
provider === "anthropic"
? t("admin.settings.ai.models.apiKey.anthropic", "Anthropic API key")
: provider === "openai"
? t("admin.settings.ai.models.apiKey.openai", "OpenAI API key")
: t("admin.settings.ai.models.apiKey.generic", "API key");
const apiKeyPlaceholder =
provider === "anthropic"
? "sk-ant-..."
: provider === "openai"
? "sk-..."
: "";
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.ai.models.title", "Models & Providers")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.ai.models.description",
"Choose the LLM provider and the smart/fast models the AI engine uses. Applied to the AI engine when saved.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Select
label={
<Group gap="xs">
<span>
{t("admin.settings.ai.models.provider.label", "Provider")}
</span>
<PendingBadge show={isFieldPending("models.provider")} />
</Group>
}
description={t(
"admin.settings.ai.models.provider.description",
"Which LLM provider the engine talks to.",
)}
data={[
{ value: "anthropic", label: "Anthropic" },
{ value: "openai", label: "OpenAI" },
{ value: "ollama", label: "Ollama" },
{ value: "custom", label: "Custom (OpenAI-compatible)" },
]}
value={provider}
onChange={(v) => setModels({ provider: v || "anthropic" })}
allowDeselect={false}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Autocomplete
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.models.smartModel.label",
"Smart model",
)}
</span>
<PendingBadge show={isFieldPending("models.smartModel")} />
</Group>
}
description={t(
"admin.settings.ai.models.smartModel.description",
"High-capability model for complex reasoning. Free text; suggestions are hints only.",
)}
data={modelSuggestions}
value={settings.models?.smartModel || ""}
onChange={(value) => setModels({ smartModel: value })}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Autocomplete
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.models.fastModel.label",
"Fast model",
)}
</span>
<PendingBadge show={isFieldPending("models.fastModel")} />
</Group>
}
description={t(
"admin.settings.ai.models.fastModel.description",
"Cheaper, faster model for lightweight tasks. Free text; suggestions are hints only.",
)}
data={modelSuggestions}
value={settings.models?.fastModel || ""}
onChange={(value) => setModels({ fastModel: value })}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.models.smartMaxTokens.label",
"Smart model max tokens",
)}
</span>
<PendingBadge
show={isFieldPending("models.smartMaxTokens")}
/>
</Group>
}
description={t(
"admin.settings.ai.models.smartMaxTokens.description",
"Maximum output tokens for the smart model.",
)}
value={settings.models?.smartMaxTokens ?? 0}
onChange={(value) => setModels({ smartMaxTokens: Number(value) })}
min={1}
/>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.models.fastMaxTokens.label",
"Fast model max tokens",
)}
</span>
<PendingBadge show={isFieldPending("models.fastMaxTokens")} />
</Group>
}
description={t(
"admin.settings.ai.models.fastMaxTokens.description",
"Maximum output tokens for the fast model.",
)}
value={settings.models?.fastMaxTokens ?? 0}
onChange={(value) => setModels({ fastMaxTokens: Number(value) })}
min={1}
/>
{showApiKey && (
<PasswordInput
label={
<Group gap="xs">
<span>{apiKeyLabel}</span>
<PendingBadge show={isFieldPending("models.apiKey")} />
</Group>
}
description={t(
"admin.settings.ai.models.apiKey.description",
"Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments.",
)}
value={settings.models?.apiKey || ""}
onChange={(e) => {
setApiKeyDirty(true);
setModels({ apiKey: e.target.value });
}}
placeholder={apiKeyPlaceholder}
/>
)}
{showBaseUrl && (
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.ai.models.baseUrl.label",
"Provider base URL",
)}
</span>
<PendingBadge show={isFieldPending("models.baseUrl")} />
</Group>
}
description={t(
"admin.settings.ai.models.baseUrl.description",
"Base URL of the OpenAI-compatible / Ollama endpoint, e.g. http://ollama:11434/v1.",
)}
value={settings.models?.baseUrl || ""}
onChange={(e) => setModels({ baseUrl: e.target.value })}
placeholder="http://ollama:11434/v1"
/>
)}
{showBaseUrl && (
<Alert
variant="light"
color="orange"
icon={
<LocalIcon
icon="warning-rounded"
width="1rem"
height="1rem"
/>
}
>
<Text size="xs">
{t(
"admin.settings.ai.models.baseUrl.warning",
"The base URL must point at a trusted internal endpoint. The engine will make server-side requests to it, so an untrusted value is SSRF-sensitive.",
)}
</Text>
</Alert>
)}
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,75 @@
// Shared types + constants for the aiEngine admin settings section.
// The four AI sub-pages fetch the same GET /section/aiEngine payload but each
// saves only its own dot-notation keys so sibling keys are preserved.
/** Secret fields come back masked as this literal when a value is set. */
export const MASKED_SECRET = "********";
export interface AiEngineModels {
provider?: string;
smartModel?: string;
fastModel?: string;
smartMaxTokens?: number;
fastMaxTokens?: number;
apiKey?: string;
baseUrl?: string;
}
export interface AiEngineRag {
embeddingProvider?: string;
embeddingModel?: string;
embeddingApiKey?: string;
embeddingBaseUrl?: string;
topK?: number;
maxSearches?: number;
}
export interface AiEngineLimits {
maxPages?: number;
maxCharacters?: number;
modelMaxConcurrency?: number;
}
export interface AiEngineFeatures {
chat?: boolean;
documentQuestions?: boolean;
createPdf?: boolean;
mathAuditor?: boolean;
pdfComment?: boolean;
classify?: boolean;
}
export interface AiEngineSettingsData {
enabled?: boolean;
url?: string;
timeoutSeconds?: number;
longRunningTimeoutSeconds?: number;
streamTimeoutSeconds?: number;
models?: AiEngineModels;
rag?: AiEngineRag;
limits?: AiEngineLimits;
features?: AiEngineFeatures;
}
export interface ApiResponseWithPending<T> {
_pending?: Partial<T>;
}
export type AiEngineApiResponse = AiEngineSettingsData &
ApiResponseWithPending<AiEngineSettingsData>;
/** Free-text model suggestions per provider (hints only). */
export const MODEL_SUGGESTIONS: Record<string, string[]> = {
anthropic: ["claude-haiku-4-5", "claude-sonnet-4-5", "claude-opus-4-1"],
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
ollama: ["llama3.1", "qwen2.5", "mistral"],
custom: ["llama3.1", "qwen2.5", "mistral"],
};
/** Free-text embedding-model suggestions per embedding provider. */
export const EMBEDDING_MODEL_SUGGESTIONS: Record<string, string[]> = {
voyageai: ["voyage-4", "voyage-3.5"],
openai: ["text-embedding-3-small", "text-embedding-3-large"],
ollama: ["nomic-embed-text", "mxbai-embed-large", "bge-m3"],
custom: ["nomic-embed-text", "mxbai-embed-large", "bge-m3"],
};

Some files were not shown because too many files have changed in this diff Show More