buyer: PDF book summarizer (/book) — chunk → map-reduce over suppliers
Adds an authenticated upload flow to the buyer web app: an operator uploads a PDF, the server extracts + paragraph-aligned-chunks it (~2.2k tokens), then runs a hierarchical map-reduce summarization by paying marketplace suppliers per chunk — one on-chain escrow per map call and per reduce node. One book upload fans out into dozens of real mainnet escrows (the intended marketplace traffic) while producing a genuine full-book summary. Server module (buyer/src/pdf/): PDF extraction (text-only, rejects scanned), token-aware chunker, supplier discovery/round-robin filtered to capable models (kimi/deepseek/gpt; excludes qwen2.5:0.5b), pre-spend cost estimate + wallet-floor guard, and a JobStore orchestrator that reuses submitPrompt → findSubmittedEscrowRef → runAccept per call. v1 serializes PostEscrow under a mutex (the shared tx builder can't pin wallet inputs, so parallel escrows would double-spend); retries failed calls on a different supplier then continues with gaps. 6 /v1/pdf-* routes (multipart upload, estimate, confirm-gated start, SSE progress, result, .md download) behind the existing session auth + page/chunk/ balance caps. SPA adds a 4-step BookSummarizer wizard. Tests: chunk/estimate/supplier-pool/extract unit tests + orchestrator (happy/gap/failover) + routes integration. Full buyer suite green (181 tests); SPA build + typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
64
tests/unit/buyer-pdf-chunk.test.ts
Normal file
64
tests/unit/buyer-pdf-chunk.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* buyer-pdf-chunk.test.ts — the paragraph/sentence-aligned chunker must never
|
||||
* cut a sentence in half, must respect the target size, and must be
|
||||
* deterministic.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { chunkText } from "../../buyer/src/pdf/chunk.js";
|
||||
|
||||
describe("chunkText", () => {
|
||||
const sentences = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => `This is sentence number ${i + 1} with some filler words added to bump the token count up.`,
|
||||
);
|
||||
const prose = sentences.join(" ");
|
||||
|
||||
it("never splits mid-sentence — every chunk ends on sentence punctuation", () => {
|
||||
const chunks = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const c of chunks) {
|
||||
expect(/[.!?]$/.test(c.text.trim())).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves every sentence verbatim (no loss, no breakage)", () => {
|
||||
const chunks = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
|
||||
for (const s of sentences) {
|
||||
expect(chunks.some((c) => c.text.includes(s))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("assigns sequential 0-based indices", () => {
|
||||
const chunks = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
|
||||
chunks.forEach((c, i) => expect(c.index).toBe(i));
|
||||
});
|
||||
|
||||
it("is deterministic", () => {
|
||||
const a = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
|
||||
const b = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
|
||||
expect(b.map((c) => c.text)).toEqual(a.map((c) => c.text));
|
||||
});
|
||||
|
||||
it("keeps each chunk within a bounded size of the target", () => {
|
||||
const chunks = chunkText(prose, { targetTokens: 60, maxTokens: 200 });
|
||||
for (const c of chunks) {
|
||||
// packing flushes before exceeding target, so a chunk is at most
|
||||
// target + one unit (a single sentence here, ~20 tokens) ≤ maxTokens.
|
||||
expect(c.tokenEstimate).toBeLessThanOrEqual(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("hard-splits a single oversize sentence (no punctuation) without losing words", () => {
|
||||
const giant = Array.from({ length: 400 }, (_, i) => `word${i}`).join(" ");
|
||||
const chunks = chunkText(giant, { targetTokens: 50, maxTokens: 80 });
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
const reassembled = chunks.map((c) => c.text).join(" ").split(/\s+/).sort();
|
||||
expect(reassembled).toEqual(giant.split(/\s+/).sort());
|
||||
});
|
||||
|
||||
it("returns no chunks for empty/whitespace input", () => {
|
||||
expect(chunkText("", { targetTokens: 60, maxTokens: 200 })).toEqual([]);
|
||||
expect(chunkText(" \n\n ", { targetTokens: 60, maxTokens: 200 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
60
tests/unit/buyer-pdf-estimate.test.ts
Normal file
60
tests/unit/buyer-pdf-estimate.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* buyer-pdf-estimate.test.ts — reduce-tree call counting + cost bound math.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { reduceCallCount, estimateJob, feeHeadroomLovelace } from "../../buyer/src/pdf/estimate.js";
|
||||
import { SupplierPool } from "../../buyer/src/pdf/supplier-pool.js";
|
||||
import type { PoolSupplier } from "../../buyer/src/pdf/types.js";
|
||||
|
||||
function sup(price: bigint): PoolSupplier {
|
||||
return {
|
||||
advertRef: { txHash: "a".repeat(64), index: 0 },
|
||||
utxoRef: `${"a".repeat(64)}#0`,
|
||||
supplierPkh: "pkh",
|
||||
model: "kimi",
|
||||
priceLovelace: price,
|
||||
maxOutputTokens: 512,
|
||||
endpointUrl: "http://x",
|
||||
};
|
||||
}
|
||||
|
||||
describe("reduceCallCount", () => {
|
||||
it("matches the F-ary tree", () => {
|
||||
expect(reduceCallCount(90, 8)).toBe(15); // 12 + 2 + 1
|
||||
expect(reduceCallCount(8, 8)).toBe(1);
|
||||
expect(reduceCallCount(64, 8)).toBe(9); // 8 + 1
|
||||
expect(reduceCallCount(1, 8)).toBe(0);
|
||||
expect(reduceCallCount(0, 8)).toBe(0);
|
||||
expect(reduceCallCount(2, 8)).toBe(1);
|
||||
});
|
||||
|
||||
it("is safe for degenerate fan-in", () => {
|
||||
expect(reduceCallCount(10, 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateJob", () => {
|
||||
it("bounds total with the priciest supplier and counts map+reduce", () => {
|
||||
const pool = new SupplierPool([sup(2_000_000n), sup(3_000_000n)]);
|
||||
const est = estimateJob(90, 8, pool);
|
||||
expect(est.mapCalls).toBe(90);
|
||||
expect(est.reduceCalls).toBe(15);
|
||||
expect(est.totalCalls).toBe(105);
|
||||
expect(est.perCallMaxLovelace).toBe("3000000");
|
||||
expect(est.totalLovelace).toBe((3_000_000n * 105n).toString());
|
||||
expect(est.totalAp3x).toBe("315.00");
|
||||
});
|
||||
|
||||
it("handles a single-chunk job (no reduce)", () => {
|
||||
const pool = new SupplierPool([sup(2_000_000n)]);
|
||||
const est = estimateJob(1, 8, pool);
|
||||
expect(est.totalCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("feeHeadroomLovelace", () => {
|
||||
it("scales with call count plus a fixed buffer", () => {
|
||||
expect(feeHeadroomLovelace(10)).toBe(12_000_000n);
|
||||
});
|
||||
});
|
||||
27
tests/unit/buyer-pdf-extract.test.ts
Normal file
27
tests/unit/buyer-pdf-extract.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* buyer-pdf-extract.test.ts — extraction error handling. A non-PDF buffer must
|
||||
* surface as a structured PdfExtractionError (not an unhandled throw), so the
|
||||
* upload route can map it to a clean 4xx.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractPdfText } from "../../buyer/src/pdf/extract.js";
|
||||
import { PdfExtractionError } from "../../buyer/src/pdf/types.js";
|
||||
|
||||
describe("extractPdfText", () => {
|
||||
it("throws a structured PdfExtractionError on a non-PDF buffer", async () => {
|
||||
await expect(extractPdfText(Buffer.from("definitely not a pdf"))).rejects.toBeInstanceOf(
|
||||
PdfExtractionError,
|
||||
);
|
||||
});
|
||||
|
||||
it("error carries a machine-readable reason", async () => {
|
||||
try {
|
||||
await extractPdfText(Buffer.from("nope"));
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(PdfExtractionError);
|
||||
expect((e as PdfExtractionError).reason).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
198
tests/unit/buyer-pdf-job.test.ts
Normal file
198
tests/unit/buyer-pdf-job.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* buyer-pdf-job.test.ts — the map-reduce orchestrator, exercised with an
|
||||
* injected per-call lifecycle (no chain/lucid). Covers the happy path,
|
||||
* cost/coverage accounting, SSE done signalling, and the retry→gap policy.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { JobStore, type RunCallFn } from "../../buyer/src/pdf/summarize-job.js";
|
||||
import { loadPdfCaps } from "../../buyer/src/pdf/caps.js";
|
||||
import type { Chunk } from "../../buyer/src/pdf/types.js";
|
||||
import type { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
|
||||
import type { SupplierView } from "../../buyer/src/sdk/types.js";
|
||||
import type { ChainProvider } from "@marketplace/shared/chain";
|
||||
import type { WalletKey } from "@marketplace/shared/tx";
|
||||
|
||||
function supplierView(model: string, ref: string, price: string): SupplierView {
|
||||
return {
|
||||
utxo_ref: ref,
|
||||
supplier_pkh: `pkh_${model}`,
|
||||
capability_id: "llm.text.generate.v1",
|
||||
model,
|
||||
max_output_tokens: 512,
|
||||
max_processing_ms: 60000,
|
||||
price_lovelace: price,
|
||||
supplier_bond_lovelace: "1000000",
|
||||
buyer_bond_lovelace: "1000000",
|
||||
endpoint_url: "http://supplier",
|
||||
detail_uri: "",
|
||||
detail_hash: "",
|
||||
advertised_at: 0,
|
||||
status: "active",
|
||||
advert_status: "Active",
|
||||
current_escrow_ref: null,
|
||||
last_seen_iso: null,
|
||||
created_slot: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const MARKETPLACE = {
|
||||
discoverSuppliers: async () => [
|
||||
supplierView("kimi-k2", `${"a".repeat(64)}#0`, "2000000"),
|
||||
supplierView("deepseek", `${"b".repeat(64)}#0`, "3000000"),
|
||||
],
|
||||
} as unknown as Marketplace;
|
||||
|
||||
const WALLET = { address: "addr_test", pubKeyHash: "pkh", pubKeyHex: "", privateKeyHex: "0".repeat(64) } as WalletKey;
|
||||
const CHAIN = {} as ChainProvider;
|
||||
|
||||
function chunks(n: number): Chunk[] {
|
||||
return Array.from({ length: n }, (_, i) => ({ index: i, text: `chunk ${i} text`, tokenEstimate: 5 }));
|
||||
}
|
||||
|
||||
function waitForDone(store: JobStore, job: { subscribe: (f: (frame: string) => void) => () => void }): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
job.subscribe((frame) => {
|
||||
if (frame.startsWith("event: done")) resolve();
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
store.start(job as any);
|
||||
});
|
||||
}
|
||||
|
||||
describe("JobStore orchestrator", () => {
|
||||
it("estimates map + reduce calls", async () => {
|
||||
const caps = loadPdfCaps({ PDF_RETRY_K: "1" });
|
||||
const store = new JobStore({
|
||||
marketplace: MARKETPLACE,
|
||||
chain: CHAIN,
|
||||
walletKey: WALLET,
|
||||
indexerUrl: "http://indexer",
|
||||
caps,
|
||||
walletBalance: async () => 10_000_000_000n,
|
||||
});
|
||||
const job = store.createJob("book.pdf", 10, chunks(20));
|
||||
const est = await store.estimate(job);
|
||||
expect(est.mapCalls).toBe(20);
|
||||
expect(est.reduceCalls).toBe(reduceExpected(20, caps.reduceFanin));
|
||||
expect(est.no_capable_suppliers).toBe(false);
|
||||
expect(est.would_drop_below_floor).toBe(false);
|
||||
expect(est.perCallMaxLovelace).toBe("3000000"); // priciest supplier
|
||||
});
|
||||
|
||||
it("flags would_drop_below_floor when balance is too low", async () => {
|
||||
const caps = loadPdfCaps({ PDF_WALLET_FLOOR_LOVELACE: "50000000" });
|
||||
const store = new JobStore({
|
||||
marketplace: MARKETPLACE,
|
||||
chain: CHAIN,
|
||||
walletKey: WALLET,
|
||||
indexerUrl: "http://indexer",
|
||||
caps,
|
||||
walletBalance: async () => 1_000_000n, // basically empty
|
||||
});
|
||||
const job = store.createJob("book.pdf", 1, chunks(3));
|
||||
const est = await store.estimate(job);
|
||||
expect(est.would_drop_below_floor).toBe(true);
|
||||
});
|
||||
|
||||
it("runs map+reduce to a final summary, accounting cost + escrows", async () => {
|
||||
const caps = loadPdfCaps({ PDF_RETRY_K: "1" });
|
||||
let n = 0;
|
||||
const runCall: RunCallFn = async (sup, prompt) => {
|
||||
n += 1;
|
||||
return {
|
||||
response: `S[${prompt.slice(0, 14)}]`,
|
||||
escrowRef: `${"f".repeat(64)}#${n}`,
|
||||
supplierPkh: sup.supplierPkh,
|
||||
model: sup.model,
|
||||
receipt: { supplier_pkh: sup.supplierPkh, model: sup.model },
|
||||
receiptSignature: "sig",
|
||||
};
|
||||
};
|
||||
const store = new JobStore({
|
||||
marketplace: MARKETPLACE,
|
||||
chain: CHAIN,
|
||||
walletKey: WALLET,
|
||||
indexerUrl: "http://indexer",
|
||||
caps,
|
||||
runCall,
|
||||
walletBalance: async () => 10_000_000_000n,
|
||||
});
|
||||
const job = store.createJob("book.pdf", 5, chunks(5));
|
||||
await waitForDone(store, job);
|
||||
|
||||
expect(job.status).toBe("completed");
|
||||
expect(job.coverageDone).toBe(5);
|
||||
expect(typeof job.finalSummary).toBe("string");
|
||||
expect((job.finalSummary as string).length).toBeGreaterThan(0);
|
||||
const expectedCalls = 5 + reduceExpected(5, caps.reduceFanin);
|
||||
expect(job.escrowRefs.length).toBe(expectedCalls);
|
||||
// round-robin mixes 2M + 3M priced suppliers; cost is the sum actually paid.
|
||||
expect(BigInt(job.view().running_cost_lovelace)).toBeGreaterThan(0n);
|
||||
});
|
||||
|
||||
it("retries a failing chunk on a different supplier, then marks a gap", async () => {
|
||||
const caps = loadPdfCaps({ PDF_RETRY_K: "1" });
|
||||
const runCall: RunCallFn = async (sup, prompt) => {
|
||||
// chunk index 1 → prompt contains "[chunk 2]" → always fail (both suppliers)
|
||||
if (prompt.includes("[chunk 2]")) {
|
||||
throw new Error("supplier exploded");
|
||||
}
|
||||
return {
|
||||
response: `S`,
|
||||
escrowRef: `${"f".repeat(64)}#${Math.floor(Math.random() * 1e6)}`,
|
||||
supplierPkh: sup.supplierPkh,
|
||||
model: sup.model,
|
||||
receipt: {},
|
||||
receiptSignature: "sig",
|
||||
};
|
||||
};
|
||||
const store = new JobStore({
|
||||
marketplace: MARKETPLACE,
|
||||
chain: CHAIN,
|
||||
walletKey: WALLET,
|
||||
indexerUrl: "http://indexer",
|
||||
caps,
|
||||
runCall,
|
||||
walletBalance: async () => 10_000_000_000n,
|
||||
});
|
||||
const job = store.createJob("book.pdf", 3, chunks(3));
|
||||
await waitForDone(store, job);
|
||||
|
||||
expect(job.status).toBe("completed_with_gaps");
|
||||
expect(job.coverageDone).toBe(2); // chunks 0 and 2 only
|
||||
expect(job.failedCount).toBeGreaterThanOrEqual(1);
|
||||
expect(job.chunkResults[1].status).toBe("gap");
|
||||
expect(typeof job.finalSummary).toBe("string");
|
||||
});
|
||||
|
||||
it("fails cleanly when no capable suppliers exist", async () => {
|
||||
const emptyMarket = { discoverSuppliers: async () => [] } as unknown as Marketplace;
|
||||
const store = new JobStore({
|
||||
marketplace: emptyMarket,
|
||||
chain: CHAIN,
|
||||
walletKey: WALLET,
|
||||
indexerUrl: "http://indexer",
|
||||
caps: loadPdfCaps({}),
|
||||
runCall: async () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
walletBalance: async () => 10_000_000_000n,
|
||||
});
|
||||
const job = store.createJob("book.pdf", 1, chunks(2));
|
||||
await waitForDone(store, job);
|
||||
expect(job.status).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
/** Mirror of estimate.reduceCallCount for the expected value in assertions. */
|
||||
function reduceExpected(n: number, fanin: number): number {
|
||||
if (n <= 1) return 0;
|
||||
let total = 0;
|
||||
let level = n;
|
||||
while (level > 1) {
|
||||
level = Math.ceil(level / fanin);
|
||||
total += level;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
164
tests/unit/buyer-pdf-routes.test.ts
Normal file
164
tests/unit/buyer-pdf-routes.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* buyer-pdf-routes.test.ts — the /v1/pdf-* HTTP surface, via supertest against
|
||||
* createApp. Auth is unconfigured in these tests (no password/sessionSecret),
|
||||
* so the routes are reachable directly — same construction shape as
|
||||
* buyer-server-synth-speech.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createApp } from "../../buyer/src/server.js";
|
||||
import { JobStore, type RunCallFn } from "../../buyer/src/pdf/summarize-job.js";
|
||||
import { loadPdfCaps } from "../../buyer/src/pdf/caps.js";
|
||||
import type { Chunk } from "../../buyer/src/pdf/types.js";
|
||||
import type { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
|
||||
import type { SupplierView } from "../../buyer/src/sdk/types.js";
|
||||
import type { ChainProvider } from "@marketplace/shared/chain";
|
||||
import type { WalletKey } from "@marketplace/shared/tx";
|
||||
|
||||
function supplierView(model: string, ref: string, price: string): SupplierView {
|
||||
return {
|
||||
utxo_ref: ref,
|
||||
supplier_pkh: `pkh_${model}`,
|
||||
capability_id: "llm.text.generate.v1",
|
||||
model,
|
||||
max_output_tokens: 512,
|
||||
max_processing_ms: 60000,
|
||||
price_lovelace: price,
|
||||
supplier_bond_lovelace: "1000000",
|
||||
buyer_bond_lovelace: "1000000",
|
||||
endpoint_url: "http://supplier",
|
||||
detail_uri: "",
|
||||
detail_hash: "",
|
||||
advertised_at: 0,
|
||||
status: "active",
|
||||
advert_status: "Active",
|
||||
current_escrow_ref: null,
|
||||
last_seen_iso: null,
|
||||
created_slot: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const MARKETPLACE = {
|
||||
discoverSuppliers: async () => [supplierView("kimi-k2", `${"a".repeat(64)}#0`, "2000000")],
|
||||
} as unknown as Marketplace;
|
||||
const WALLET = { address: "addr_test", pubKeyHash: "pkh", pubKeyHex: "", privateKeyHex: "0".repeat(64) } as WalletKey;
|
||||
const CHAIN = {} as ChainProvider;
|
||||
|
||||
function makeStore(opts?: { balance?: bigint; caps?: Record<string, string> }): JobStore {
|
||||
let n = 0;
|
||||
const runCall: RunCallFn = async (sup) => {
|
||||
n += 1;
|
||||
return {
|
||||
response: `summary ${n}`,
|
||||
escrowRef: `${"f".repeat(64)}#${n}`,
|
||||
supplierPkh: sup.supplierPkh,
|
||||
model: sup.model,
|
||||
receipt: {},
|
||||
receiptSignature: "sig",
|
||||
};
|
||||
};
|
||||
return new JobStore({
|
||||
marketplace: MARKETPLACE,
|
||||
chain: CHAIN,
|
||||
walletKey: WALLET,
|
||||
indexerUrl: "http://indexer",
|
||||
caps: loadPdfCaps(opts?.caps ?? {}),
|
||||
runCall,
|
||||
walletBalance: async () => opts?.balance ?? 10_000_000_000n,
|
||||
});
|
||||
}
|
||||
|
||||
function chunks(n: number): Chunk[] {
|
||||
return Array.from({ length: n }, (_, i) => ({ index: i, text: `chunk ${i}`, tokenEstimate: 5 }));
|
||||
}
|
||||
|
||||
describe("/v1/pdf-* routes", () => {
|
||||
it("503s every pdf route when the feature is disabled (no jobStore)", async () => {
|
||||
const app = createApp({});
|
||||
const { default: request } = await import("supertest");
|
||||
const up = await request(app).post("/v1/pdf-upload").send({});
|
||||
expect(up.status).toBe(503);
|
||||
const job = await request(app).get("/v1/pdf-jobs/whatever");
|
||||
expect(job.status).toBe(503);
|
||||
});
|
||||
|
||||
it("rejects an upload with no file (400 file_required)", async () => {
|
||||
const app = createApp({ jobStore: makeStore(), pdfCaps: loadPdfCaps({}) });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post("/v1/pdf-upload");
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("file_required");
|
||||
});
|
||||
|
||||
it("rejects a non-PDF upload (422)", async () => {
|
||||
const app = createApp({ jobStore: makeStore(), pdfCaps: loadPdfCaps({}) });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app)
|
||||
.post("/v1/pdf-upload")
|
||||
.attach("file", Buffer.from("this is not a pdf"), "junk.pdf");
|
||||
expect(res.status).toBe(422);
|
||||
expect(["pdf_parse_failed", "scanned_or_image_pdf", "no_text"]).toContain(res.body.error);
|
||||
});
|
||||
|
||||
it("404s estimate for an unknown job", async () => {
|
||||
const app = createApp({ jobStore: makeStore(), pdfCaps: loadPdfCaps({}) });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).get("/v1/pdf-jobs/nope/estimate");
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("job_not_found");
|
||||
});
|
||||
|
||||
it("estimate → confirm-gate → start → run → summary (seeded job)", async () => {
|
||||
const store = makeStore();
|
||||
const seeded = store.createJob("MyBook.pdf", 4, chunks(4));
|
||||
const app = createApp({ jobStore: store, pdfCaps: loadPdfCaps({}) });
|
||||
const { default: request } = await import("supertest");
|
||||
|
||||
// estimate
|
||||
const est = await request(app).get(`/v1/pdf-jobs/${seeded.jobId}/estimate`);
|
||||
expect(est.status).toBe(200);
|
||||
expect(est.body.mapCalls).toBe(4);
|
||||
expect(est.body.would_drop_below_floor).toBe(false);
|
||||
|
||||
// start without confirm → 400
|
||||
const noConfirm = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({});
|
||||
expect(noConfirm.status).toBe(400);
|
||||
expect(noConfirm.body.error).toBe("not_confirmed");
|
||||
|
||||
// start with confirm → 202
|
||||
const start = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({ confirm: true });
|
||||
expect(start.status).toBe(202);
|
||||
|
||||
// poll until finished
|
||||
let view = seeded.view();
|
||||
for (let i = 0; i < 100 && view.status === "running"; i++) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const v = await request(app).get(`/v1/pdf-jobs/${seeded.jobId}`);
|
||||
view = v.body;
|
||||
}
|
||||
expect(view.status).toBe("completed");
|
||||
expect(view.coverage).toEqual({ done: 4, total: 4 });
|
||||
expect(view.escrow_refs.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// double-start now 409
|
||||
const again = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({ confirm: true });
|
||||
expect(again.status).toBe(409);
|
||||
|
||||
// summary download
|
||||
const md = await request(app).get(`/v1/pdf-jobs/${seeded.jobId}/summary.md`);
|
||||
expect(md.status).toBe(200);
|
||||
expect(md.headers["content-type"]).toMatch(/text\/markdown/);
|
||||
expect(md.headers["content-disposition"]).toContain("MyBook-summary.md");
|
||||
expect(md.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("refuses to start when spend would drop below the wallet floor (402)", async () => {
|
||||
const store = makeStore({ balance: 1_000_000n, caps: { PDF_WALLET_FLOOR_LOVELACE: "50000000" } });
|
||||
const seeded = store.createJob("book.pdf", 1, chunks(3));
|
||||
const app = createApp({ jobStore: store, pdfCaps: loadPdfCaps({ PDF_WALLET_FLOOR_LOVELACE: "50000000" }) });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post(`/v1/pdf-jobs/${seeded.jobId}/start`).send({ confirm: true });
|
||||
expect(res.status).toBe(402);
|
||||
expect(res.body.error).toBe("below_wallet_floor");
|
||||
});
|
||||
});
|
||||
108
tests/unit/buyer-pdf-supplier-pool.test.ts
Normal file
108
tests/unit/buyer-pdf-supplier-pool.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* buyer-pdf-supplier-pool.test.ts — capability filtering (must drop the weak
|
||||
* qwen model and non-parseable demo refs) + round-robin with failover.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isCapableModel,
|
||||
filterSuppliers,
|
||||
SupplierPool,
|
||||
} from "../../buyer/src/pdf/supplier-pool.js";
|
||||
import { loadPdfCaps } from "../../buyer/src/pdf/caps.js";
|
||||
import type { SupplierView } from "../../buyer/src/sdk/types.js";
|
||||
|
||||
const caps = loadPdfCaps({});
|
||||
|
||||
function view(p: Partial<SupplierView>): SupplierView {
|
||||
return {
|
||||
utxo_ref: `${"a".repeat(64)}#0`,
|
||||
supplier_pkh: "pkh",
|
||||
capability_id: "llm.text.generate.v1",
|
||||
model: "kimi-k2",
|
||||
max_output_tokens: 512,
|
||||
max_processing_ms: 60000,
|
||||
price_lovelace: "2000000",
|
||||
supplier_bond_lovelace: "1000000",
|
||||
buyer_bond_lovelace: "1000000",
|
||||
endpoint_url: "http://supplier",
|
||||
detail_uri: "",
|
||||
detail_hash: "",
|
||||
advertised_at: 0,
|
||||
status: "active",
|
||||
advert_status: "Active",
|
||||
current_escrow_ref: null,
|
||||
last_seen_iso: null,
|
||||
created_slot: 0,
|
||||
...p,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isCapableModel", () => {
|
||||
it("excludes deny-listed models, allows allow-listed", () => {
|
||||
expect(isCapableModel("kimi-k2.6", ["kimi", "deepseek", "gpt"], ["qwen2.5:0.5b"])).toBe(true);
|
||||
expect(isCapableModel("deepseek-chat", ["kimi", "deepseek", "gpt"], ["qwen2.5:0.5b"])).toBe(true);
|
||||
expect(isCapableModel("qwen2.5:0.5b", ["kimi", "deepseek", "gpt"], ["qwen2.5:0.5b"])).toBe(false);
|
||||
expect(isCapableModel("llama3", ["kimi", "deepseek", "gpt"], [])).toBe(false);
|
||||
});
|
||||
|
||||
it("deny wins over allow", () => {
|
||||
expect(isCapableModel("gpt-qwen2.5:0.5b", ["gpt"], ["qwen2.5:0.5b"])).toBe(false);
|
||||
});
|
||||
|
||||
it("empty allowlist allows anything not denied", () => {
|
||||
expect(isCapableModel("anything", [], ["bad"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterSuppliers", () => {
|
||||
it("keeps capable suppliers and drops qwen + demo + wrong-capability", () => {
|
||||
const views = [
|
||||
view({ model: "kimi-k2.6", utxo_ref: `${"a".repeat(64)}#0` }),
|
||||
view({ model: "deepseek-chat", utxo_ref: `${"b".repeat(64)}#1` }),
|
||||
view({ model: "qwen2.5:0.5b", utxo_ref: `${"c".repeat(64)}#0` }),
|
||||
view({ model: "kimi", utxo_ref: "demo:piper-tts" }), // unparseable ref
|
||||
view({ model: "gpt-4o", capability_id: "audio.synthesize.piper.v1", utxo_ref: `${"d".repeat(64)}#0` }),
|
||||
];
|
||||
const out = filterSuppliers(views, caps);
|
||||
expect(out.map((s) => s.model).sort()).toEqual(["deepseek-chat", "kimi-k2.6"]);
|
||||
expect(out[0].priceLovelace).toBe(2_000_000n);
|
||||
});
|
||||
|
||||
it("dedups by utxo_ref", () => {
|
||||
const ref = `${"a".repeat(64)}#0`;
|
||||
const out = filterSuppliers([view({ utxo_ref: ref }), view({ utxo_ref: ref })], caps);
|
||||
expect(out).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SupplierPool.next", () => {
|
||||
function pool(): SupplierPool {
|
||||
return new SupplierPool(
|
||||
filterSuppliers(
|
||||
[
|
||||
view({ model: "kimi", utxo_ref: `${"a".repeat(64)}#0` }),
|
||||
view({ model: "deepseek", utxo_ref: `${"b".repeat(64)}#0` }),
|
||||
view({ model: "gpt-4o", utxo_ref: `${"c".repeat(64)}#0` }),
|
||||
],
|
||||
caps,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
it("round-robins through all suppliers", () => {
|
||||
const p = pool();
|
||||
const seen = [p.next()?.model, p.next()?.model, p.next()?.model, p.next()?.model];
|
||||
expect(seen.slice(0, 3).sort()).toEqual(["deepseek", "gpt-4o", "kimi"]);
|
||||
expect(seen[3]).toBe(seen[0]); // wrapped around
|
||||
});
|
||||
|
||||
it("skips excluded suppliers and returns null when all excluded", () => {
|
||||
const p = pool();
|
||||
const all = p.all().map((s) => s.utxoRef);
|
||||
const exclude = new Set(all);
|
||||
expect(p.next(exclude)).toBeNull();
|
||||
const excludeTwo = new Set(all.slice(0, 2));
|
||||
expect(p.next(excludeTwo)?.utxoRef).toBe(all[2]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user