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>
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
/**
|
|
* 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]);
|
|
});
|
|
});
|