mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Avoid double-charging for classification if it escalates to AI
This commit is contained in:
+34
-5
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user