mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Split out of #7574 — this is the classification half, which is independent of the editor-source work and can land on its own. ## What this does - **Runs the local heuristic first and only escalates an unsure verdict to the AI.** A high-confidence local answer stands; anything less (or a file the heuristic hasn't reached yet) goes to the engine. A wrong label costs more than an engine call, so the bar is deliberately strict. - **Makes `classify` an authorable pipeline task**, so it can be used as a step like any other tool, and skips files that are already classified. - **Leaves the seeded Classification policy unowned** rather than naming a `system` placeholder that was never a real user; existing seeds are repaired on boot. ## Review feedback applied From @jbrunton96 on #7574: - **The generic runner no longer names classification.** Everything classification-specific moved into `proprietary/data/classificationPolicy.ts`, and `usePolicyAutoRun` now asks capability questions instead: `policyRewritesDocument`, `policyDeliversOutputFiles`, `policyRequiresAiEngine`, `shouldDispatchToAi`. There is no `id === "classification"` left in the runner. - **Ordering is no longer a name in the runner.** `pinClassificationLast` is gone; the runner sorts annotating policies after rewriting ones. The constraint is real: an annotating policy is non-blocking, so a rewriting one running after it forks from the pre-annotation version and drops the labels. To be straight about what this is and isn't - see "Still open" below - `policyRewritesDocument` is still keyed on the category id, not on a property each policy declares. The check moved out of the runner; it did not stop being a check on one id. - **Confidence is typed.** New `ClassificationConfidence` union in `core/types/fileContext.ts`, reused by `fileStorage`, `HeuristicConfidence`, and the trusted-verdict constant instead of being respelled at each site. - **Comments trimmed** to the repo's 2-line guideline, and a stale seeder javadoc that still claimed an internal-user owner was corrected. ## Still open, deliberately `classificationPolicy.ts` answers its capability questions with `categoryId === "classification"`. That is the same check relocated, not removed, and the module doc now says so outright. Deliberate, for two reasons: - **The concept it would be declared against is going away.** Policies are becoming pipelines with labels behind a separate enforcement layer, which removes the category the flag would live on. A capability system built on `categoryId` today gets migrated twice. - **Classification is genuinely privileged, not accidentally special.** It is the only policy with a browser-side implementation, so it can answer without the server. That is a product decision, and a local-only mode for set scenarios is planned - the flag for it should be designed with that feature, not guessed at now. The end state for the rest: an in-place output mode retires the ordering rule and `policyDeliversOutputFiles`, and a run result that can carry findings as well as files retires the remainder. Both touch the import path, which is the most delicate code in `usePolicyAutoRun` - not something to bolt on to a PR that has already been split once. Nothing is broken by leaving it. A user-built classify pipeline still gets its labels: the generic import path reads them off the returned PDF. It versions the file instead of labelling in place, and it misses the local-heuristic shortcut, so it always bills the engine. ## Testing - `classificationPolicy.test.ts` — 12 cases covering each capability and the escalation rule - Full frontend `proprietary` project: 39 files / 442 tests - `:proprietary:test` for `DefaultClassificationPolicySeederTest` + `ClassifyLabelControllerTest` - `tsc --noEmit` on core, proprietary, portal, saas, desktop, cloud --------- Co-authored-by: James Brunton <jbrunton96@gmail.com>
245 lines
9.3 KiB
TypeScript
245 lines
9.3 KiB
TypeScript
// The Classification policy's first pass: every upload is labelled locally before the AI is asked.
|
||
// The confidence reported here decides whether the AI is asked at all - see usePolicyAutoRun.
|
||
|
||
import { useEffect, 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 { 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 {
|
||
isDispatched,
|
||
markDispatched,
|
||
recordRunStart,
|
||
updateRun,
|
||
} 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 } 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 useClientSideClassification(): void {
|
||
const { fileStubs } = useAllFiles();
|
||
const { updateStirlingFileStub } = useFileManagement();
|
||
const { bumpRevision } = useIndexedDB();
|
||
const { policies } = usePolicies();
|
||
const classificationEnabled = useClassificationEnabled();
|
||
// 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.
|
||
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];
|
||
// Only when the admin has an active Classification policy - the same gate the AI path uses.
|
||
const active = Boolean(
|
||
policy?.configured &&
|
||
policy.status === "active" &&
|
||
policy.backendId &&
|
||
(!policy.sources ||
|
||
policy.sources.length === 0 ||
|
||
policy.sources.includes("editor")),
|
||
);
|
||
|
||
useEffect(() => {
|
||
// 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) =>
|
||
`${s.id as string}:${s.lastModified ?? 0}`;
|
||
// null labels = never delivered, retried here; [] = definitive no-label verdict.
|
||
const pending = fileStubs
|
||
.filter(
|
||
(s) =>
|
||
!s.derivedFromTool &&
|
||
s.classificationLabels == null &&
|
||
!claimed.current.has(claimKey(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 as FileId,
|
||
stub.name,
|
||
stub.size ?? 0,
|
||
);
|
||
// Bytes never landed (file removed mid-wait): leave undelivered 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 as FileId, {
|
||
classificationLabels: verdict.labels,
|
||
classificationConfidence: verdict.confidence,
|
||
});
|
||
const ok = await fileStorage.updateFileMetadata(stub.id as FileId, {
|
||
classificationLabels: verdict.labels,
|
||
classificationConfidence: verdict.confidence,
|
||
});
|
||
if (ok) wrote = true;
|
||
}
|
||
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,
|
||
classificationEnabled,
|
||
configLoading,
|
||
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",
|
||
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();
|
||
}
|