Avoid double-charging for classification if it escalates to AI

This commit is contained in:
James Brunton
2026-09-02 15:03:26 +01:00
parent 73baf60317
commit ae69500438
3 changed files with 50 additions and 10 deletions
@@ -117,8 +117,10 @@ describe("useClientSideClassification delivery", () => {
it("classifies pending uploads, writes labels, and meters once per file", async () => { it("classifies pending uploads, writes labels, and meters once per file", async () => {
mocks.workspace = [stub("a"), stub("b")]; mocks.workspace = [stub("a"), stub("b")];
// A confident verdict stands locally, so the local pass is the billable run.
mocks.classify.mockImplementation(async (file: File) => ({ mocks.classify.mockImplementation(async (file: File) => ({
labels: [file.name.startsWith("a") ? "invoice" : "resume"], labels: [file.name.startsWith("a") ? "invoice" : "resume"],
confidence: "high",
})); }));
renderHook(() => useClientSideClassification()); renderHook(() => useClientSideClassification());
@@ -128,19 +130,43 @@ describe("useClientSideClassification delivery", () => {
); );
expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("a", { expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("a", {
classificationLabels: ["invoice"], classificationLabels: ["invoice"],
classificationConfidence: "high",
}); });
expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("b", { expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("b", {
classificationLabels: ["resume"], classificationLabels: ["resume"],
classificationConfidence: "high",
}); });
expect(mocks.meter).toHaveBeenCalledTimes(2); expect(mocks.meter).toHaveBeenCalledTimes(2);
expect(mocks.bumpRevision).toHaveBeenCalled(); expect(mocks.bumpRevision).toHaveBeenCalled();
}); });
it("does not meter a low-confidence local pass; the AI escalation bills it", async () => {
mocks.workspace = [stub("unsure")];
mocks.classify.mockResolvedValue({
labels: ["maybe-invoice"],
confidence: "low",
});
renderHook(() => useClientSideClassification());
await waitFor(() =>
expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("unsure", {
classificationLabels: ["maybe-invoice"],
classificationConfidence: "low",
}),
);
// Labels still written locally, but no local charge - the file escalates to the AI run.
expect(mocks.meter).not.toHaveBeenCalled();
});
it("delivers a result computed while the effect re-fired mid-batch (upload-wave race)", async () => { it("delivers a result computed while the effect re-fired mid-batch (upload-wave race)", async () => {
let resolveA!: (v: { labels: string[] }) => void; type Verdict = { labels: string[]; confidence: string };
const gateA = new Promise<{ labels: string[] }>((r) => (resolveA = r)); let resolveA!: (v: Verdict) => void;
const gateA = new Promise<Verdict>((r) => (resolveA = r));
mocks.classify.mockImplementation((file: File) => mocks.classify.mockImplementation((file: File) =>
file.name.startsWith("a") ? gateA : Promise.resolve({ labels: ["nda"] }), file.name.startsWith("a")
? gateA
: Promise.resolve({ labels: ["nda"], confidence: "high" }),
); );
mocks.workspace = [stub("a")]; mocks.workspace = [stub("a")];
@@ -152,16 +178,18 @@ describe("useClientSideClassification delivery", () => {
mocks.workspace = [stub("a"), stub("b")]; mocks.workspace = [stub("a"), stub("b")];
rerender(); rerender();
resolveA({ labels: ["purchase-order"] }); resolveA({ labels: ["purchase-order"], confidence: "high" });
await waitFor(() => await waitFor(() =>
expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("a", { expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("a", {
classificationLabels: ["purchase-order"], classificationLabels: ["purchase-order"],
classificationConfidence: "high",
}), }),
); );
// The newly-arrived file classifies too, and neither is double-classified. // The newly-arrived file classifies too, and neither is double-classified.
await waitFor(() => await waitFor(() =>
expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("b", { expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("b", {
classificationLabels: ["nda"], classificationLabels: ["nda"],
classificationConfidence: "high",
}), }),
); );
expect(mocks.classify).toHaveBeenCalledTimes(2); expect(mocks.classify).toHaveBeenCalledTimes(2);
@@ -170,13 +198,14 @@ describe("useClientSideClassification delivery", () => {
it("persists a definitive [] verdict for an unlabelled file and does not retry it", async () => { it("persists a definitive [] verdict for an unlabelled file and does not retry it", async () => {
mocks.workspace = [stub("plain")]; mocks.workspace = [stub("plain")];
mocks.classify.mockResolvedValue({ labels: [] }); mocks.classify.mockResolvedValue({ labels: [], confidence: "high" });
renderHook(() => useClientSideClassification()); renderHook(() => useClientSideClassification());
await waitFor(() => await waitFor(() =>
expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("plain", { expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("plain", {
classificationLabels: [], classificationLabels: [],
classificationConfidence: "high",
}), }),
); );
expect(mocks.classify).toHaveBeenCalledTimes(1); expect(mocks.classify).toHaveBeenCalledTimes(1);
@@ -20,7 +20,10 @@ import {
import type { FileId } from "@app/types/file"; import type { FileId } from "@app/types/file";
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
import type { HeuristicConfidence } from "@app/services/heuristic/types"; import type { HeuristicConfidence } from "@app/services/heuristic/types";
import { CLASSIFICATION_CATEGORY_ID } from "@app/data/classificationPolicy"; import {
CLASSIFICATION_CATEGORY_ID,
localVerdictStands,
} from "@app/data/classificationPolicy";
/** /**
* Dispatch-store key namespace for "this file's local pass has been metered". Deliberately NOT the * Dispatch-store key namespace for "this file's local pass has been metered". Deliberately NOT the
@@ -204,9 +207,10 @@ async function classifyStub(
(alreadyMetered ? " [heal: not re-metered]" : ""), (alreadyMetered ? " [heal: not re-metered]" : ""),
); );
if (debug && result.explain) logExplanation(fileName, result); if (debug && result.explain) logExplanation(fileName, result);
// Meter on the first classification only; a healing re-run of an undelivered // Meter the local pass only when its verdict stands: an unsure verdict escalates to the AI
// result (already dispatched) is not a new billable run. // run, which is billed there instead (avoids double-charging one classification). Metered on
if (!alreadyMetered) { // the first pass only; a healing re-run of an undelivered result is not a new billable run.
if (!alreadyMetered && localVerdictStands(result.confidence)) {
meterAutomationRun({ meterAutomationRun({
automationName: "Classification", automationName: "Classification",
operations: [CLASSIFY_STEP], operations: [CLASSIFY_STEP],
@@ -55,6 +55,13 @@ export function orderRewritesFirst(categoryIds: string[]): string[] {
*/ */
const TRUSTED_CONFIDENCE: ClassificationConfidence = "high"; const TRUSTED_CONFIDENCE: ClassificationConfidence = "high";
/** Whether a local heuristic verdict is final and stands on its own. */
export function localVerdictStands(
confidence: ClassificationConfidence | undefined,
): boolean {
return confidence === TRUSTED_CONFIDENCE;
}
/** /**
* Whether the AI classifier should be asked about this file. For an upload, only once the * Whether the AI classifier should be asked about this file. For an upload, only once the
* heuristic has reported: dispatching before then races the first pass and bills for an answer it * heuristic has reported: dispatching before then races the first pass and bills for an answer it
@@ -69,5 +76,5 @@ export function shouldDispatchToAi(
if (!isClassificationCategory(categoryId)) return true; if (!isClassificationCategory(categoryId)) return true;
const confidence = stub.classificationConfidence; const confidence = stub.classificationConfidence;
if (confidence == null) return Boolean(stub.derivedFromTool); if (confidence == null) return Boolean(stub.derivedFromTool);
return confidence !== TRUSTED_CONFIDENCE; return !localVerdictStands(confidence);
} }