mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Redesign policy running to be generic with local and server passes
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
|
||||
import { useClassificationPolicy } from "@app/components/policies/useClassificationPolicy";
|
||||
import { usePolicyLocalPasses } from "@app/components/policies/usePolicyLocalPasses";
|
||||
|
||||
/**
|
||||
* Headless controller that drives policy auto-run (enforce every enabled policy
|
||||
@@ -7,9 +7,9 @@ import { useClassificationPolicy } from "@app/components/policies/useClassificat
|
||||
* regardless of whether the policy panel is visible. Renders nothing.
|
||||
*/
|
||||
export function PolicyAutoRunController() {
|
||||
// File-producing policies and their chain.
|
||||
// Server-dispatched, file-producing policies and their chain.
|
||||
usePolicyAutoRun();
|
||||
// The Classification policy, which runs itself: local pass, then AI escalation when unsure.
|
||||
useClassificationPolicy();
|
||||
// Policies with a browser-side fast path (e.g. classification's heuristic), run generically.
|
||||
usePolicyLocalPasses();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* The Classification policy's browser-side fast path, as a {@link LocalPass} the generic local-pass
|
||||
* engine runs. Everything classification-specific lives here: the heuristic, the label/confidence it
|
||||
* writes, metering, and the browser-local run it records. The engine only sees the generic result
|
||||
* (fields to write + whether the AI server run is still needed).
|
||||
*/
|
||||
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { classifyFileHeuristically } from "@app/services/heuristic/heuristicClassification";
|
||||
import { meterClassificationRun } from "@app/services/classificationMeter";
|
||||
import {
|
||||
isDispatched,
|
||||
markDispatched,
|
||||
recordRunStart,
|
||||
updateRun,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type { StirlingFile } from "@app/types/fileContext";
|
||||
import type { HeuristicConfidence } from "@app/services/heuristic/types";
|
||||
import {
|
||||
CLASSIFICATION_CATEGORY_ID,
|
||||
localVerdictNeedsEscalation,
|
||||
} from "@app/data/classificationPolicy";
|
||||
import type { LocalPass } from "@app/components/policies/policyLocalPass";
|
||||
|
||||
/** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s).
|
||||
* The stub can surface in the file list a beat before its bytes are committed. */
|
||||
const FILE_WAIT_TRIES = 20;
|
||||
const FILE_WAIT_MS = 250;
|
||||
|
||||
/** localStorage flag: set to "true" for a full per-file scoring breakdown in the console. */
|
||||
const DEBUG_FLAG = "stirling-classification-debug";
|
||||
|
||||
function isClassificationDebug(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DEBUG_FLAG) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const classificationLocalPass: LocalPass = {
|
||||
// A new document that has not been classified yet. Tool outputs inherit their input's verdict via
|
||||
// the file reducer, so they are never classified afresh here.
|
||||
eligible: (stub) =>
|
||||
!stub.derivedFromTool && stub.classificationLabels == null,
|
||||
run: async (fileId, stub) => {
|
||||
const verdict = await classifyStub(fileId, stub.name, stub.size ?? 0);
|
||||
// Bytes never landed (file removed mid-wait): leave unclassified so a reload retries.
|
||||
if (verdict == null) return null;
|
||||
return {
|
||||
stubUpdates: {
|
||||
classificationLabels: verdict.labels,
|
||||
classificationConfidence: verdict.confidence,
|
||||
},
|
||||
// A confident local verdict stands; anything less asks the AI engine, which overwrites it.
|
||||
needsServerRun: localVerdictNeedsEscalation(verdict.confidence),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Classify one file, metering exactly once; null = no verdict, retried later. */
|
||||
async function classifyStub(
|
||||
fileId: FileId,
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
): 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);
|
||||
if (file) break;
|
||||
await delay(FILE_WAIT_MS);
|
||||
}
|
||||
if (!file) {
|
||||
console.warn(
|
||||
`[Classify] ${fileName}: bytes never arrived in storage; will retry on next load`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const debug = isClassificationDebug();
|
||||
const startedAt = performance.now();
|
||||
// A local run is still a billable policy run, so it belongs in the activity feed; recorded only
|
||||
// once the bytes are in hand, so a file whose bytes never land leaves no phantom row.
|
||||
|
||||
// Read before recordRunStart, which takes the dispatch key itself and would otherwise always
|
||||
// answer "already dispatched", silently stopping metering.
|
||||
const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY_ID, fileId);
|
||||
const runId = `local-${CLASSIFICATION_CATEGORY_ID}-${fileId}-${Date.now()}`;
|
||||
recordRunStart({
|
||||
runId,
|
||||
categoryId: CLASSIFICATION_CATEGORY_ID,
|
||||
fileId: fileId as string,
|
||||
fileName,
|
||||
fileSize,
|
||||
target: "local",
|
||||
// The heuristic ran in the browser - there is no server run to poll (see the poll effect).
|
||||
browserLocal: true,
|
||||
status: "RUNNING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
try {
|
||||
const result = await classifyFileHeuristically(file, { explain: debug });
|
||||
const { labels } = result;
|
||||
const ms = Math.round(performance.now() - startedAt);
|
||||
const verdict =
|
||||
labels.length > 0
|
||||
? labels.join(", ")
|
||||
: result.isEnglish
|
||||
? "no label"
|
||||
: "no label (not English)";
|
||||
console.debug(
|
||||
`[Classify] ${fileName} -> ${verdict} (${result.confidence}, score ${result.score}, ${ms}ms)` +
|
||||
(alreadyMetered ? " [heal: not re-metered]" : ""),
|
||||
);
|
||||
if (debug && result.explain) logExplanation(fileName, result);
|
||||
// Meter on the first classification only; a healing re-run of an undelivered
|
||||
// result (already dispatched) is not a new billable run.
|
||||
if (!alreadyMetered) {
|
||||
meterClassificationRun({
|
||||
policyName: "Classification",
|
||||
documentCount: 1,
|
||||
labels,
|
||||
});
|
||||
}
|
||||
markDispatched(CLASSIFICATION_CATEGORY_ID, fileId);
|
||||
// Labels, no output file - the same settle shape the server-run classification uses.
|
||||
updateRun(runId, {
|
||||
status: "COMPLETED",
|
||||
imported: true,
|
||||
outputFileIds: [fileId as string],
|
||||
});
|
||||
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.
|
||||
console.warn(`[Classify] ${fileName}: could not be read, will retry`, err);
|
||||
updateRun(runId, {
|
||||
status: "FAILED",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Full scoring breakdown, one collapsed console group per file (debug flag only). */
|
||||
function logExplanation(
|
||||
fileName: string,
|
||||
result: Awaited<ReturnType<typeof classifyFileHeuristically>>,
|
||||
): void {
|
||||
const ex = result.explain;
|
||||
if (!ex) return;
|
||||
console.groupCollapsed(
|
||||
`[Classify] ${fileName} scoring (english=${ex.isEnglish}, lowText=${ex.lowText})`,
|
||||
);
|
||||
if (ex.candidates.length === 0) {
|
||||
console.log("no label scored above zero");
|
||||
}
|
||||
for (const c of ex.candidates) {
|
||||
console.log(
|
||||
`${c.id}${c.emit ? "" : " (suppressed)"}: score ${c.score}, ${c.distinct} distinct signals`,
|
||||
);
|
||||
for (const s of c.signals) console.log(` ${s}`);
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* The generic "browser-side fast path" seam. A policy may declare a {@link LocalPass}: cheap local
|
||||
* work that runs before any server dispatch and can settle a file on its own, or decide the server
|
||||
* run is still needed. The local-pass engine ({@link ../../hooks/usePolicyLocalPasses}) runs it
|
||||
* without knowing what it computes; the policy-specific logic lives entirely inside the pass.
|
||||
*/
|
||||
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { CLASSIFICATION_CATEGORY_ID } from "@app/data/classificationPolicy";
|
||||
import { classificationLocalPass } from "@app/components/policies/classificationLocalPass";
|
||||
|
||||
export interface LocalPassResult {
|
||||
/** Fields to merge onto the file's stub and stored metadata. Opaque to the engine. */
|
||||
stubUpdates: Partial<StirlingFileStub>;
|
||||
/** Whether the policy's server run should still be dispatched after this pass. */
|
||||
needsServerRun: boolean;
|
||||
}
|
||||
|
||||
export interface LocalPass {
|
||||
/** Files this pass should run on (e.g. new documents it has not processed yet). */
|
||||
eligible(stub: StirlingFileStub): boolean;
|
||||
/**
|
||||
* Do the local work for one file. Returns the stub fields to write and whether the server run is
|
||||
* still needed, or null if the work could not be done and should be retried later.
|
||||
*/
|
||||
run(fileId: FileId, stub: StirlingFileStub): Promise<LocalPassResult | null>;
|
||||
}
|
||||
|
||||
/** The local fast path a policy declares, if any. The default (most policies) is none. */
|
||||
export function localPassFor(categoryId: string): LocalPass | undefined {
|
||||
if (categoryId === CLASSIFICATION_CATEGORY_ID) return classificationLocalPass;
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAllFiles, useFileManagement } from "@app/contexts/FileContext";
|
||||
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";
|
||||
import { meterClassificationRun } from "@app/services/classificationMeter";
|
||||
import { runPolicyOnFile } from "@app/services/policyDispatch";
|
||||
import {
|
||||
isDispatched,
|
||||
markDispatched,
|
||||
recordRunStart,
|
||||
updateRun,
|
||||
usePolicyRuns,
|
||||
} 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";
|
||||
import {
|
||||
CLASSIFICATION_CATEGORY_ID,
|
||||
localVerdictNeedsEscalation,
|
||||
orderedRewritingCategories,
|
||||
} from "@app/data/classificationPolicy";
|
||||
|
||||
/** Files classified per idle pass, so a large library drains over several ticks. */
|
||||
const CLASSIFY_BATCH = 3;
|
||||
/** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s).
|
||||
* The stub can surface in the file list a beat before its bytes are committed. */
|
||||
const FILE_WAIT_TRIES = 20;
|
||||
const FILE_WAIT_MS = 250;
|
||||
|
||||
/** localStorage flag: set to "true" for a full per-file scoring breakdown in the console. */
|
||||
const DEBUG_FLAG = "stirling-classification-debug";
|
||||
|
||||
function isClassificationDebug(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DEBUG_FLAG) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function useClassificationPolicy(): void {
|
||||
const { fileStubs } = useAllFiles();
|
||||
const { updateStirlingFileStub } = useFileManagement();
|
||||
const { bumpRevision } = useIndexedDB();
|
||||
const { policies } = usePolicies();
|
||||
const classificationEnabled = useClassificationEnabled();
|
||||
const aiEnabled = useAiEngineEnabled();
|
||||
// Still waited on: a verdict written before app-config lands would be escalated before it knows
|
||||
// whether the AI engine is even available.
|
||||
const { loading: configLoading } = useAppConfig();
|
||||
const runs = usePolicyRuns();
|
||||
// Read inside the effect without re-firing it every status poll; the effect keys off the file list
|
||||
// and the settled-outputs signal below instead.
|
||||
const runsRef = useRef(runs);
|
||||
runsRef.current = runs;
|
||||
// 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.
|
||||
const claimed = useRef<Set<string>>(new Set());
|
||||
// Bumped after each batch to drain the next one.
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
// TODO: keyed on the Classification CATEGORY, so a pipeline that merely contains a classify
|
||||
// step gets no local pass - suppressing one step of a chain is not expressible today.
|
||||
const policy = policies[CLASSIFICATION_CATEGORY_ID];
|
||||
const backendId = policy?.backendId;
|
||||
// Only when the admin has an active Classification policy - the same gate the file-producing
|
||||
// policies use in the auto-run engine.
|
||||
const active = Boolean(
|
||||
policy?.configured &&
|
||||
policy.status === "active" &&
|
||||
backendId &&
|
||||
policy.runsOnEditor &&
|
||||
(policy.runOn ?? "upload") === "upload",
|
||||
);
|
||||
|
||||
// The file-producing policies whose chain classification waits behind: it runs on the last one's
|
||||
// output, not on an upload a rewrite is about to change. Empty means classification runs on uploads.
|
||||
const rewriters = useMemo(
|
||||
() => orderedRewritingCategories(policies),
|
||||
[policies],
|
||||
);
|
||||
const lastRewriter = rewriters.at(-1);
|
||||
|
||||
// A stable key of the final (last-rewriter) output ids, so the effect re-runs when a chain settles
|
||||
// a new document but not on every unrelated status poll.
|
||||
const settledOutputsKey = useMemo(() => {
|
||||
if (!lastRewriter) return "";
|
||||
return runs
|
||||
.filter((r) => r.categoryId === lastRewriter && r.status === "COMPLETED")
|
||||
.flatMap((r) => r.outputFileIds ?? [])
|
||||
.sort()
|
||||
.join(",");
|
||||
}, [runs, lastRewriter]);
|
||||
|
||||
useEffect(() => {
|
||||
// Runs whether or not the AI engine is on: the local pass is the first pass either way. Escalation
|
||||
// below is what needs the engine.
|
||||
if (configLoading || !classificationEnabled || !active) {
|
||||
return;
|
||||
}
|
||||
const claimKey = (s: StirlingFileStub) =>
|
||||
`${s.id as string}:${s.lastModified ?? 0}`;
|
||||
// A document is ours to classify once no rewrite will change it: an upload when nothing rewrites,
|
||||
// or the output of the last rewriter in a chain (identified by that run, so an upload a rewrite is
|
||||
// about to change is never picked up early).
|
||||
const isSettledLeaf = (s: StirlingFileStub): boolean => {
|
||||
if (!lastRewriter) return !s.derivedFromTool;
|
||||
return runsRef.current.some(
|
||||
(r) =>
|
||||
r.categoryId === lastRewriter &&
|
||||
r.status === "COMPLETED" &&
|
||||
(r.outputFileIds ?? []).includes(s.id as string),
|
||||
);
|
||||
};
|
||||
// null labels = never classified, retried here; [] = definitive no-label verdict.
|
||||
const pending = fileStubs
|
||||
.filter(
|
||||
(s) =>
|
||||
s.classificationLabels == null &&
|
||||
!claimed.current.has(claimKey(s)) &&
|
||||
isSettledLeaf(s),
|
||||
)
|
||||
.slice(0, CLASSIFY_BATCH);
|
||||
if (pending.length === 0) return;
|
||||
let cancelled = false;
|
||||
const cancelIdle = scheduleIdle(() => {
|
||||
// Superseded before starting: the newer effect instance owns the queue.
|
||||
if (cancelled) return;
|
||||
void (async () => {
|
||||
let wrote = false;
|
||||
for (const stub of pending) {
|
||||
const key = claimKey(stub);
|
||||
// Re-validate at execution time - another batch may have claimed it since.
|
||||
if (claimed.current.has(key)) continue;
|
||||
claimed.current.add(key);
|
||||
const verdict = await classifyStub(
|
||||
stub.id,
|
||||
stub.name,
|
||||
stub.size ?? 0,
|
||||
);
|
||||
// Bytes never landed (file removed mid-wait): leave unclassified so a
|
||||
// reload (or new version) retries; the claim stops churn this session.
|
||||
if (verdict == null) continue;
|
||||
// Deliver unconditionally - a re-render must never discard a computed
|
||||
// (and already metered) result. Writes are idempotent.
|
||||
updateStirlingFileStub(stub.id, {
|
||||
classificationLabels: verdict.labels,
|
||||
classificationConfidence: verdict.confidence,
|
||||
});
|
||||
const ok = await fileStorage.updateFileMetadata(stub.id, {
|
||||
classificationLabels: verdict.labels,
|
||||
classificationConfidence: verdict.confidence,
|
||||
});
|
||||
if (ok) wrote = true;
|
||||
// Escalate an unsure verdict to the AI engine, which overwrites it. A chained output jumps
|
||||
// the dispatch queue so a file mid-flow finishes before new uploads start.
|
||||
if (
|
||||
aiEnabled &&
|
||||
backendId &&
|
||||
localVerdictNeedsEscalation(verdict.confidence)
|
||||
) {
|
||||
void runPolicyOnFile(
|
||||
CLASSIFICATION_CATEGORY_ID,
|
||||
backendId,
|
||||
stub.id,
|
||||
stub.name,
|
||||
Boolean(stub.derivedFromTool),
|
||||
).catch(() => {
|
||||
// Backstop: runPolicyOnFile handles its own failures.
|
||||
});
|
||||
}
|
||||
}
|
||||
if (wrote) bumpRevision();
|
||||
// Drain the next batch; the terminal pass finds nothing pending and stops.
|
||||
setTick((n) => n + 1);
|
||||
})();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdle();
|
||||
};
|
||||
}, [
|
||||
fileStubs,
|
||||
active,
|
||||
aiEnabled,
|
||||
backendId,
|
||||
classificationEnabled,
|
||||
configLoading,
|
||||
lastRewriter,
|
||||
settledOutputsKey,
|
||||
updateStirlingFileStub,
|
||||
bumpRevision,
|
||||
tick,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Classify one file, metering exactly once; null = no verdict, retried later. */
|
||||
async function classifyStub(
|
||||
fileId: FileId,
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
): 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);
|
||||
if (file) break;
|
||||
await delay(FILE_WAIT_MS);
|
||||
}
|
||||
if (!file) {
|
||||
console.warn(
|
||||
`[Classify] ${fileName}: bytes never arrived in storage; will retry on next load`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const debug = isClassificationDebug();
|
||||
const startedAt = performance.now();
|
||||
// A local run is still a billable policy run, so it belongs in the activity feed; recorded only
|
||||
// once the bytes are in hand, so a file whose bytes never land leaves no phantom row.
|
||||
|
||||
// Read before recordRunStart, which takes the dispatch key itself and would otherwise always
|
||||
// answer "already dispatched", silently stopping metering.
|
||||
const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY_ID, fileId);
|
||||
const runId = `local-${CLASSIFICATION_CATEGORY_ID}-${fileId}-${Date.now()}`;
|
||||
recordRunStart({
|
||||
runId,
|
||||
categoryId: CLASSIFICATION_CATEGORY_ID,
|
||||
fileId: fileId as string,
|
||||
fileName,
|
||||
fileSize,
|
||||
target: "local",
|
||||
// The heuristic ran in the browser - there is no server run to poll (see the poll effect).
|
||||
browserLocal: true,
|
||||
status: "RUNNING",
|
||||
outputs: [],
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
try {
|
||||
const result = await classifyFileHeuristically(file, { explain: debug });
|
||||
const { labels } = result;
|
||||
const ms = Math.round(performance.now() - startedAt);
|
||||
const verdict =
|
||||
labels.length > 0
|
||||
? labels.join(", ")
|
||||
: result.isEnglish
|
||||
? "no label"
|
||||
: "no label (not English)";
|
||||
console.debug(
|
||||
`[Classify] ${fileName} -> ${verdict} (${result.confidence}, score ${result.score}, ${ms}ms)` +
|
||||
(alreadyMetered ? " [heal: not re-metered]" : ""),
|
||||
);
|
||||
if (debug && result.explain) logExplanation(fileName, result);
|
||||
// Meter on the first classification only; a healing re-run of an undelivered
|
||||
// result (already dispatched) is not a new billable run.
|
||||
if (!alreadyMetered) {
|
||||
meterClassificationRun({
|
||||
policyName: "Classification",
|
||||
documentCount: 1,
|
||||
labels,
|
||||
});
|
||||
}
|
||||
markDispatched(CLASSIFICATION_CATEGORY_ID, fileId);
|
||||
// Labels, no output file - the same settle shape the server-run classification uses.
|
||||
updateRun(runId, {
|
||||
status: "COMPLETED",
|
||||
imported: true,
|
||||
outputFileIds: [fileId as string],
|
||||
});
|
||||
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.
|
||||
console.warn(`[Classify] ${fileName}: could not be read, will retry`, err);
|
||||
updateRun(runId, {
|
||||
status: "FAILED",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Full scoring breakdown, one collapsed console group per file (debug flag only). */
|
||||
function logExplanation(
|
||||
fileName: string,
|
||||
result: Awaited<ReturnType<typeof classifyFileHeuristically>>,
|
||||
): void {
|
||||
const ex = result.explain;
|
||||
if (!ex) return;
|
||||
console.groupCollapsed(
|
||||
`[Classify] ${fileName} scoring (english=${ex.isEnglish}, lowText=${ex.lowText})`,
|
||||
);
|
||||
if (ex.candidates.length === 0) {
|
||||
console.log("no label scored above zero");
|
||||
}
|
||||
for (const c of ex.candidates) {
|
||||
console.log(
|
||||
`${c.id}${c.emit ? "" : " (suppressed)"}: score ${c.score}, ${c.distinct} distinct signals`,
|
||||
);
|
||||
for (const s of c.signals) console.log(` ${s}`);
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
+14
-10
@@ -124,14 +124,14 @@ vi.mock("@app/services/classificationMeter", () => ({
|
||||
}));
|
||||
|
||||
import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun";
|
||||
import { useClassificationPolicy } from "@app/components/policies/useClassificationPolicy";
|
||||
import { usePolicyLocalPasses } from "@app/components/policies/usePolicyLocalPasses";
|
||||
import {
|
||||
usePolicyRuns,
|
||||
resetPolicyRuns,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
|
||||
|
||||
// Run idle callbacks immediately so the classification hook's batches start without timer waits.
|
||||
// Run idle callbacks immediately so the local-pass engine's batches start without timer waits.
|
||||
vi.stubGlobal("requestIdleCallback", (cb: () => void) => {
|
||||
cb();
|
||||
return 1;
|
||||
@@ -142,12 +142,15 @@ vi.stubGlobal("cancelIdleCallback", () => {});
|
||||
let latestRuns: PolicyRunRecord[] = [];
|
||||
function Harness() {
|
||||
usePolicyAutoRun();
|
||||
useClassificationPolicy();
|
||||
usePolicyLocalPasses();
|
||||
latestRuns = usePolicyRuns();
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceInWorkspace(inputIds: string[], outputIds: string[]) {
|
||||
// A versioned output inherits its input's classification verdict, exactly as the real CONSUME_FILES
|
||||
// reducer does - so a label put on the upload rides forward without re-classifying the output.
|
||||
const donor = mocks.workspace.find((s) => inputIds.includes(s.id));
|
||||
mocks.workspace = mocks.workspace
|
||||
.filter((s) => !inputIds.includes(s.id))
|
||||
.concat(
|
||||
@@ -155,6 +158,8 @@ function replaceInWorkspace(inputIds: string[], outputIds: string[]) {
|
||||
id,
|
||||
name: "doc.pdf",
|
||||
derivedFromTool: true,
|
||||
classificationLabels: donor?.classificationLabels,
|
||||
classificationConfidence: donor?.classificationConfidence,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -335,19 +340,18 @@ describe("policy auto-run — 61-file batch through a Security → Classificatio
|
||||
await act(async () => {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const imported = latestRuns.filter((r) => r.imported).length;
|
||||
expect(imported).toBe(FILE_COUNT);
|
||||
const security = latestRuns.filter(
|
||||
(r) => r.categoryId === "security" && r.imported,
|
||||
);
|
||||
expect(security).toHaveLength(FILE_COUNT);
|
||||
},
|
||||
{ timeout: 8000, interval: 20 },
|
||||
);
|
||||
});
|
||||
|
||||
// Security's versions went to STORAGE, never re-added to the workbench, so the
|
||||
// workspace stays empty. Classification needs the file in the workbench to tag,
|
||||
// so a closed file is left unclassified rather than re-opened.
|
||||
expect(latestRuns.filter((r) => r.categoryId === "security")).toHaveLength(
|
||||
FILE_COUNT,
|
||||
);
|
||||
// workspace stays empty. (Classification may have tagged the few files still open
|
||||
// when the workbench was cleared; the point here is the runner does not re-open them.)
|
||||
expect(mocks.workspace).toHaveLength(0);
|
||||
expect(mocks.consumeSilentCalls).toBe(0);
|
||||
expect(mocks.persistCalls).toBeGreaterThan(0);
|
||||
|
||||
+13
-14
@@ -87,7 +87,7 @@ vi.mock("@app/services/classificationMeter", () => ({
|
||||
meterClassificationRun: (payload: unknown) => mocks.meter(payload),
|
||||
}));
|
||||
|
||||
import { useClassificationPolicy } from "@app/components/policies/useClassificationPolicy";
|
||||
import { usePolicyLocalPasses } from "@app/components/policies/usePolicyLocalPasses";
|
||||
|
||||
// Run idle callbacks immediately so batches start without timer waits.
|
||||
vi.stubGlobal("requestIdleCallback", (cb: () => void) => {
|
||||
@@ -105,7 +105,7 @@ const stub = (id: string, extra: Partial<TestStub> = {}): TestStub => ({
|
||||
|
||||
const fakeFile = (id: string) => new File([id], `${id}.pdf`);
|
||||
|
||||
describe("useClassificationPolicy delivery", () => {
|
||||
describe("usePolicyLocalPasses delivery", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetPolicyRuns();
|
||||
@@ -132,7 +132,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
labels: [file.name.startsWith("a") ? "invoice" : "resume"],
|
||||
}));
|
||||
|
||||
renderHook(() => useClassificationPolicy());
|
||||
renderHook(() => usePolicyLocalPasses());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.updateStirlingFileStub).toHaveBeenCalledTimes(2),
|
||||
@@ -155,7 +155,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
);
|
||||
mocks.workspace = [stub("a")];
|
||||
|
||||
const { rerender } = renderHook(() => useClassificationPolicy());
|
||||
const { rerender } = renderHook(() => usePolicyLocalPasses());
|
||||
await waitFor(() => expect(mocks.classify).toHaveBeenCalledTimes(1));
|
||||
|
||||
// A new upload mid-classify re-fires the effect and cancels the in-flight
|
||||
@@ -183,7 +183,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
mocks.workspace = [stub("plain")];
|
||||
mocks.classify.mockResolvedValue({ labels: [] });
|
||||
|
||||
renderHook(() => useClassificationPolicy());
|
||||
renderHook(() => usePolicyLocalPasses());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("plain", {
|
||||
@@ -200,7 +200,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
mocks.workspace = [stub("lost")];
|
||||
mocks.classify.mockResolvedValue({ labels: ["bank-statement"] });
|
||||
|
||||
renderHook(() => useClassificationPolicy());
|
||||
renderHook(() => usePolicyLocalPasses());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("lost", {
|
||||
@@ -217,7 +217,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
mocks.workspace = [stub("corrupt")];
|
||||
mocks.classify.mockRejectedValue(new Error("bad pdf"));
|
||||
|
||||
renderHook(() => useClassificationPolicy());
|
||||
renderHook(() => usePolicyLocalPasses());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
@@ -239,7 +239,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
mocks.workspace = [stub("early")];
|
||||
mocks.classify.mockResolvedValue({ labels: ["invoice"] });
|
||||
|
||||
const { rerender } = renderHook(() => useClassificationPolicy());
|
||||
const { rerender } = renderHook(() => usePolicyLocalPasses());
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(mocks.classify).not.toHaveBeenCalled();
|
||||
|
||||
@@ -260,7 +260,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
stub("verdict", { classificationLabels: [] }),
|
||||
];
|
||||
|
||||
renderHook(() => useClassificationPolicy());
|
||||
renderHook(() => usePolicyLocalPasses());
|
||||
|
||||
// Nothing to classify; give the (immediate) idle path a beat to prove it.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
@@ -275,7 +275,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
confidence: "low",
|
||||
});
|
||||
|
||||
renderHook(() => useClassificationPolicy());
|
||||
renderHook(() => usePolicyLocalPasses());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.runPolicyOnFile).toHaveBeenCalledWith(
|
||||
@@ -283,7 +283,6 @@ describe("useClassificationPolicy delivery", () => {
|
||||
"backend-classification",
|
||||
"a",
|
||||
"a.pdf",
|
||||
false, // an upload, not a chained output
|
||||
),
|
||||
);
|
||||
// The local verdict is still delivered before escalation.
|
||||
@@ -301,7 +300,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
renderHook(() => useClassificationPolicy());
|
||||
renderHook(() => usePolicyLocalPasses());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("a", {
|
||||
@@ -320,7 +319,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
confidence: "none",
|
||||
});
|
||||
|
||||
renderHook(() => useClassificationPolicy());
|
||||
renderHook(() => usePolicyLocalPasses());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("a", {
|
||||
@@ -342,7 +341,7 @@ describe("useClassificationPolicy delivery", () => {
|
||||
confidence: "low",
|
||||
});
|
||||
|
||||
renderHook(() => useClassificationPolicy());
|
||||
renderHook(() => usePolicyLocalPasses());
|
||||
|
||||
// Give the (immediate) idle path a beat to prove it stays silent.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Generic engine for policies' browser-side fast paths. Any active editor policy that declares a
|
||||
* {@link LocalPass} has it run here: eligible files are classified/processed locally, the returned
|
||||
* fields are written to the stub, and the policy's server run is dispatched only if the pass says it
|
||||
* is still needed (and the AI engine, if the policy needs it, is on).
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAllFiles, useFileManagement } from "@app/contexts/FileContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled";
|
||||
import { scheduleIdle } from "@app/utils/scheduleIdle";
|
||||
import { usePolicies } from "@app/hooks/usePolicies";
|
||||
import { runPolicyOnFile } from "@app/services/policyDispatch";
|
||||
import {
|
||||
localPassFor,
|
||||
type LocalPass,
|
||||
} from "@app/components/policies/policyLocalPass";
|
||||
import { policyRequiresAiEngine } from "@app/data/classificationPolicy";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
/** Files processed per idle pass, so a large upload drains over several ticks instead of janking. */
|
||||
const LOCAL_PASS_BATCH = 3;
|
||||
|
||||
interface ActivePass {
|
||||
categoryId: string;
|
||||
backendId: string;
|
||||
pass: LocalPass;
|
||||
/** When true, the server run is skipped while the AI engine is off (nothing to escalate to). */
|
||||
requiresAiEngine: boolean;
|
||||
}
|
||||
|
||||
export function usePolicyLocalPasses(): void {
|
||||
const { fileStubs } = useAllFiles();
|
||||
const { updateStirlingFileStub } = useFileManagement();
|
||||
const { bumpRevision } = useIndexedDB();
|
||||
const { policies } = usePolicies();
|
||||
const aiEnabled = useAiEngineEnabled();
|
||||
// Waited on so a verdict is not written and escalated before it is known whether the AI engine
|
||||
// (which the server run may need) is even available.
|
||||
const { loading: configLoading } = useAppConfig();
|
||||
// Files claimed this session, keyed policy+id+lastModified so a new version is retried once. Claimed
|
||||
// synchronously right before running, so overlapping batches never double-process.
|
||||
const claimed = useRef<Set<string>>(new Set());
|
||||
// Bumped after each batch to drain the next one.
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
// Active editor upload policies that declare a local fast path.
|
||||
const passes = useMemo<ActivePass[]>(() => {
|
||||
const out: ActivePass[] = [];
|
||||
for (const [categoryId, s] of Object.entries(policies)) {
|
||||
const active =
|
||||
s.configured &&
|
||||
s.status === "active" &&
|
||||
s.backendId &&
|
||||
s.runsOnEditor &&
|
||||
(s.runOn ?? "upload") === "upload";
|
||||
if (!active) continue;
|
||||
const pass = localPassFor(categoryId);
|
||||
if (!pass) continue;
|
||||
out.push({
|
||||
categoryId,
|
||||
backendId: s.backendId as string,
|
||||
pass,
|
||||
requiresAiEngine: policyRequiresAiEngine(categoryId),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [policies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (configLoading || passes.length === 0) return;
|
||||
const claimKey = (categoryId: string, s: StirlingFileStub) =>
|
||||
`${categoryId}:${s.id as string}:${s.lastModified ?? 0}`;
|
||||
// Collect one idle batch of pending (pass, file) work across all passes.
|
||||
const batch: { active: ActivePass; stub: StirlingFileStub }[] = [];
|
||||
outer: for (const active of passes) {
|
||||
for (const stub of fileStubs) {
|
||||
if (batch.length >= LOCAL_PASS_BATCH) break outer;
|
||||
if (!active.pass.eligible(stub)) continue;
|
||||
if (claimed.current.has(claimKey(active.categoryId, stub))) continue;
|
||||
batch.push({ active, stub });
|
||||
}
|
||||
}
|
||||
if (batch.length === 0) return;
|
||||
let cancelled = false;
|
||||
const cancelIdle = scheduleIdle(() => {
|
||||
// Superseded before starting: the newer effect instance owns the queue.
|
||||
if (cancelled) return;
|
||||
void (async () => {
|
||||
let wrote = false;
|
||||
for (const { active, stub } of batch) {
|
||||
const key = claimKey(active.categoryId, stub);
|
||||
// Re-validate at execution time - another batch may have claimed it since.
|
||||
if (claimed.current.has(key)) continue;
|
||||
claimed.current.add(key);
|
||||
const result = await active.pass.run(stub.id, stub);
|
||||
// Could not run (e.g. bytes not in storage yet): leave unprocessed so a reload retries.
|
||||
if (result == null) continue;
|
||||
// Deliver unconditionally - a re-render must never discard a computed result. Writes are
|
||||
// idempotent. The engine applies the fields the pass returned without reading them.
|
||||
updateStirlingFileStub(stub.id, result.stubUpdates);
|
||||
const ok = await fileStorage.updateFileMetadata(
|
||||
stub.id,
|
||||
result.stubUpdates,
|
||||
);
|
||||
if (ok) wrote = true;
|
||||
// Dispatch the server run only if the pass still wants it, and skip it while an
|
||||
// AI-engine-dependent policy has no engine to reach.
|
||||
if (
|
||||
result.needsServerRun &&
|
||||
!(active.requiresAiEngine && !aiEnabled)
|
||||
) {
|
||||
void runPolicyOnFile(
|
||||
active.categoryId,
|
||||
active.backendId,
|
||||
stub.id,
|
||||
stub.name,
|
||||
).catch(() => {
|
||||
// Backstop: runPolicyOnFile handles its own failures.
|
||||
});
|
||||
}
|
||||
}
|
||||
if (wrote) bumpRevision();
|
||||
// Drain the next batch; the terminal pass finds nothing pending and stops.
|
||||
setTick((n) => n + 1);
|
||||
})();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdle();
|
||||
};
|
||||
}, [
|
||||
fileStubs,
|
||||
passes,
|
||||
aiEnabled,
|
||||
configLoading,
|
||||
updateStirlingFileStub,
|
||||
bumpRevision,
|
||||
tick,
|
||||
]);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Everything specific to the built-in Classification policy, in one module. The generic policy
|
||||
* runner dispatches and chains only file-producing policies (see {@link orderedRewritingCategories});
|
||||
* classification runs itself - local heuristic first, escalating an unsure verdict to the AI - so the
|
||||
* runner never learns it has two ways to run. A second annotating policy is a change here, not there.
|
||||
* Everything specific to the built-in Classification policy, in one module. The generic policy runner
|
||||
* dispatches and chains only file-producing policies (see {@link orderedRewritingCategories}), and the
|
||||
* generic local-pass engine runs whatever browser-side fast path a policy declares. Classification's
|
||||
* fast path (its heuristic) lives in classificationLocalPass; the capability answers below let the
|
||||
* generic engines treat it without naming it. A second annotating policy is a change here, not there.
|
||||
*
|
||||
* These are still keyed on the category id rather than a property each policy declares. That is
|
||||
* deliberate for now: policies are becoming pipelines with labels behind a separate enforcement
|
||||
@@ -35,6 +36,14 @@ export function policyDeliversOutputFiles(categoryId: string): boolean {
|
||||
return policyRewritesDocument(categoryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the policy's server run needs the AI engine. The local-pass engine skips dispatching such
|
||||
* a run when the engine is off - there is nothing to escalate to, and the local verdict stands.
|
||||
*/
|
||||
export function policyRequiresAiEngine(categoryId: string): boolean {
|
||||
return isClassificationCategory(categoryId);
|
||||
}
|
||||
|
||||
/** Order annotating policies last; everything else keeps the order it was given. */
|
||||
export function orderRewritesFirst(categoryIds: string[]): string[] {
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user