document classifier agent that runs as a policy and assigns a category and sub-category docType
This commit is contained in:
@@ -20,15 +20,61 @@ import {
|
||||
downloadFileFromStorage,
|
||||
downloadMultipleFiles,
|
||||
} from "@app/utils/downloadUtils";
|
||||
import ToolChain from "@app/components/shared/ToolChain";
|
||||
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
|
||||
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { extractPDFMetadata } from "@app/services/pdfMetadataService";
|
||||
import {
|
||||
VersionTimeline,
|
||||
DetailField,
|
||||
} from "@app/components/filesPage/VersionTimeline";
|
||||
|
||||
/** Custom PDF Info-dictionary key the classify-and-tag tool writes (must match
|
||||
* the backend's PdfMetadataService.CLASSIFICATION_KEY). */
|
||||
const CLASSIFICATION_KEY = "StirlingPDFClassification";
|
||||
|
||||
/** Reading classification means loading the file's bytes through PDF.js, so cap
|
||||
* the auto-read by size — the app handles very large PDFs and we won't pull a
|
||||
* multi-GB file into memory just to surface a metadata tag. */
|
||||
const MAX_CLASSIFICATION_READ_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
interface DocumentClassification {
|
||||
category: string;
|
||||
docType: string;
|
||||
typeConfidence?: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** Parse the classification JSON stored in PDF metadata; null if absent/invalid. */
|
||||
function parseClassification(value: string): DocumentClassification | null {
|
||||
try {
|
||||
const raw = JSON.parse(value) as Record<string, unknown>;
|
||||
const category = typeof raw.category === "string" ? raw.category : "";
|
||||
const docType = typeof raw.docType === "string" ? raw.docType : "";
|
||||
if (!category && !docType) return null;
|
||||
return {
|
||||
category,
|
||||
docType,
|
||||
typeConfidence:
|
||||
typeof raw.typeConfidence === "number" ? raw.typeConfidence : undefined,
|
||||
tags: Array.isArray(raw.tags)
|
||||
? raw.tags.filter((tag): tag is string => typeof tag === "string")
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** "lab_result" → "Lab result" for display. */
|
||||
function prettyLabel(id: string): string {
|
||||
return id
|
||||
.split(/[_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word[0].toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
interface FileDetailsPanelProps {
|
||||
selectedFileIds: FileId[];
|
||||
fileMap: Map<FileId, StirlingFileStub>;
|
||||
@@ -76,6 +122,12 @@ export function FileDetailsPanel({
|
||||
// Metadata (size/type/dates) is collapsed by default so the panel stays
|
||||
// short and the action buttons keep their pinned footer in view.
|
||||
const [fieldsOpen, setFieldsOpen] = useState(false);
|
||||
// Version journey is collapsed by default so the panel stays short.
|
||||
const [versionsOpen, setVersionsOpen] = useState(false);
|
||||
// Document classification read from PDF metadata, plus its (collapsed) section.
|
||||
const [classification, setClassification] =
|
||||
useState<DocumentClassification | null>(null);
|
||||
const [classificationOpen, setClassificationOpen] = useState(false);
|
||||
// Version chain for the selected file; empty for v1 or multi-select.
|
||||
const [versionChain, setVersionChain] = useState<StirlingFileStub[]>([]);
|
||||
const singleFileForChain = files.length === 1 ? files[0] : null;
|
||||
@@ -101,6 +153,35 @@ export function FileDetailsPanel({
|
||||
};
|
||||
}, [singleFileForChain]);
|
||||
|
||||
// Read the classification the policy wrote into PDF metadata
|
||||
useEffect(() => {
|
||||
setClassification(null);
|
||||
const stub = singleFileForChain;
|
||||
if (!stub) return;
|
||||
if (stub.type && !stub.type.toLowerCase().includes("pdf")) return;
|
||||
if (stub.size > MAX_CLASSIFICATION_READ_BYTES) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const file = await fileStorage.getStirlingFile(stub.id);
|
||||
if (cancelled || !file) return;
|
||||
const result = await extractPDFMetadata(file);
|
||||
if (cancelled || !result.success) return;
|
||||
const entry = result.metadata.customMetadata.find(
|
||||
(item) => item.key === CLASSIFICATION_KEY,
|
||||
);
|
||||
if (!entry) return;
|
||||
const parsed = parseClassification(entry.value);
|
||||
if (parsed && !cancelled) setClassification(parsed);
|
||||
} catch (err) {
|
||||
console.error("Failed to read classification metadata", err);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [singleFileForChain]);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -232,17 +313,74 @@ export function FileDetailsPanel({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{single.toolHistory && single.toolHistory.length > 0 && (
|
||||
<div className="files-page-details-tool-history">
|
||||
<div className="files-page-details-tool-history-label">
|
||||
{t("filesPage.field.toolHistory", "Tool history")}
|
||||
</div>
|
||||
<ToolChain
|
||||
toolChain={single.toolHistory}
|
||||
displayStyle="badges"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
{classification && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="files-page-details-collapse-toggle"
|
||||
onClick={() => setClassificationOpen((o) => !o)}
|
||||
aria-expanded={classificationOpen}
|
||||
>
|
||||
<span>{t("filesPage.classification", "Classification")}</span>
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-collapse-chevron${
|
||||
classificationOpen ? " is-open" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
</button>
|
||||
{classificationOpen && (
|
||||
<div className="files-page-details-fieldlist">
|
||||
{classification.category && (
|
||||
<DetailField
|
||||
label={t("filesPage.field.category", "Category")}
|
||||
value={prettyLabel(classification.category)}
|
||||
/>
|
||||
)}
|
||||
{classification.docType && (
|
||||
<DetailField
|
||||
label={t("filesPage.field.type", "Type")}
|
||||
value={prettyLabel(classification.docType)}
|
||||
/>
|
||||
)}
|
||||
{classification.typeConfidence != null && (
|
||||
<DetailField
|
||||
label={t("filesPage.field.confidence", "Confidence")}
|
||||
value={`${Math.round(
|
||||
classification.typeConfidence * 100,
|
||||
)}%`}
|
||||
/>
|
||||
)}
|
||||
{classification.tags.length > 0 && (
|
||||
<div className="files-page-details-field">
|
||||
<span className="files-page-details-field-label">
|
||||
{t("filesPage.field.tags", "Tags")}
|
||||
</span>
|
||||
<span
|
||||
className="files-page-details-field-value"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.25rem",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{classification.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Version journey. Each tool run writes a new StirlingFile
|
||||
with the same `originalFileId` and an incremented
|
||||
@@ -266,12 +404,37 @@ export function FileDetailsPanel({
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<VersionTimeline
|
||||
chain={versionChain}
|
||||
currentId={single.id}
|
||||
onAddToWorkspace={onAddToWorkspace}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="files-page-details-collapse-toggle"
|
||||
onClick={() => setVersionsOpen((o) => !o)}
|
||||
aria-expanded={versionsOpen}
|
||||
>
|
||||
<span>
|
||||
{t(
|
||||
"filesPage.viewVersionHistory",
|
||||
"Version journey ({{count}})",
|
||||
{ count: versionChain.length },
|
||||
)}
|
||||
</span>
|
||||
<KeyboardArrowDownIcon
|
||||
className={`files-page-details-collapse-chevron${
|
||||
versionsOpen ? " is-open" : ""
|
||||
}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
</button>
|
||||
{versionsOpen && (
|
||||
<VersionTimeline
|
||||
chain={versionChain}
|
||||
currentId={single.id}
|
||||
onAddToWorkspace={onAddToWorkspace}
|
||||
onRemove={onRemove}
|
||||
hideHeader
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface VersionTimelineProps {
|
||||
currentId: FileId;
|
||||
onAddToWorkspace: (fileIds: FileId[]) => void;
|
||||
onRemove: (fileIds: FileId[]) => void;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
/** Version timeline with per-row tool deltas and collapse-when-long. */
|
||||
@@ -63,6 +64,7 @@ export function VersionTimeline({
|
||||
currentId,
|
||||
onAddToWorkspace,
|
||||
onRemove,
|
||||
hideHeader = false,
|
||||
}: VersionTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<FileId>>(new Set());
|
||||
@@ -120,15 +122,17 @@ export function VersionTimeline({
|
||||
|
||||
return (
|
||||
<div className="files-page-details-version-timeline">
|
||||
<div className="files-page-details-version-timeline-label">
|
||||
<HistoryIcon fontSize="small" />
|
||||
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
|
||||
<span className="files-page-details-version-timeline-count">
|
||||
{t("filesPage.versionsCount", "{{count}} versions", {
|
||||
count: ordered.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{!hideHeader && (
|
||||
<div className="files-page-details-version-timeline-label">
|
||||
<HistoryIcon fontSize="small" />
|
||||
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
|
||||
<span className="files-page-details-version-timeline-count">
|
||||
{t("filesPage.versionsCount", "{{count}} versions", {
|
||||
count: ordered.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ol className="files-page-details-version-timeline-list">
|
||||
{rows.map((row, idx) => {
|
||||
const isLast = idx === rows.length - 1;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Menu, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -135,6 +135,8 @@ export interface FileItemFolderRef {
|
||||
export interface FileItemPolicyRef {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Badge glyph — the policy's own icon; falls back to a shield. */
|
||||
icon?: ReactNode;
|
||||
/** CSS colour for the badge (matches the policy's accent). */
|
||||
accentColor: string;
|
||||
/** True only just after the policy was applied — drives the one-off glow, so
|
||||
@@ -313,7 +315,9 @@ export function FileItem({
|
||||
className="file-sidebar-policy-badge"
|
||||
style={{ color: policy.accentColor }}
|
||||
>
|
||||
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
|
||||
{policy.icon ?? (
|
||||
<ShieldOutlinedIcon sx={{ fontSize: "0.7rem" }} />
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
@@ -273,6 +273,36 @@
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* A section label rendered as a collapse toggle (Recent Activity): strip the
|
||||
button chrome but keep the .pol-section-label typography, chevron pushed right. */
|
||||
.pol-section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
.pol-section-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-4);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.pol-section-chevron.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Recent-activity feed: cap to ~4.5 rows (and never more than ~45% of the
|
||||
viewport) then scroll, so a long history doesn't push the stats footer away. */
|
||||
.pol-activity-list {
|
||||
max-height: min(22rem, 45vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Sub-section header inside a settings card (e.g. "Output filename"). The field
|
||||
directly below it carries data-first so the borders don't double up. */
|
||||
.pol-subhead {
|
||||
|
||||
@@ -439,6 +439,7 @@ export function PolicyDetailTakeover() {
|
||||
state.backendId,
|
||||
item.fileId as FileId,
|
||||
item.doc,
|
||||
item.runId,
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -7,6 +7,7 @@ import DescriptionIcon from "@mui/icons-material/Description";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import { PanelHeader } from "@shared/components/PanelHeader";
|
||||
@@ -115,6 +116,7 @@ export function PolicyDetailPanel({
|
||||
}: PolicyDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const isPaused = status === "paused";
|
||||
const [activityOpen, setActivityOpen] = useState(true);
|
||||
// Real configured steps drive the flow; fall back to the preset's rule labels.
|
||||
const enforceItems =
|
||||
steps && steps.length > 0
|
||||
@@ -181,70 +183,87 @@ export function PolicyDetailPanel({
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div>
|
||||
<p className="pol-section-label">
|
||||
{t("policies.detail.recentActivity", "Recent Activity")}
|
||||
</p>
|
||||
{activityItems.length > 0 ? (
|
||||
<Card padding="none">
|
||||
{activityItems.map((item, i) => (
|
||||
<ListRow
|
||||
key={item.runId ?? `${item.doc}-${item.time}`}
|
||||
divider={i > 0}
|
||||
leadingTone={
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "processing"
|
||||
? "info"
|
||||
: "success"
|
||||
}
|
||||
leading={
|
||||
item.status === "flagged" ? (
|
||||
<WarningAmberIcon sx={{ fontSize: "0.85rem" }} />
|
||||
) : item.status === "processing" ? (
|
||||
<AutorenewIcon
|
||||
className="pol-spin"
|
||||
sx={{ fontSize: "0.85rem" }}
|
||||
/>
|
||||
) : (
|
||||
<CheckCircleIcon sx={{ fontSize: "0.85rem" }} />
|
||||
)
|
||||
}
|
||||
title={item.doc}
|
||||
description={
|
||||
item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} t={t} />
|
||||
) : (
|
||||
item.action
|
||||
)
|
||||
}
|
||||
meta={item.time}
|
||||
trailing={
|
||||
item.status === "flagged" && onRetry ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRetry(item)}
|
||||
>
|
||||
{t("policies.detail.retry", "Retry")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
className="pol-section-label pol-section-toggle"
|
||||
onClick={() => setActivityOpen((o) => !o)}
|
||||
aria-expanded={activityOpen}
|
||||
>
|
||||
<span>
|
||||
{t("policies.detail.recentActivity", "Recent Activity")}
|
||||
</span>
|
||||
<KeyboardArrowDownIcon
|
||||
className={`pol-section-chevron${activityOpen ? " is-open" : ""}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
</button>
|
||||
{activityOpen &&
|
||||
(activityItems.length > 0 ? (
|
||||
<Card padding="none">
|
||||
<div className="pol-activity-list">
|
||||
{activityItems.map((item, i) => (
|
||||
<ListRow
|
||||
key={item.runId ?? `${item.doc}-${item.time}`}
|
||||
divider={i > 0}
|
||||
leadingTone={
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "processing"
|
||||
? "info"
|
||||
: "success"
|
||||
}
|
||||
leading={
|
||||
item.status === "flagged" ? (
|
||||
<WarningAmberIcon sx={{ fontSize: "0.85rem" }} />
|
||||
) : item.status === "processing" ? (
|
||||
<AutorenewIcon
|
||||
className="pol-spin"
|
||||
sx={{ fontSize: "0.85rem" }}
|
||||
/>
|
||||
) : (
|
||||
<CheckCircleIcon sx={{ fontSize: "0.85rem" }} />
|
||||
)
|
||||
}
|
||||
title={item.doc}
|
||||
description={
|
||||
item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} t={t} />
|
||||
) : (
|
||||
item.action
|
||||
)
|
||||
}
|
||||
meta={item.time}
|
||||
trailing={
|
||||
item.status === "flagged" && onRetry ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRetry(item)}
|
||||
>
|
||||
{t("policies.detail.retry", "Retry")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="default">
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
|
||||
title={t(
|
||||
"policies.detail.noActivityTitle",
|
||||
"No activity yet",
|
||||
)}
|
||||
description={t(
|
||||
"policies.detail.noActivityDescription",
|
||||
"Documents will appear here once this policy runs.",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="default">
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
|
||||
title={t("policies.detail.noActivityTitle", "No activity yet")}
|
||||
description={t(
|
||||
"policies.detail.noActivityDescription",
|
||||
"Documents will appear here once this policy runs.",
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats — one grouped card with divided columns, intentionally
|
||||
|
||||
@@ -22,6 +22,7 @@ export const STATUS_LABEL: Record<PolicyRowStatus, string> = {
|
||||
*/
|
||||
export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
|
||||
ingestion: "blue",
|
||||
classification: "orange",
|
||||
security: "purple",
|
||||
compliance: "green",
|
||||
routing: "amber",
|
||||
|
||||
@@ -8,6 +8,9 @@ export const POLICY_TOOL_CHAINS: Record<string, string[]> = {
|
||||
// Security: redact PII + watermark + sanitize (strips JS). Which are enabled
|
||||
// by default comes from the preset's defaultOperations, not this list.
|
||||
security: ["redact", "watermark", "sanitize"],
|
||||
// Classification: a single backend step that classifies the document and
|
||||
// writes the result into its metadata.
|
||||
classification: ["classify"],
|
||||
};
|
||||
|
||||
/** The configurable tool chain for a category, or null if it has none yet. */
|
||||
|
||||
+62
-1
@@ -33,7 +33,10 @@ vi.mock("@app/contexts/IndexedDBContext", () => ({
|
||||
useIndexedDB: () => ({ bumpRevision: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
|
||||
import {
|
||||
usePolicyAutoRun,
|
||||
runPolicyOnFile,
|
||||
} from "@app/components/policies/usePolicyAutoRun";
|
||||
import {
|
||||
recordRunStart,
|
||||
getRun,
|
||||
@@ -107,3 +110,61 @@ describe("auto-run queue-rejection retry", () => {
|
||||
expect(getRun("run-2")?.status).toBe("PENDING");
|
||||
});
|
||||
});
|
||||
|
||||
describe("manual retry in place", () => {
|
||||
it("replaces the failed run's row instead of stacking a second", async () => {
|
||||
getFile.mockResolvedValue({ size: 999 } as never);
|
||||
runStored.mockResolvedValue("run-new");
|
||||
|
||||
// A previously-failed run sits in the activity feed.
|
||||
recordRunStart({
|
||||
runId: "run-old",
|
||||
categoryId: "security",
|
||||
fileId: "file-1",
|
||||
fileName: "doc.pdf",
|
||||
fileSize: 999,
|
||||
status: "FAILED",
|
||||
outputs: [],
|
||||
error: "AI engine unreachable",
|
||||
startedAt: 0,
|
||||
});
|
||||
|
||||
// Retrying that specific run (passing its id) drops it as the new run records.
|
||||
await runPolicyOnFile(
|
||||
"security",
|
||||
"backend-1",
|
||||
"file-1" as never,
|
||||
"doc.pdf",
|
||||
"run-old",
|
||||
);
|
||||
|
||||
expect(getRun("run-old")).toBeUndefined();
|
||||
expect(getRun("run-new")?.status).toBe("PENDING");
|
||||
});
|
||||
|
||||
it("without a replace id, leaves any existing run untouched", async () => {
|
||||
getFile.mockResolvedValue({ size: 999 } as never);
|
||||
runStored.mockResolvedValue("run-new");
|
||||
recordRunStart({
|
||||
runId: "run-old",
|
||||
categoryId: "security",
|
||||
fileId: "file-2",
|
||||
fileName: "doc2.pdf",
|
||||
fileSize: 999,
|
||||
status: "FAILED",
|
||||
outputs: [],
|
||||
error: "boom",
|
||||
startedAt: 0,
|
||||
});
|
||||
|
||||
await runPolicyOnFile(
|
||||
"security",
|
||||
"backend-1",
|
||||
"file-2" as never,
|
||||
"doc2.pdf",
|
||||
);
|
||||
|
||||
expect(getRun("run-old")).toBeDefined();
|
||||
expect(getRun("run-new")?.status).toBe("PENDING");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Document-classification vocabulary — the single, type-safe source of truth.
|
||||
*
|
||||
* The Python engine can't import TypeScript, so this is GENERATED into
|
||||
* `engine/src/stirling/agents/default_taxonomy.generated.json` by
|
||||
* `editor/scripts/generate-taxonomy.mts` (`task frontend:taxonomy`, drift-guarded
|
||||
* by `task frontend:taxonomy:check`). Edit THIS file, never the generated JSON.
|
||||
*
|
||||
* Shape mirrors the engine's `ClassificationTaxonomy` contract; the camelCase
|
||||
* keys here map onto that model's aliases.
|
||||
*
|
||||
* Override points for later (kept simple now, designed to drop in):
|
||||
* - This is the built-in DEFAULT. A per-org / DB-configured taxonomy is meant to
|
||||
* layer on top, not replace this file.
|
||||
* - The engine already accepts a `taxonomy` per classify request; when one is
|
||||
* supplied it wins, and this default is the fallback.
|
||||
* - The backend `ClassifyTagController.resolveTaxonomyOverride()` is the seam to
|
||||
* load the caller's org/DB taxonomy and pass it through; today it returns none.
|
||||
*/
|
||||
|
||||
/** A specific instrument within a category — category-scoped (e.g. `nda` only
|
||||
* under `contract`); the engine enforces it can't apply to another category. */
|
||||
export interface DocumentType {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DocumentCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Category-scoped tags. */
|
||||
docTypes: DocumentType[];
|
||||
}
|
||||
|
||||
export interface ClassificationTaxonomy {
|
||||
/** Ordered most-common → least-common. */
|
||||
categories: DocumentCategory[];
|
||||
/** Loose, cross-cutting tags that aren't tied to any single category. */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_CLASSIFICATION_TAXONOMY: ClassificationTaxonomy = {
|
||||
categories: [
|
||||
{
|
||||
id: "invoice",
|
||||
label: "Invoice",
|
||||
docTypes: [
|
||||
{ id: "invoice", label: "Invoice" },
|
||||
{ id: "receipt", label: "Receipt" },
|
||||
{ id: "credit_note", label: "Credit note" },
|
||||
{ id: "purchase_order", label: "Purchase order" },
|
||||
{ id: "quote", label: "Quote" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contract",
|
||||
label: "Contract",
|
||||
docTypes: [
|
||||
{ id: "nda", label: "Non-disclosure agreement" },
|
||||
{ id: "employment_agreement", label: "Employment agreement" },
|
||||
{ id: "service_agreement", label: "Service agreement" },
|
||||
{ id: "lease_agreement", label: "Lease agreement" },
|
||||
{ id: "master_service_agreement", label: "Master service agreement" },
|
||||
{ id: "statement_of_work", label: "Statement of work" },
|
||||
{ id: "terms_of_service", label: "Terms of service" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "financial_statement",
|
||||
label: "Financial statement",
|
||||
docTypes: [
|
||||
{ id: "balance_sheet", label: "Balance sheet" },
|
||||
{ id: "income_statement", label: "Income statement" },
|
||||
{ id: "cash_flow_statement", label: "Cash flow statement" },
|
||||
{ id: "bank_statement", label: "Bank statement" },
|
||||
{ id: "annual_report", label: "Annual report" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "report",
|
||||
label: "Report",
|
||||
docTypes: [
|
||||
{ id: "business_report", label: "Business report" },
|
||||
{ id: "project_report", label: "Project report" },
|
||||
{ id: "research_report", label: "Research report" },
|
||||
{ id: "status_report", label: "Status report" },
|
||||
{ id: "incident_report", label: "Incident report" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "letter",
|
||||
label: "Letter",
|
||||
docTypes: [
|
||||
{ id: "business_letter", label: "Business letter" },
|
||||
{ id: "cover_letter", label: "Cover letter" },
|
||||
{ id: "recommendation_letter", label: "Recommendation letter" },
|
||||
{ id: "complaint_letter", label: "Complaint letter" },
|
||||
{ id: "demand_letter", label: "Demand letter" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "form",
|
||||
label: "Form",
|
||||
docTypes: [
|
||||
{ id: "application_form", label: "Application form" },
|
||||
{ id: "registration_form", label: "Registration form" },
|
||||
{ id: "consent_form", label: "Consent form" },
|
||||
{ id: "survey", label: "Survey" },
|
||||
{ id: "questionnaire", label: "Questionnaire" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resume",
|
||||
label: "Resume",
|
||||
docTypes: [
|
||||
{ id: "resume", label: "Resume" },
|
||||
{ id: "curriculum_vitae", label: "Curriculum vitae" },
|
||||
{ id: "portfolio", label: "Portfolio" },
|
||||
{ id: "reference_sheet", label: "Reference sheet" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tax_form",
|
||||
label: "Tax form",
|
||||
docTypes: [
|
||||
{ id: "tax_return", label: "Tax return" },
|
||||
{ id: "w2", label: "W-2" },
|
||||
{ id: "w9", label: "W-9" },
|
||||
{ id: "form_1099", label: "Form 1099" },
|
||||
{ id: "vat_return", label: "VAT return" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "expense_report",
|
||||
label: "Expense report",
|
||||
docTypes: [
|
||||
{ id: "expense_report", label: "Expense report" },
|
||||
{ id: "reimbursement_request", label: "Reimbursement request" },
|
||||
{ id: "mileage_log", label: "Mileage log" },
|
||||
{ id: "per_diem_claim", label: "Per diem claim" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "presentation",
|
||||
label: "Presentation",
|
||||
docTypes: [
|
||||
{ id: "slide_deck", label: "Slide deck" },
|
||||
{ id: "pitch_deck", label: "Pitch deck" },
|
||||
{ id: "training_deck", label: "Training deck" },
|
||||
{ id: "webinar_deck", label: "Webinar deck" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "medical_record",
|
||||
label: "Medical record",
|
||||
docTypes: [
|
||||
{ id: "lab_result", label: "Lab result" },
|
||||
{ id: "prescription", label: "Prescription" },
|
||||
{ id: "discharge_summary", label: "Discharge summary" },
|
||||
{ id: "medical_history", label: "Medical history" },
|
||||
{ id: "imaging_report", label: "Imaging report" },
|
||||
{ id: "vaccination_record", label: "Vaccination record" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "legal_filing",
|
||||
label: "Legal filing",
|
||||
docTypes: [
|
||||
{ id: "court_filing", label: "Court filing" },
|
||||
{ id: "complaint", label: "Complaint" },
|
||||
{ id: "motion", label: "Motion" },
|
||||
{ id: "subpoena", label: "Subpoena" },
|
||||
{ id: "affidavit", label: "Affidavit" },
|
||||
{ id: "deposition", label: "Deposition" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "identity_document",
|
||||
label: "Identity document",
|
||||
docTypes: [
|
||||
{ id: "passport", label: "Passport" },
|
||||
{ id: "drivers_license", label: "Driver's license" },
|
||||
{ id: "national_id", label: "National ID" },
|
||||
{ id: "birth_certificate", label: "Birth certificate" },
|
||||
{ id: "visa", label: "Visa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "insurance",
|
||||
label: "Insurance",
|
||||
docTypes: [
|
||||
{ id: "insurance_policy", label: "Insurance policy" },
|
||||
{ id: "insurance_claim", label: "Insurance claim" },
|
||||
{ id: "certificate_of_insurance", label: "Certificate of insurance" },
|
||||
{ id: "explanation_of_benefits", label: "Explanation of benefits" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "real_estate",
|
||||
label: "Real estate",
|
||||
docTypes: [
|
||||
{ id: "deed", label: "Deed" },
|
||||
{ id: "mortgage_agreement", label: "Mortgage agreement" },
|
||||
{ id: "property_appraisal", label: "Property appraisal" },
|
||||
{ id: "closing_disclosure", label: "Closing disclosure" },
|
||||
{ id: "title_report", label: "Title report" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "shipping",
|
||||
label: "Shipping",
|
||||
docTypes: [
|
||||
{ id: "bill_of_lading", label: "Bill of lading" },
|
||||
{ id: "packing_slip", label: "Packing slip" },
|
||||
{ id: "customs_declaration", label: "Customs declaration" },
|
||||
{ id: "delivery_note", label: "Delivery note" },
|
||||
{ id: "air_waybill", label: "Air waybill" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "hr_document",
|
||||
label: "HR document",
|
||||
docTypes: [
|
||||
{ id: "offer_letter", label: "Offer letter" },
|
||||
{ id: "performance_review", label: "Performance review" },
|
||||
{ id: "payslip", label: "Payslip" },
|
||||
{ id: "employee_handbook", label: "Employee handbook" },
|
||||
{ id: "termination_letter", label: "Termination letter" },
|
||||
{ id: "timesheet", label: "Timesheet" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "academic_record",
|
||||
label: "Academic record",
|
||||
docTypes: [
|
||||
{ id: "transcript", label: "Transcript" },
|
||||
{ id: "diploma", label: "Diploma" },
|
||||
{ id: "certificate", label: "Certificate" },
|
||||
{ id: "syllabus", label: "Syllabus" },
|
||||
{ id: "thesis", label: "Thesis" },
|
||||
{ id: "report_card", label: "Report card" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "marketing_material",
|
||||
label: "Marketing material",
|
||||
docTypes: [
|
||||
{ id: "brochure", label: "Brochure" },
|
||||
{ id: "flyer", label: "Flyer" },
|
||||
{ id: "case_study", label: "Case study" },
|
||||
{ id: "white_paper", label: "White paper" },
|
||||
{ id: "press_release", label: "Press release" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "technical_document",
|
||||
label: "Technical document",
|
||||
docTypes: [
|
||||
{ id: "user_manual", label: "User manual" },
|
||||
{ id: "specification", label: "Specification" },
|
||||
{ id: "api_documentation", label: "API documentation" },
|
||||
{ id: "installation_guide", label: "Installation guide" },
|
||||
{ id: "datasheet", label: "Datasheet" },
|
||||
{ id: "release_notes", label: "Release notes" },
|
||||
],
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
"finance",
|
||||
"legal",
|
||||
"medical",
|
||||
"hr",
|
||||
"tax",
|
||||
"insurance",
|
||||
"marketing",
|
||||
"technical",
|
||||
"operations",
|
||||
"academic",
|
||||
"government",
|
||||
"draft",
|
||||
"final",
|
||||
"signed",
|
||||
"unsigned",
|
||||
"executed",
|
||||
"expired",
|
||||
"amended",
|
||||
"void",
|
||||
"confidential",
|
||||
"internal",
|
||||
"public",
|
||||
"pii",
|
||||
"phi",
|
||||
"certified",
|
||||
"notarized",
|
||||
"scanned",
|
||||
"redacted",
|
||||
"template",
|
||||
"urgent",
|
||||
],
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import PublicIcon from "@mui/icons-material/Public";
|
||||
import CloudIcon from "@mui/icons-material/Cloud";
|
||||
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
|
||||
import type {
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
@@ -42,6 +43,14 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
icon: <ShieldIcon sx={ICON_SX} />,
|
||||
desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
|
||||
},
|
||||
{
|
||||
id: "classification",
|
||||
label: "Classification",
|
||||
icon: <LabelOutlinedIcon sx={ICON_SX} />,
|
||||
desc: "Identify each document's type on upload and tag its metadata for filing and search.",
|
||||
// Needs the AI engine to classify; hidden from the policy list when it's off.
|
||||
requiresAiEngine: true,
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
label: "Compliance",
|
||||
@@ -204,6 +213,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
// output naming + retries are set in the wizard.
|
||||
fields: [],
|
||||
},
|
||||
classification: {
|
||||
summary:
|
||||
"Classifies every uploaded document and writes the result to its metadata.",
|
||||
rules: ["Classify", "Tag metadata"],
|
||||
// Single backend step: classify the document via the AI engine and store the
|
||||
// result in the document's StirlingPDFClassification metadata field.
|
||||
defaultOperations: [{ operation: "classify", parameters: {} }],
|
||||
scopeLabel: "All PDFs on this device",
|
||||
fields: [],
|
||||
},
|
||||
compliance: {
|
||||
summary:
|
||||
"Validates documents against regulatory frameworks before they leave the system.",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ToolType } from "@app/hooks/tools/shared/useToolOperation";
|
||||
|
||||
/** Classify-and-tag takes no user parameters — it only needs the file. */
|
||||
export type ClassifyParameters = Record<string, never>;
|
||||
|
||||
export const defaultParameters: ClassifyParameters = {};
|
||||
|
||||
// Static function shared by the registry/automation executor. The backend reads
|
||||
// only the file: it classifies via the AI engine and writes the result into the
|
||||
// StirlingPDFClassification metadata field.
|
||||
export const buildClassifyFormData = (
|
||||
_parameters: ClassifyParameters,
|
||||
file: File,
|
||||
): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const classifyOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildClassifyFormData,
|
||||
operationType: "classify",
|
||||
endpoint: "/api/v1/ai/tools/classify-and-tag",
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled";
|
||||
import {
|
||||
loadPolicyCatalog,
|
||||
type PolicyCatalog,
|
||||
@@ -10,7 +11,20 @@ import {
|
||||
* directly. Memoised; when the catalog becomes a backend fetch, this hook is
|
||||
* where loading/error state would be introduced — its consumers already treat
|
||||
* it as the single source of definitions.
|
||||
*
|
||||
* Categories flagged {@link PolicyCategory.requiresAiEngine} are hidden while the
|
||||
* AI engine is off, so a policy only appears where it can actually run.
|
||||
*/
|
||||
export function usePolicyCatalog(): PolicyCatalog {
|
||||
return useMemo(() => loadPolicyCatalog(), []);
|
||||
const aiEngineEnabled = useAiEngineEnabled();
|
||||
return useMemo(() => {
|
||||
const catalog = loadPolicyCatalog();
|
||||
if (aiEngineEnabled) return catalog;
|
||||
return {
|
||||
...catalog,
|
||||
categories: catalog.categories.filter(
|
||||
(category) => !category.requiresAiEngine,
|
||||
),
|
||||
};
|
||||
}, [aiEngineEnabled]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useMemo,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { usePolicyRuns } from "@app/components/policies/policyRunStore";
|
||||
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
@@ -18,8 +24,22 @@ const ACCENT_VAR: Record<string, string> = {
|
||||
green: "var(--color-green)",
|
||||
amber: "var(--color-amber)",
|
||||
red: "var(--color-red)",
|
||||
orange: "var(--color-orange)",
|
||||
};
|
||||
|
||||
/** Glyph size for the file-sidebar policy badge. */
|
||||
const BADGE_ICON_SIZE = "0.7rem";
|
||||
|
||||
/** Reuse a policy category's own icon at badge size,
|
||||
* so each badge reflects its policy */
|
||||
function toBadgeIcon(icon: ReactNode): ReactNode {
|
||||
return isValidElement(icon)
|
||||
? cloneElement(icon as ReactElement<{ sx?: object }>, {
|
||||
sx: { fontSize: BADGE_ICON_SIZE },
|
||||
})
|
||||
: icon;
|
||||
}
|
||||
|
||||
/** Minimal provenance shape needed to resolve a file's inherited badges. */
|
||||
type LineageStub = {
|
||||
id: string;
|
||||
@@ -58,6 +78,7 @@ export function buildPolicyBadgeMap(
|
||||
stubs: ReadonlyArray<LineageStub>,
|
||||
labelById: ReadonlyMap<string, string>,
|
||||
now: number,
|
||||
iconById?: ReadonlyMap<string, ReactNode>,
|
||||
): Map<string, FileItemPolicyRef[]> {
|
||||
// Direct badges: a file that IS a policy run's output.
|
||||
const directByFile = new Map<string, FileItemPolicyRef[]>();
|
||||
@@ -71,6 +92,7 @@ export function buildPolicyBadgeMap(
|
||||
list.push({
|
||||
id: run.categoryId,
|
||||
name,
|
||||
icon: iconById?.get(run.categoryId),
|
||||
accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"],
|
||||
recent,
|
||||
});
|
||||
@@ -121,9 +143,17 @@ export function usePolicyFileBadges(): Map<string, FileItemPolicyRef[]> {
|
||||
const runs = usePolicyRuns();
|
||||
const { fileStubs } = useAllFiles();
|
||||
return useMemo(() => {
|
||||
const labelById = new Map(
|
||||
loadPolicyCatalog().categories.map((c) => [c.id, c.label]),
|
||||
const categories = loadPolicyCatalog().categories;
|
||||
const labelById = new Map(categories.map((c) => [c.id, c.label]));
|
||||
const iconById = new Map<string, ReactNode>(
|
||||
categories.map((c) => [c.id, toBadgeIcon(c.icon)]),
|
||||
);
|
||||
return buildPolicyBadgeMap(
|
||||
runs,
|
||||
fileStubs,
|
||||
labelById,
|
||||
Date.now(),
|
||||
iconById,
|
||||
);
|
||||
return buildPolicyBadgeMap(runs, fileStubs, labelById, Date.now());
|
||||
}, [runs, fileStubs]);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ export interface PolicyCategory {
|
||||
* or configured. Only Security is live today.
|
||||
*/
|
||||
comingSoon?: boolean;
|
||||
/**
|
||||
* Requires the AI engine to be enabled. Hidden from the catalog when the
|
||||
* engine is off, so the policy only appears where it can actually run.
|
||||
*/
|
||||
requiresAiEngine?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user