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>
61 lines
1.9 KiB
TypeScript
61 lines
1.9 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|