Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1143a8aba3 | ||
|
|
baf33b6844 |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
-1
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
-61
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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) {
|
||||
|
||||
|
||||
+71
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
}
|
||||
+76
@@ -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;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JPA row for a team's classification 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;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
|
||||
+7
-5
@@ -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() {
|
||||
|
||||
+37
-14
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+17
-38
@@ -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;
|
||||
|
||||
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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 {
|
||||
|
||||
|
||||
+53
-2
@@ -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 */
|
||||
|
||||
-135
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+181
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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
+133
@@ -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);
|
||||
}
|
||||
}
|
||||
+146
@@ -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");
|
||||
}
|
||||
}
|
||||
+37
-15
@@ -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());
|
||||
|
||||
+2
-2
@@ -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()),
|
||||
|
||||
+55
-1
@@ -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
|
||||
|
||||
-139
@@ -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("<b>"));
|
||||
}
|
||||
|
||||
@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;"));
|
||||
}
|
||||
}
|
||||
+150
@@ -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());
|
||||
}
|
||||
}
|
||||
+54
@@ -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());
|
||||
}
|
||||
}
|
||||
+8
-46
@@ -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;
|
||||
-4
@@ -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)))
|
||||
|
||||
-84
@@ -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();
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
+19
-21
@@ -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 %}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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$")
|
||||
|
||||
|
||||
|
||||
@@ -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}.")
|
||||
|
||||
@@ -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 "<script>" 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+4
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
-46
@@ -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"),
|
||||
|
||||
+343
@@ -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>
|
||||
);
|
||||
}
|
||||
+500
@@ -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>
|
||||
);
|
||||
}
|
||||
+196
@@ -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>
|
||||
);
|
||||
}
|
||||
+361
@@ -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>
|
||||
);
|
||||
}
|
||||
+75
@@ -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
Reference in New Issue
Block a user