Run the local heuristic first and only escalate an unsure verdict to the AI

This commit is contained in:
Anthony Stirling
2026-08-19 18:16:40 +01:00
parent a4fd10b156
commit ecfdd7d703
8 changed files with 130 additions and 20 deletions
@@ -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);
});
});
@@ -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.
@@ -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,