mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Run the local heuristic first and only escalate an unsure verdict to the AI
This commit is contained in:
@@ -4552,6 +4552,10 @@ desc = "Change document restrictions and permissions"
|
||||
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
|
||||
title = "Change Permissions"
|
||||
|
||||
[home.classify]
|
||||
desc = "Identify what kind of document this is and tag it."
|
||||
title = "Classify"
|
||||
|
||||
[home.compare]
|
||||
desc = "Compares and shows the differences between 2 PDF Documents"
|
||||
tags = "difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta"
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface StoredStirlingFileRecord extends BaseFileMetadata {
|
||||
// group by label without re-reading PDF bytes, and it survives versioning.
|
||||
// See StirlingFileStub.classificationLabels.
|
||||
classificationLabels?: string[];
|
||||
// See StirlingFileStub.classificationConfidence.
|
||||
classificationConfidence?: "none" | "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
@@ -695,6 +697,7 @@ class FileStorageService {
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
classificationLabels: record.classificationLabels,
|
||||
classificationConfidence: record.classificationConfidence,
|
||||
};
|
||||
|
||||
resolve(stub);
|
||||
@@ -762,6 +765,7 @@ class FileStorageService {
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
classificationLabels: record.classificationLabels,
|
||||
classificationConfidence: record.classificationConfidence,
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
@@ -861,6 +865,7 @@ class FileStorageService {
|
||||
folderId: record.folderId ?? null,
|
||||
createdAt: record.createdAt || Date.now(),
|
||||
classificationLabels: record.classificationLabels,
|
||||
classificationConfidence: record.classificationConfidence,
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
|
||||
@@ -61,6 +61,12 @@ export interface StirlingFileStub extends BaseFileMetadata {
|
||||
* unclassified files / non-SaaS builds.
|
||||
*/
|
||||
classificationLabels?: string[];
|
||||
/**
|
||||
* How sure the local heuristic was about {@link classificationLabels}. Recorded because it
|
||||
* decides whether the AI classifier is asked at all: a confident local verdict stands, an unsure
|
||||
* one is escalated. Undefined when the labels came from the AI rather than the heuristic.
|
||||
*/
|
||||
classificationConfidence?: "none" | "low" | "medium" | "high";
|
||||
/**
|
||||
* This session proved the stored bytes unreadable (WebKit losing a blob's
|
||||
* backing store). The row renders as "data lost" instead of pretending the
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* The rule that decides whether a document costs an AI classification.
|
||||
*
|
||||
* The local heuristic runs on every editor upload; only a high-confidence verdict from it stands
|
||||
* alone. Anything weaker - or not yet computed - goes to the engine, which overwrites it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldDispatchToAi } from "@app/components/policies/usePolicyAutoRun";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
const stub = (
|
||||
confidence?: StirlingFileStub["classificationConfidence"],
|
||||
): StirlingFileStub =>
|
||||
({ id: "f1", classificationConfidence: confidence }) as StirlingFileStub;
|
||||
|
||||
describe("classification escalation", () => {
|
||||
it("trusts only a high-confidence heuristic verdict", () => {
|
||||
expect(shouldDispatchToAi("classification", stub("high"))).toBe(false);
|
||||
});
|
||||
|
||||
it("escalates anything less certain than high", () => {
|
||||
for (const confidence of ["medium", "low", "none"] as const) {
|
||||
expect(shouldDispatchToAi("classification", stub(confidence))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("defers while the heuristic has not reported yet", () => {
|
||||
// Not a skip: dispatching now would race the local pass and pay for an answer it is about to
|
||||
// produce for free. The caller re-evaluates when the verdict lands on the stub.
|
||||
expect(shouldDispatchToAi("classification", stub(undefined))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not gate any other policy category", () => {
|
||||
expect(shouldDispatchToAi("security", stub(undefined))).toBe(true);
|
||||
expect(shouldDispatchToAi("security", stub("high"))).toBe(true);
|
||||
});
|
||||
});
|
||||
+17
-14
@@ -1,5 +1,7 @@
|
||||
// With the AI engine off, the Classification policy runs here in the browser:
|
||||
// each upload is labelled by the heuristic engine and metered for billing parity.
|
||||
// The Classification policy's first pass, and on the editor path it always runs: every upload is
|
||||
// labelled by the local heuristic engine before anything is asked of the AI. The confidence it
|
||||
// reports is what decides whether the AI is asked at all - see usePolicyAutoRun - so a document the
|
||||
// heuristic is sure about never costs an engine call, and an unsure one is escalated.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAllFiles, useFileManagement } from "@app/contexts/FileContext";
|
||||
@@ -7,7 +9,6 @@ import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled";
|
||||
import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled";
|
||||
import { scheduleIdle } from "@app/utils/scheduleIdle";
|
||||
import { usePolicies } from "@app/hooks/usePolicies";
|
||||
import { classifyFileHeuristically } from "@app/services/heuristic/heuristicClassification";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { HeuristicConfidence } from "@app/services/heuristic/types";
|
||||
|
||||
/** The category id of the Classification policy (see policyDefinitions). */
|
||||
const CLASSIFICATION_CATEGORY = "classification";
|
||||
@@ -47,9 +49,8 @@ export function useClientSideClassification(): void {
|
||||
const { bumpRevision } = useIndexedDB();
|
||||
const { policies } = usePolicies();
|
||||
const classificationEnabled = useClassificationEnabled();
|
||||
const aiEnabled = useAiEngineEnabled();
|
||||
// While app-config loads, aiEnabled reads false even on AI-on tenants; classifying
|
||||
// in that window would double-run (and double-bill) files the server also labels.
|
||||
// Still waited on: a verdict written before app-config lands would be acted on by the
|
||||
// escalation decision before it knows whether the AI engine is even available.
|
||||
const { loading: configLoading } = useAppConfig();
|
||||
// Files claimed this session, keyed id+lastModified so a new version is retried once. A claim is
|
||||
// taken synchronously right before classifying, so overlapping batches never double-classify.
|
||||
@@ -69,7 +70,8 @@ export function useClientSideClassification(): void {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (configLoading || !classificationEnabled || aiEnabled || !active) {
|
||||
// Runs whether or not the AI engine is on: it is the first pass either way, not a fallback.
|
||||
if (configLoading || !classificationEnabled || !active) {
|
||||
return;
|
||||
}
|
||||
const claimKey = (s: StirlingFileStub) =>
|
||||
@@ -95,17 +97,19 @@ export function useClientSideClassification(): void {
|
||||
// Re-validate at execution time - another batch may have claimed it since.
|
||||
if (claimed.current.has(key)) continue;
|
||||
claimed.current.add(key);
|
||||
const labels = await classifyStub(stub.id as FileId, stub.name);
|
||||
const verdict = await classifyStub(stub.id as FileId, stub.name);
|
||||
// Bytes never landed (file removed mid-wait): leave undelivered so a
|
||||
// reload (or new version) retries; the claim stops churn this session.
|
||||
if (labels == null) continue;
|
||||
if (verdict == null) continue;
|
||||
// Deliver unconditionally - a re-render must never discard a computed
|
||||
// (and already metered) result. Writes are idempotent.
|
||||
updateStirlingFileStub(stub.id as FileId, {
|
||||
classificationLabels: labels,
|
||||
classificationLabels: verdict.labels,
|
||||
classificationConfidence: verdict.confidence,
|
||||
});
|
||||
const ok = await fileStorage.updateFileMetadata(stub.id as FileId, {
|
||||
classificationLabels: labels,
|
||||
classificationLabels: verdict.labels,
|
||||
classificationConfidence: verdict.confidence,
|
||||
});
|
||||
if (ok) wrote = true;
|
||||
}
|
||||
@@ -122,7 +126,6 @@ export function useClientSideClassification(): void {
|
||||
fileStubs,
|
||||
active,
|
||||
classificationEnabled,
|
||||
aiEnabled,
|
||||
configLoading,
|
||||
updateStirlingFileStub,
|
||||
bumpRevision,
|
||||
@@ -134,7 +137,7 @@ export function useClientSideClassification(): void {
|
||||
async function classifyStub(
|
||||
fileId: FileId,
|
||||
fileName: string,
|
||||
): Promise<string[] | null> {
|
||||
): Promise<{ labels: string[]; confidence: HeuristicConfidence } | null> {
|
||||
let file: StirlingFile | null = null;
|
||||
for (let i = 0; i < FILE_WAIT_TRIES; i++) {
|
||||
file = await fileStorage.getStirlingFile(fileId).catch(() => null);
|
||||
@@ -175,7 +178,7 @@ async function classifyStub(
|
||||
});
|
||||
}
|
||||
markDispatched(CLASSIFICATION_CATEGORY, fileId);
|
||||
return labels;
|
||||
return { labels, confidence: result.confidence };
|
||||
} catch (err) {
|
||||
// Never persist a verdict for an unreadable file - the failure may be
|
||||
// environmental, so it must stay eligible to retry (and meter) later.
|
||||
|
||||
+17
-2
@@ -12,7 +12,11 @@ const FILE_COUNT = 61;
|
||||
// the workbench, mirrored into useAllFiles. consumeFiles mutates it in place
|
||||
// (input id → output id) exactly as the real silent reducer would.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workspace: [] as Array<{ id: string; classificationLabels?: string[] }>,
|
||||
workspace: [] as Array<{
|
||||
id: string;
|
||||
classificationLabels?: string[];
|
||||
classificationConfidence?: "none" | "low" | "medium" | "high";
|
||||
}>,
|
||||
consumeSilentCalls: 0,
|
||||
consumeNonSilentCalls: 0,
|
||||
persistCalls: 0,
|
||||
@@ -117,10 +121,20 @@ function Harness() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The heuristic verdict that escalates to the AI classifier; only "high" stands alone. */
|
||||
const LOW = "low" as const;
|
||||
|
||||
function replaceInWorkspace(inputIds: string[], outputIds: string[]) {
|
||||
// A versioned output carries its input's heuristic verdict; the escalation decision is about the
|
||||
// document, not about which step produced the current bytes.
|
||||
const inherited =
|
||||
mocks.workspace.find((s) => inputIds.includes(s.id))
|
||||
?.classificationConfidence ?? LOW;
|
||||
mocks.workspace = mocks.workspace
|
||||
.filter((s) => !inputIds.includes(s.id))
|
||||
.concat(outputIds.map((id) => ({ id })));
|
||||
.concat(
|
||||
outputIds.map((id) => ({ id, classificationConfidence: inherited })),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -138,6 +152,7 @@ beforeEach(() => {
|
||||
|
||||
mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({
|
||||
id: `file-${i}`,
|
||||
classificationConfidence: LOW,
|
||||
}));
|
||||
|
||||
mocks.listPolicyRuns.mockResolvedValue([]);
|
||||
|
||||
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
|
||||
sourceFileIds?: string[];
|
||||
derivedFromTool?: boolean;
|
||||
classificationLabels?: string[];
|
||||
classificationConfidence?: "none" | "low" | "medium" | "high";
|
||||
}>,
|
||||
runStoredPolicy: vi.fn(),
|
||||
getPolicyRun: vi.fn(),
|
||||
@@ -114,7 +115,7 @@ beforeEach(() => {
|
||||
resetPolicyRuns();
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.workspace = [{ id: "file-0" }];
|
||||
mocks.workspace = [{ id: "file-0", classificationConfidence: "low" }];
|
||||
|
||||
mocks.listPolicyRuns.mockResolvedValue([]);
|
||||
mocks.getStirlingFile.mockResolvedValue(
|
||||
|
||||
@@ -195,8 +195,9 @@ export function usePolicyAutoRun(): void {
|
||||
s.sources.length === 0 ||
|
||||
s.sources.includes("editor")) &&
|
||||
(s.runOn ?? "upload") === "upload" &&
|
||||
// Non-AI systems classify in the browser (useClientSideClassification), so keep the
|
||||
// Classification policy out of the server chain when the AI engine is off.
|
||||
// Classification's first pass is always the local heuristic
|
||||
// (useClientSideClassification). The server chain only ever carries the escalation, so
|
||||
// with no engine to escalate to there is nothing for it to do.
|
||||
!(id === "classification" && !aiEnabled),
|
||||
)
|
||||
// Classification runs last: it's non-blocking, so an enforcement policy
|
||||
@@ -218,6 +219,10 @@ export function usePolicyAutoRun(): void {
|
||||
// Latest policies, read from inside the stable retry callback (which has no deps).
|
||||
const policiesRef = useRef(policies);
|
||||
policiesRef.current = policies;
|
||||
// Latest stubs, for the chaining effect: it keys off runs, not stubs, so it must not add them as
|
||||
// a dependency just to read one file's heuristic confidence.
|
||||
const stubsRef = useRef(fileStubs);
|
||||
stubsRef.current = fileStubs;
|
||||
// Per-file (dispatchKey) count of consecutive queue-rejection retries, so backoff escalates and
|
||||
// eventually gives up. Survives the run-id changing on each retry; reset on any real outcome.
|
||||
const queueRetries = useRef<Map<string, number>>(new Map());
|
||||
@@ -301,6 +306,8 @@ export function usePolicyAutoRun(): void {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// A confident local verdict stands; only an unsure one is escalated to the engine.
|
||||
if (!shouldDispatchToAi(firstCategory, stub)) continue;
|
||||
dispatching.current.add(key);
|
||||
void runPolicyOnFile(firstCategory, backendId, stub.id, stub.name)
|
||||
.catch(() => {
|
||||
@@ -336,6 +343,11 @@ export function usePolicyAutoRun(): void {
|
||||
// ZIP-unpacked) must apply the next policy to all of them, or outputs 2..N silently skip it.
|
||||
for (const outputId of outputIds) {
|
||||
if (isDispatched(nextCategory, outputId as FileId)) continue;
|
||||
const outputStub = stubsRef.current.find((s) => s.id === outputId);
|
||||
// Not yet classified locally: defer rather than skip - this effect re-runs when the
|
||||
// heuristic verdict lands on the stub.
|
||||
if (outputStub && !shouldDispatchToAi(nextCategory, outputStub))
|
||||
continue;
|
||||
void runPolicyOnFile(
|
||||
nextCategory,
|
||||
backendId,
|
||||
@@ -345,7 +357,7 @@ export function usePolicyAutoRun(): void {
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [runs, policies, orderedUploadCategories]);
|
||||
}, [runs, policies, orderedUploadCategories, fileStubs]);
|
||||
|
||||
// Poll each in-flight run to a terminal state.
|
||||
useEffect(() => {
|
||||
@@ -918,6 +930,33 @@ async function importOutputs(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one heuristic verdict trusted to stand on its own.
|
||||
*
|
||||
* <p>The local heuristic runs on every editor upload, but only a high-confidence answer settles the
|
||||
* matter; anything less is escalated to the AI classifier, which overwrites it. Deliberately strict:
|
||||
* a wrong label is worse than the cost of an engine call.
|
||||
*/
|
||||
const TRUSTED_CONFIDENCE = "high";
|
||||
|
||||
/**
|
||||
* Whether the AI classifier should be asked about this file.
|
||||
*
|
||||
* <p>Only for the Classification category, and only once the heuristic has actually reported: a
|
||||
* stub with no confidence yet has not been classified locally, and dispatching then would race the
|
||||
* first pass and bill for an answer it was about to produce for free.
|
||||
*/
|
||||
export function shouldDispatchToAi(
|
||||
categoryId: string,
|
||||
stub: StirlingFileStub,
|
||||
): boolean {
|
||||
if (!isClassificationCategory(categoryId)) {
|
||||
return true;
|
||||
}
|
||||
const confidence = stub.classificationConfidence;
|
||||
return confidence != null && confidence !== TRUSTED_CONFIDENCE;
|
||||
}
|
||||
|
||||
/** Resolve the file's bytes, fire a backend run, and record it. */
|
||||
async function runPolicyOnFile(
|
||||
categoryId: string,
|
||||
|
||||
Reference in New Issue
Block a user