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:
david
2026-06-03 11:37:49 +02:00
parent 2025ab7411
commit c552c4caa3
25 changed files with 2518 additions and 0 deletions

View File

@@ -38,6 +38,29 @@ NETWORK_ID=0
# along over loopback. Production behind TLS should leave this unset / "1".
# COOKIE_SECURE=1
# ─── Optional: PDF Book Summarizer (/book page, /v1/pdf-* routes) ─────────
# Upload a PDF → chunk it → pay suppliers to summarize each chunk (one escrow
# per map + reduce call) → full-book summary. All knobs are optional; defaults
# shown. Tune the caps to bound spend/abuse on mainnet.
#
# Hard caps (a job is refused if it would exceed these):
# PDF_MAX_PAGES=400 # reject books larger than this
# PDF_MAX_CHUNKS=200 # reject books that chunk into more calls
# PDF_MAX_PDF_BYTES=25000000 # multipart upload size limit (bytes)
# Refuse to START a job if projected spend would drop the wallet below this:
# PDF_WALLET_FLOOR_LOVELACE=50000000 # 50 AP3X
# Chunking + reduce shape:
# PDF_CHUNK_TARGET_TOKENS=2200 # target tokens per chunk
# PDF_CHUNK_MAX_TOKENS=3000 # hard ceiling per chunk
# PDF_REDUCE_FANIN=8 # summaries collapsed per reduce node
# PDF_RETRY_K=2 # per-call retries on a different supplier
# Supplier selection (comma-separated model-name substrings):
# PDF_MODEL_ALLOWLIST=kimi,deepseek,gpt
# PDF_MODEL_DENYLIST=qwen2.5:0.5b,qwen2.5-0.5b
# Concurrency. v1 must stay 1 (serial PostEscrow — the shared tx builder
# can't pin wallet inputs yet, so parallel escrows would double-spend):
# PDF_LANE_COUNT=1
# ─── Optional: CIP-33 reference scripts ───────────────────────────────────
# When set, buyer-signed script-spending txs (Accept, Reclaim) read the
# escrow validator from an on-chain reference UTxO instead of inlining

View File

@@ -21,6 +21,9 @@
"bech32": "^2.0.0",
"better-sqlite3": "^11.9.1",
"express": "^4.21.2",
"gpt-tokenizer": "^2.8.1",
"multer": "^1.4.5-lts.1",
"pdf-parse": "^1.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
@@ -31,6 +34,8 @@
"@testing-library/user-event": "^14.5.2",
"@types/better-sqlite3": "^7.6.12",
"@types/express": "^5.0.3",
"@types/multer": "^1.4.11",
"@types/pdf-parse": "^1.1.4",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",

View File

@@ -16,6 +16,8 @@ import { createApp } from "./server.js";
import { buildChainProvider } from "./chain.js";
import { Marketplace, MemoryTaskHistoryStore } from "./sdk/index.js";
import { ResponseArchive } from "./db/archive.js";
import { JobStore } from "./pdf/summarize-job.js";
import { loadPdfCaps } from "./pdf/caps.js";
import type { WalletKey } from "@marketplace/shared/tx";
// Wire ed25519 sha512 hook (idempotent — same as receipt/sign.ts).
@@ -91,6 +93,18 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
);
}
// PDF book summarizer: one job registry, sharing the live marketplace SDK,
// chain, wallet, and (optional) archive. Caps come from PDF_* env vars.
const pdfCaps = loadPdfCaps(env);
const jobStore = new JobStore({
marketplace,
chain,
walletKey,
indexerUrl: config.indexerUrl,
archive,
caps: pdfCaps,
});
const app = createApp({
distPath,
chain,
@@ -102,6 +116,8 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
password: config.password,
sessionSecret: config.sessionSecret,
cookieSecure: config.cookieSecure,
jobStore,
pdfCaps,
});
const server = app.listen(config.port, () => {

58
buyer/src/pdf/caps.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* buyer/src/pdf/caps.ts — resolve PdfCaps (safety + sizing knobs) from env.
*
* Every knob is optional with a sane default, so the PDF summarizer works out
* of the box; operators tune spend/abuse limits via PDF_* env vars. See
* pdf/types.ts for field docs.
*/
import type { PdfCaps } from "./types.js";
function intEnv(env: Record<string, string | undefined>, name: string, dflt: number): number {
const v = env[name];
if (v === undefined || v === "") return dflt;
const n = Number(v);
if (!Number.isFinite(n) || n < 0) {
throw new Error(`loadPdfCaps: ${name} must be a non-negative number (got: ${v})`);
}
return Math.floor(n);
}
function bigintEnv(env: Record<string, string | undefined>, name: string, dflt: bigint): bigint {
const v = env[name];
if (v === undefined || v === "") return dflt;
if (!/^\d+$/.test(v)) {
throw new Error(`loadPdfCaps: ${name} must be a non-negative integer (got: ${v})`);
}
return BigInt(v);
}
function listEnv(env: Record<string, string | undefined>, name: string, dflt: string[]): string[] {
const v = env[name];
if (v === undefined) return dflt;
return v
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
/** Built-in defaults — used as the fallback when createApp gets no pdfCaps. */
export function defaultPdfCaps(): PdfCaps {
return loadPdfCaps({});
}
export function loadPdfCaps(env: Record<string, string | undefined>): PdfCaps {
return {
maxPages: intEnv(env, "PDF_MAX_PAGES", 400),
maxChunks: intEnv(env, "PDF_MAX_CHUNKS", 200),
walletFloorLovelace: bigintEnv(env, "PDF_WALLET_FLOOR_LOVELACE", 50_000_000n),
laneCount: Math.max(1, intEnv(env, "PDF_LANE_COUNT", 1)),
chunkTargetTokens: intEnv(env, "PDF_CHUNK_TARGET_TOKENS", 2200),
chunkMaxTokens: intEnv(env, "PDF_CHUNK_MAX_TOKENS", 3000),
reduceFanin: Math.max(2, intEnv(env, "PDF_REDUCE_FANIN", 8)),
retryK: intEnv(env, "PDF_RETRY_K", 2),
modelAllowlist: listEnv(env, "PDF_MODEL_ALLOWLIST", ["kimi", "deepseek", "gpt"]),
modelDenylist: listEnv(env, "PDF_MODEL_DENYLIST", ["qwen2.5:0.5b", "qwen2.5-0.5b"]),
maxPdfBytes: intEnv(env, "PDF_MAX_PDF_BYTES", 25_000_000),
};
}

100
buyer/src/pdf/chunk.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* buyer/src/pdf/chunk.ts — paragraph-aligned chunker.
*
* Splits extracted book text into ~targetTokens chunks WITHOUT ever cutting a
* sentence in half (the unit of summarization should be coherent). Strategy:
*
* 1. Decompose text into "units" each ≤ maxTokens:
* paragraph → if too big → sentences → if a sentence is still too big
* (rare) → hard word-split as a last resort.
* 2. Greedily pack units into chunks, flushing before a unit would push the
* running chunk past targetTokens.
*
* Each chunk therefore contains whole sentences; the only place a sentence is
* ever broken is a single sentence longer than maxTokens, which essentially
* never happens in real prose.
*/
import { countTokens } from "./tokens.js";
import type { Chunk } from "./types.js";
export interface ChunkOptions {
targetTokens: number;
maxTokens: number;
}
/** Blank-line separated paragraphs, internal whitespace collapsed. */
function splitParagraphs(text: string): string[] {
return text
.split(/\n\s*\n+/)
.map((p) => p.replace(/\s+/g, " ").trim())
.filter((p) => p.length > 0);
}
/** Sentence-ish split: keep terminal punctuation attached to its sentence. */
function splitSentences(paragraph: string): string[] {
const matches = paragraph.match(/[^.!?]+[.!?]+(?:\s|$)|[^.!?]+$/g);
if (!matches) return [paragraph];
return matches.map((s) => s.trim()).filter((s) => s.length > 0);
}
/** Last-resort split of a single oversize sentence by word count. */
function hardSplitByTokens(s: string, maxTokens: number): string[] {
const words = s.split(/\s+/).filter(Boolean);
const total = countTokens(s) || 1;
const wordsPerChunk = Math.max(1, Math.floor((words.length * maxTokens) / total));
const out: string[] = [];
for (let i = 0; i < words.length; i += wordsPerChunk) {
out.push(words.slice(i, i + wordsPerChunk).join(" "));
}
return out.length > 0 ? out : [s];
}
/** Decompose text into coherent units each ≤ maxTokens. */
function toUnits(text: string, maxTokens: number): string[] {
const units: string[] = [];
for (const para of splitParagraphs(text)) {
if (countTokens(para) <= maxTokens) {
units.push(para);
continue;
}
for (const sent of splitSentences(para)) {
if (countTokens(sent) <= maxTokens) {
units.push(sent);
} else {
units.push(...hardSplitByTokens(sent, maxTokens));
}
}
}
return units;
}
export function chunkText(text: string, opts: ChunkOptions): Chunk[] {
const { targetTokens, maxTokens } = opts;
const units = toUnits(text, maxTokens);
const chunks: Chunk[] = [];
let buf: string[] = [];
let bufTokens = 0;
const flush = (): void => {
if (buf.length === 0) return;
chunks.push({
index: chunks.length,
text: buf.join("\n\n"),
tokenEstimate: bufTokens,
});
buf = [];
bufTokens = 0;
};
for (const unit of units) {
const ut = countTokens(unit);
if (bufTokens > 0 && bufTokens + ut > targetTokens) flush();
buf.push(unit);
bufTokens += ut;
}
flush();
return chunks;
}

67
buyer/src/pdf/estimate.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* buyer/src/pdf/estimate.ts — pure cost math, computed BEFORE any escrow is
* posted so the operator can confirm the spend.
*
* A job's call count = mapCalls (one per chunk) + reduceCalls (the hierarchical
* reduce tree). Each call pays exactly one supplier's flat price_lovelace; the
* pool mixes prices, so we bound the total with the priciest supplier.
*/
import type { SupplierPool } from "./supplier-pool.js";
/**
* Number of reduce calls in an F-ary reduce tree over `n` leaves.
* e.g. n=90,F=8 → 12 + 2 + 1 = 15; n=8 → 1; n=1 → 0.
*/
export function reduceCallCount(n: number, fanin: number): number {
if (n <= 1 || fanin < 2) return 0;
let total = 0;
let level = n;
while (level > 1) {
level = Math.ceil(level / fanin);
total += level;
}
return total;
}
export interface JobEstimate {
mapCalls: number;
reduceCalls: number;
totalCalls: number;
/** Upper-bound per-call price (priciest supplier), lovelace. */
perCallMaxLovelace: string;
/** Upper-bound total payment, lovelace. */
totalLovelace: string;
/** Same, expressed in AP3X (1 AP3X = 1e6 lovelace), 2dp. */
totalAp3x: string;
}
export function estimateJob(
chunkCount: number,
fanin: number,
pool: SupplierPool,
): JobEstimate {
const mapCalls = chunkCount;
const reduceCalls = reduceCallCount(chunkCount, fanin);
const totalCalls = mapCalls + reduceCalls;
const priceMax = pool.maxPrice();
const totalLovelace = priceMax * BigInt(totalCalls);
return {
mapCalls,
reduceCalls,
totalCalls,
perCallMaxLovelace: priceMax.toString(),
totalLovelace: totalLovelace.toString(),
totalAp3x: (Number(totalLovelace) / 1e6).toFixed(2),
};
}
/**
* Conservative lovelace headroom for chain fees + transient bonds across a
* whole job. The buyer pays PostEscrow + Accept fees per call and locks a
* 1 AP3X bond transiently; this over-estimates so the wallet floor check
* errs safe.
*/
export function feeHeadroomLovelace(totalCalls: number): bigint {
return BigInt(totalCalls) * 1_000_000n + 2_000_000n;
}

57
buyer/src/pdf/extract.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* buyer/src/pdf/extract.ts — text extraction from an uploaded PDF buffer.
*
* Text-based PDFs only (v1 has no OCR). Scanned/image-only PDFs extract
* almost no text; we detect that via a chars-per-page floor and reject with
* a clear `scanned_or_image_pdf` reason so the UI can tell the operator why.
*
* We require `pdf-parse/lib/pdf-parse.js` directly (not the package index)
* because pdf-parse's index.js runs a debug block that reads a bundled test
* PDF when `module.parent` is falsy — which is exactly the case under ESM/tsx
* and would crash on import. The lib entry is the pure function.
*/
import { createRequire } from "module";
import { PdfExtractionError } from "./types.js";
const require = createRequire(import.meta.url);
const pdfParse = require("pdf-parse/lib/pdf-parse.js") as (
data: Buffer,
opts?: Record<string, unknown>,
) => Promise<{ text: string; numpages: number }>;
/** Below this many extracted chars per page we treat the PDF as scanned. */
const MIN_CHARS_PER_PAGE = 50;
export interface ExtractResult {
text: string;
pageCount: number;
}
export async function extractPdfText(buf: Buffer): Promise<ExtractResult> {
let parsed: { text: string; numpages: number };
try {
parsed = await pdfParse(buf);
} catch (err) {
throw new PdfExtractionError(
"pdf_parse_failed",
err instanceof Error ? err.message : String(err),
);
}
const text = (parsed.text ?? "").trim();
const pageCount = parsed.numpages ?? 0;
if (
text.length === 0 ||
(pageCount > 0 && text.length / pageCount < MIN_CHARS_PER_PAGE)
) {
throw new PdfExtractionError(
"scanned_or_image_pdf",
`extracted only ${text.length} chars across ${pageCount} page(s) — ` +
`looks scanned/image-only, which v1 can't summarize (no OCR)`,
);
}
return { text, pageCount };
}

208
buyer/src/pdf/routes.ts Normal file
View File

@@ -0,0 +1,208 @@
/**
* buyer/src/pdf/routes.ts — HTTP surface for the PDF book summarizer.
*
* Mounted under /v1/pdf-* so it inherits the buyer app's session auth
* (server.ts gates every /v1/* route except /v1/auth/*). The upload route
* uses multer (multipart) so the global express.json({limit:"1mb"}) body
* parser never sees the PDF.
*
* Endpoints:
* POST /v1/pdf-upload multipart "file" → {job_id, chunk_count, ...}
* GET /v1/pdf-jobs/:id/estimate pre-spend cost + wallet floor check
* POST /v1/pdf-jobs/:id/start {confirm:true} → 202 (posts escrows)
* GET /v1/pdf-jobs/:id/events text/event-stream live progress
* GET /v1/pdf-jobs/:id job view (status, coverage, summary)
* GET /v1/pdf-jobs/:id/summary.md downloadable markdown
*/
import type { Express, Request, Response } from "express";
import multer from "multer";
import { extractPdfText } from "./extract.js";
import { chunkText } from "./chunk.js";
import { PdfExtractionError, type PdfCaps } from "./types.js";
import type { JobStore } from "./summarize-job.js";
function jsonError(res: Response, status: number, reason: string, message: string): Response {
return res.status(status).json({ error: reason, message });
}
export function registerPdfRoutes(
app: Express,
jobStore: JobStore | undefined,
caps: PdfCaps,
): void {
// Without a JobStore (chain/marketplace deps missing, or archive off) the
// feature is disabled — respond 503 on every pdf route so the SPA renders a
// clear banner instead of 404s.
if (!jobStore) {
const disabled = (_req: Request, res: Response): Response =>
jsonError(
res,
503,
"service_unavailable",
"PDF summarizer disabled (buyer booted without chain/marketplace/archive deps)",
);
app.post("/v1/pdf-upload", disabled);
app.all(/^\/v1\/pdf-jobs(\/.*)?$/, disabled);
return;
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: caps.maxPdfBytes },
});
// ── POST /v1/pdf-upload ─────────────────────────────────────────────
app.post("/v1/pdf-upload", (req: Request, res: Response) => {
upload.single("file")(req, res, async (err: unknown) => {
if (err) {
const code = (err as { code?: string }).code;
if (code === "LIMIT_FILE_SIZE") {
return jsonError(res, 413, "pdf_too_large", `max upload is ${caps.maxPdfBytes} bytes`);
}
return jsonError(res, 400, "upload_failed", err instanceof Error ? err.message : String(err));
}
const file = (req as Request & { file?: { buffer: Buffer; originalname?: string } }).file;
if (!file) {
return jsonError(res, 400, "file_required", 'multipart form field "file" is required');
}
let extracted;
try {
extracted = await extractPdfText(file.buffer);
} catch (e) {
if (e instanceof PdfExtractionError) {
return jsonError(res, 422, e.reason, e.message);
}
return jsonError(res, 500, "extract_failed", e instanceof Error ? e.message : String(e));
}
if (extracted.pageCount > caps.maxPages) {
return jsonError(
res,
422,
"too_many_pages",
`${extracted.pageCount} pages exceeds the ${caps.maxPages}-page cap`,
);
}
const chunks = chunkText(extracted.text, {
targetTokens: caps.chunkTargetTokens,
maxTokens: caps.chunkMaxTokens,
});
if (chunks.length === 0) {
return jsonError(res, 422, "no_text", "no extractable text found in the PDF");
}
if (chunks.length > caps.maxChunks) {
return jsonError(
res,
422,
"too_many_chunks",
`${chunks.length} chunks exceeds the ${caps.maxChunks}-chunk cap`,
);
}
const job = jobStore.createJob(file.originalname || "book.pdf", extracted.pageCount, chunks);
return res.status(200).json({
job_id: job.jobId,
filename: job.filename,
page_count: extracted.pageCount,
chunk_count: chunks.length,
sample_chunk: chunks[0].text.slice(0, 500),
});
});
});
// ── GET /v1/pdf-jobs/:id/estimate ───────────────────────────────────
app.get("/v1/pdf-jobs/:id/estimate", async (req: Request, res: Response) => {
const job = jobStore.get(String(req.params.id));
if (!job) return jsonError(res, 404, "job_not_found", `no job ${String(req.params.id)}`);
try {
const est = await jobStore.estimate(job);
return res.status(200).json(est);
} catch (err) {
return jsonError(res, 502, "estimate_failed", err instanceof Error ? err.message : String(err));
}
});
// ── POST /v1/pdf-jobs/:id/start ─────────────────────────────────────
app.post("/v1/pdf-jobs/:id/start", async (req: Request, res: Response) => {
const job = jobStore.get(String(req.params.id));
if (!job) return jsonError(res, 404, "job_not_found", `no job ${String(req.params.id)}`);
const body = (typeof req.body === "object" && req.body !== null ? req.body : {}) as {
confirm?: unknown;
};
if (body.confirm !== true) {
return jsonError(res, 400, "not_confirmed", "body must include { confirm: true }");
}
if (job.status !== "estimated") {
return jsonError(res, 409, "already_started", `job is already ${job.status}`);
}
let est;
try {
est = await jobStore.estimate(job);
} catch (err) {
return jsonError(res, 502, "estimate_failed", err instanceof Error ? err.message : String(err));
}
if (est.no_capable_suppliers) {
return jsonError(res, 409, "no_capable_suppliers", "no capable suppliers available right now");
}
if (est.would_drop_below_floor) {
return jsonError(
res,
402,
"below_wallet_floor",
`projected spend would drop the wallet below the ${est.wallet_floor_lovelace} lovelace floor`,
);
}
jobStore.start(job);
return res.status(202).json({ job_id: job.jobId, status: "running" });
});
// ── GET /v1/pdf-jobs/:id/events (SSE) ───────────────────────────────
app.get("/v1/pdf-jobs/:id/events", (req: Request, res: Response) => {
const job = jobStore.get(String(req.params.id));
if (!job) {
jsonError(res, 404, "job_not_found", `no job ${String(req.params.id)}`);
return;
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
// Defeat proxy buffering (Traefik/nginx/Cloudflare) so frames flush live.
res.setHeader("X-Accel-Buffering", "no");
if (typeof (res as Response & { flushHeaders?: () => void }).flushHeaders === "function") {
(res as Response & { flushHeaders: () => void }).flushHeaders();
}
const unsubscribe = job.subscribe((frame) => res.write(frame));
const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 15_000);
req.on("close", () => {
clearInterval(keepAlive);
unsubscribe();
});
});
// ── GET /v1/pdf-jobs/:id/summary.md ─────────────────────────────────
app.get("/v1/pdf-jobs/:id/summary.md", (req: Request, res: Response) => {
const job = jobStore.get(String(req.params.id));
if (!job) return jsonError(res, 404, "job_not_found", `no job ${String(req.params.id)}`);
if (!job.finalSummary) {
return jsonError(res, 404, "not_ready", "summary not available yet");
}
const safeName = job.filename.replace(/\.pdf$/i, "").replace(/[^A-Za-z0-9._-]+/g, "_") || "summary";
res.setHeader("Content-Type", "text/markdown; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="${safeName}-summary.md"`);
return res.send(job.finalSummary);
});
// ── GET /v1/pdf-jobs/:id ────────────────────────────────────────────
app.get("/v1/pdf-jobs/:id", (req: Request, res: Response) => {
const job = jobStore.get(String(req.params.id));
if (!job) return jsonError(res, 404, "job_not_found", `no job ${String(req.params.id)}`);
return res.status(200).json(job.view());
});
}

View File

@@ -0,0 +1,525 @@
/**
* buyer/src/pdf/summarize-job.ts — the summarization orchestrator + in-memory
* job registry.
*
* One job = one uploaded book. The worker:
* MAP — summarize each chunk (one paid escrow per chunk).
* REDUCE — hierarchically collapse chunk summaries (fan-in F) up a tree to a
* single book summary; each reduce node is another paid escrow.
*
* Each call reuses the exact run-cycle.ts sequence: submitPrompt (PostEscrow →
* supplier → receipt-verify) → findSubmittedEscrowRef (indexer) → runAccept.
*
* Concurrency: v1 is serial. submitPrompt+accept run under a global mutex
* because the shared tx builder can't pin wallet inputs yet, so two in-flight
* PostEscrow txs would select the same wallet UTxO and double-spend. laneCount
* is honored for dispatch but the mutex keeps chain ops serialized regardless
* — the parallel fast-follow lifts the mutex once PostEscrow takes
* presetWalletInputs.
*
* Failure policy: a failed call retries on a *different* supplier up to
* caps.retryK times; if still failing it becomes a "gap" (excluded, job
* continues). Orphaned escrows from failed calls are recovered by the existing
* `reclaim:orphans` cron (submitPrompt throws before returning a ref, so we
* can't reclaim inline).
*
* Job state is in-memory only (lost on restart); already-posted escrows remain
* recoverable via the indexer + reclaim cron.
*/
import { randomUUID } from "crypto";
import { canonicalize } from "@marketplace/shared/cbor";
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import type { Marketplace } from "../sdk/Marketplace.js";
import type { ResponseArchive } from "../db/archive.js";
import { runAccept } from "../cli/acceptFlow.js";
import { SUMMARIZE_CAPABILITY_ID, buildSupplierPool, SupplierPool } from "./supplier-pool.js";
import { estimateJob, feeHeadroomLovelace, type JobEstimate } from "./estimate.js";
import type { Chunk, ChunkResult, JobPhase, JobView, PdfCaps, PoolSupplier } from "./types.js";
const ESCROW_REF_RE = /^([0-9a-f]{64})#(\d+)$/;
const SUBMITTED_LOOKUP_TIMEOUT_MS = 30_000;
/** The result of running one supplier call's full on-chain lifecycle. */
export interface CallOutcome {
response: string;
escrowRef: string;
supplierPkh: string;
model: string;
receipt: Record<string, unknown>;
receiptSignature: string;
}
/** Runs one call (PostEscrow → supplier → verify → Accept). Injectable for
* tests so the orchestrator can be exercised without lucid/chain. */
export type RunCallFn = (supplier: PoolSupplier, prompt: string) => Promise<CallOutcome>;
export interface JobDeps {
marketplace: Marketplace;
chain: ChainProvider;
walletKey: WalletKey;
indexerUrl: string;
archive?: ResponseArchive;
caps: PdfCaps;
/** Override the per-call lifecycle (tests). Defaults to the real
* submitPrompt → findSubmittedEscrowRef → runAccept sequence. */
runCall?: RunCallFn;
/** Override the wallet-balance read (tests). Defaults to chain query. */
walletBalance?: () => Promise<bigint>;
}
export interface ProgressEvent {
phase: "map" | "reduce" | "done" | "error";
label?: string;
completed: number;
failed: number;
total: number;
reduce_level?: number;
running_cost_lovelace: string;
coverage: { done: number; total: number };
message?: string;
status?: JobPhase;
}
export interface EstimateView extends JobEstimate {
suppliers: { model: string; price_lovelace: string }[];
wallet_balance_lovelace: string;
wallet_floor_lovelace: string;
would_drop_below_floor: boolean;
no_capable_suppliers: boolean;
}
/** Minimal async mutex — serializes the chain-touching critical section. */
class Mutex {
private tail: Promise<void> = Promise.resolve();
run<T>(fn: () => Promise<T>): Promise<T> {
const result = this.tail.then(fn);
// Keep the chain alive but swallow errors so one failure doesn't poison
// the queue; callers observe the real result/throw via `result`.
this.tail = result.then(
() => undefined,
() => undefined,
);
return result;
}
}
async function walletLovelace(chain: ChainProvider, address: string): Promise<bigint> {
const utxos = await chain.queryUtxosByAddress(address);
return utxos.reduce((acc, u) => acc + BigInt(u.lovelace), 0n);
}
/**
* Poll the indexer for the current Submitted-state escrow ref matching this
* prompt_hash. submitPrompt returns the Open ref, which Claim+Submit have
* already spent — runAccept needs the live Submitted ref. Lifted from
* buyer/src/cli/run-cycle.ts.
*/
async function findSubmittedEscrowRef(
indexerUrl: string,
buyerPkh: string,
promptHash: string,
timeoutMs: number,
): Promise<OutputReference> {
const deadline = Date.now() + timeoutMs;
const base = indexerUrl.replace(/\/+$/, "");
while (Date.now() < deadline) {
const resp = await fetch(`${base}/escrows?buyer=${buyerPkh}`);
if (resp.ok) {
const rows = (await resp.json()) as Array<{
utxo_ref: string;
prompt_hash: string;
state: string;
submitted_at: number | null;
}>;
const match = rows
.filter((r) => r.prompt_hash === promptHash && r.state === "Submitted")
.sort((a, b) => (b.submitted_at ?? 0) - (a.submitted_at ?? 0))[0];
if (match) {
const m = ESCROW_REF_RE.exec(match.utxo_ref);
if (m) return { txHash: m[1], index: Number(m[2]) };
}
}
await new Promise((r) => setTimeout(r, 2_000));
}
throw new Error(
`indexer: no Submitted escrow with prompt_hash=${promptHash} within ${timeoutMs}ms`,
);
}
function mapPrompt(chunk: Chunk): string {
// The "[chunk N]" prefix salts the prompt so prompt_hash is unique even when
// two chunks share identical text — the indexer Submitted lookup matches on
// hash and would otherwise be ambiguous.
return (
`[chunk ${chunk.index + 1}] You are summarizing one section of a longer book. ` +
`Write a faithful, self-contained summary of the passage below in clear prose — ` +
`capture the key events, arguments, characters, and conclusions, and add nothing ` +
`that is not present.\n\nPASSAGE:\n${chunk.text}`
);
}
function reducePrompt(level: number, node: number, summaries: string[]): string {
const joined = summaries
.map((s, i) => `--- Section ${i + 1} ---\n${s}`)
.join("\n\n");
return (
`[reduce L${level}.${node}] Below are summaries of consecutive sections of a book, ` +
`in order. Synthesize them into a single coherent summary that preserves the overall ` +
`narrative/argument and chronological flow. Be faithful; do not invent.\n\nSECTIONS:\n${joined}`
);
}
function groupBy<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
async function runWithConcurrency<T>(
items: T[],
worker: (item: T) => Promise<void>,
concurrency: number,
): Promise<void> {
let next = 0;
const lanes = Array.from({ length: Math.max(1, concurrency) }, async () => {
for (;;) {
const idx = next++;
if (idx >= items.length) return;
await worker(items[idx]);
}
});
await Promise.all(lanes);
}
export class Job {
readonly jobId: string;
readonly filename: string;
readonly pageCount: number;
readonly chunks: Chunk[];
status: JobPhase = "estimated";
runningCost = 0n;
finalSummary?: string;
coverageDone = 0;
failedCount = 0;
readonly escrowRefs: string[] = [];
/** Map results indexed by chunk index; reduce results appended after. */
readonly chunkResults: ChunkResult[];
private subscribers = new Set<(frame: string) => void>();
private frames: string[] = [];
constructor(filename: string, pageCount: number, chunks: Chunk[]) {
this.jobId = `job_${randomUUID()}`;
this.filename = filename;
this.pageCount = pageCount;
this.chunks = chunks;
this.chunkResults = chunks.map((c) => ({
index: c.index,
label: `map:${c.index}`,
status: "pending" as const,
}));
}
get coverageTotal(): number {
return this.chunks.length;
}
emit(ev: ProgressEvent): void {
const eventName = ev.phase === "done" ? "done" : "progress";
const frame = `event: ${eventName}\ndata: ${JSON.stringify(ev)}\n\n`;
this.frames.push(frame);
if (this.frames.length > 1000) this.frames.shift();
for (const send of this.subscribers) {
try {
send(frame);
} catch {
/* dropped client; route's close handler unsubscribes */
}
}
}
/** Attach an SSE writer; replays buffered frames so reconnects catch up. */
subscribe(send: (frame: string) => void): () => void {
for (const f of this.frames) send(f);
this.subscribers.add(send);
return () => {
this.subscribers.delete(send);
};
}
view(): JobView {
return {
job_id: this.jobId,
filename: this.filename,
status: this.status,
page_count: this.pageCount,
chunk_count: this.chunks.length,
coverage: { done: this.coverageDone, total: this.coverageTotal },
running_cost_lovelace: this.runningCost.toString(),
final_summary_md: this.finalSummary,
chunk_results: this.chunkResults,
escrow_refs: this.escrowRefs,
};
}
}
export class JobStore {
private readonly jobs = new Map<string, Job>();
private readonly started = new Set<string>();
private readonly chainMutex = new Mutex();
constructor(private readonly deps: JobDeps) {}
createJob(filename: string, pageCount: number, chunks: Chunk[]): Job {
const job = new Job(filename, pageCount, chunks);
this.jobs.set(job.jobId, job);
return job;
}
get(id: string): Job | undefined {
return this.jobs.get(id);
}
/** Discover suppliers + read balance to produce the pre-spend estimate. */
async estimate(job: Job): Promise<EstimateView> {
const pool = await buildSupplierPool(this.deps.marketplace, this.deps.caps);
const est = estimateJob(job.chunks.length, this.deps.caps.reduceFanin, pool);
const balance = this.deps.walletBalance
? await this.deps.walletBalance()
: await walletLovelace(this.deps.chain, this.deps.walletKey.address);
const projected = BigInt(est.totalLovelace) + feeHeadroomLovelace(est.totalCalls);
const wouldDrop = balance - projected < this.deps.caps.walletFloorLovelace;
return {
...est,
suppliers: pool.all().map((s) => ({
model: s.model,
price_lovelace: s.priceLovelace.toString(),
})),
wallet_balance_lovelace: balance.toString(),
wallet_floor_lovelace: this.deps.caps.walletFloorLovelace.toString(),
would_drop_below_floor: wouldDrop,
no_capable_suppliers: pool.size === 0,
};
}
/** Kick off the background worker. Idempotent: a second call is a no-op. */
start(job: Job): void {
if (this.started.has(job.jobId)) return;
this.started.add(job.jobId);
job.status = "running";
void this.run(job).catch((err) => {
job.status = "failed";
// Terminal: emit a `done` frame so SSE clients stop waiting; carries the
// failure message + status so the UI can surface it.
job.emit(
this.event(job, "done", {
status: "failed",
message: err instanceof Error ? err.message : String(err),
}),
);
});
}
private async run(job: Job): Promise<void> {
const { caps } = this.deps;
const pool = await buildSupplierPool(this.deps.marketplace, caps);
if (pool.size === 0) {
job.status = "failed";
job.emit(this.event(job, "done", { message: "no_capable_suppliers", status: "failed" }));
return;
}
// ── MAP ────────────────────────────────────────────────────────────
await runWithConcurrency(
job.chunks,
async (chunk) => {
const r = await this.doOneCall(job, pool, "map", `map:${chunk.index}`, mapPrompt(chunk));
const row = job.chunkResults[chunk.index];
if (r.ok) {
row.status = "ok";
row.summary = r.summary;
row.escrowRef = r.escrowRef;
row.supplierModel = r.model;
job.coverageDone++;
} else {
row.status = "gap";
job.failedCount++;
}
job.emit(this.event(job, "map", { label: row.label }));
},
caps.laneCount,
);
// ── REDUCE (hierarchical) ───────────────────────────────────────────
let summaries = job.chunkResults
.filter((r) => r.status === "ok" && typeof r.summary === "string")
.map((r) => r.summary as string);
let level = 0;
while (summaries.length > 1) {
level++;
const groups = groupBy(summaries, caps.reduceFanin);
const nextLevel: (string | undefined)[] = new Array(groups.length);
await runWithConcurrency(
groups.map((g, i) => ({ g, i })),
async ({ g, i }) => {
const label = `reduce:${level}.${i}`;
const r = await this.doOneCall(job, pool, "reduce", label, reducePrompt(level, i, g));
const row: ChunkResult = {
index: -1,
label,
status: r.ok ? "ok" : "gap",
escrowRef: r.escrowRef,
supplierModel: r.model,
summary: r.summary,
};
job.chunkResults.push(row);
if (r.ok) {
nextLevel[i] = r.summary;
} else {
job.failedCount++;
}
job.emit(this.event(job, "reduce", { label, reduce_level: level }));
},
caps.laneCount,
);
summaries = nextLevel.filter((s): s is string => typeof s === "string");
}
job.finalSummary = summaries[0] ?? this.assembleFromPartials(job);
const hasSummary = typeof job.finalSummary === "string" && job.finalSummary.length > 0;
if (!hasSummary) {
job.status = "failed";
} else if (job.coverageDone < job.coverageTotal || job.failedCount > 0) {
job.status = "completed_with_gaps";
} else {
job.status = "completed";
}
// Per-call chats are already persisted in doOneCall; the final summary
// lives in job state and is downloadable via GET .../summary.md.
job.emit(this.event(job, "done", { status: job.status }));
}
/** The real per-call lifecycle: submitPrompt → resolve Submitted → Accept. */
private async defaultRunCall(sup: PoolSupplier, prompt: string): Promise<CallOutcome> {
const submit = await this.deps.marketplace.submitPrompt({
advertRef: sup.advertRef,
messages: [{ role: "user", content: prompt }],
payment_lovelace: sup.priceLovelace,
max_output_tokens: sup.maxOutputTokens,
});
const submittedRef = await findSubmittedEscrowRef(
this.deps.indexerUrl,
this.deps.walletKey.pubKeyHash,
submit.receipt.prompt_hash,
SUBMITTED_LOOKUP_TIMEOUT_MS,
);
await runAccept({
chain: this.deps.chain,
walletKey: this.deps.walletKey,
escrowRef: submittedRef,
});
return {
response: submit.response,
escrowRef: `${submit.escrowRef.txHash}#${submit.escrowRef.index}`,
supplierPkh: submit.receipt.supplier_pkh,
model: submit.receipt.model,
receipt: submit.receipt as unknown as Record<string, unknown>,
receiptSignature: submit.receiptSignature,
};
}
/** One map/reduce call: round-robin a supplier, run the full lifecycle.
* Serialized through chainMutex so concurrent PostEscrow txs never select
* the same wallet UTxO (v1 has no input pinning). */
private async doOneCall(
job: Job,
pool: SupplierPool,
phase: "map" | "reduce",
label: string,
prompt: string,
): Promise<{ ok: boolean; summary?: string; escrowRef?: string; model?: string }> {
const runCall: RunCallFn = this.deps.runCall ?? ((s, p) => this.defaultRunCall(s, p));
const tried = new Set<string>();
for (let attempt = 0; attempt <= this.deps.caps.retryK; attempt++) {
const sup = pool.next(tried);
if (!sup) break; // ran out of distinct suppliers
tried.add(sup.utxoRef);
try {
const result = await this.chainMutex.run(() => runCall(sup, prompt));
job.runningCost += sup.priceLovelace;
job.escrowRefs.push(result.escrowRef);
if (this.deps.archive) {
try {
const canonicalAssistant = canonicalize({
role: "assistant",
content: result.response,
});
this.deps.archive.persistChat({
escrow_ref: result.escrowRef,
posted_at: Date.now(),
capability_id: SUMMARIZE_CAPABILITY_ID,
supplier_pkh: result.supplierPkh,
model: result.model,
payment_lovelace: sup.priceLovelace.toString(),
request_messages: [{ role: "user", content: prompt }],
response_canonical: canonicalAssistant,
receipt: result.receipt,
receipt_signature: result.receiptSignature,
});
} catch (err) {
// archive is best-effort; the on-chain receipt is authoritative
// eslint-disable-next-line no-console
console.error(`[pdf] archive.persistChat failed for ${label}:`, err);
}
}
return { ok: true, summary: result.response, escrowRef: result.escrowRef, model: sup.model };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
job.emit(
this.event(job, phase, {
label,
message: `retry ${attempt + 1}/${this.deps.caps.retryK + 1} on new supplier: ${message}`,
}),
);
// Orphaned escrow (if PostEscrow landed) is recovered by reclaim:orphans.
}
}
return { ok: false };
}
/** Fallback book summary built from surviving map summaries, gaps marked. */
private assembleFromPartials(job: Job): string {
const parts: string[] = [];
for (const r of job.chunkResults.filter((x) => x.index >= 0)) {
if (r.status === "ok" && r.summary) {
parts.push(`## Section ${r.index + 1}\n\n${r.summary}`);
} else {
parts.push(`## Section ${r.index + 1}\n\n_[gap: section omitted — summarization failed]_`);
}
}
return parts.join("\n\n");
}
private event(
job: Job,
phase: ProgressEvent["phase"],
extra: Partial<ProgressEvent> = {},
): ProgressEvent {
return {
phase,
completed: job.coverageDone,
failed: job.failedCount,
total: job.coverageTotal,
running_cost_lovelace: job.runningCost.toString(),
coverage: { done: job.coverageDone, total: job.coverageTotal },
...extra,
};
}
}

View File

@@ -0,0 +1,120 @@
/**
* buyer/src/pdf/supplier-pool.ts — discover + filter + round-robin the
* capable suppliers a summarization job pays.
*
* "Capable" = a llm.text.generate.v1 supplier whose model passes the
* allow/deny filter (e.g. exclude the weak local qwen2.5:0.5b). The pool
* round-robins across them so traffic is spread over multiple agents and a
* retry always lands on a *different* supplier than the one that just failed.
*/
import type { Marketplace } from "../sdk/Marketplace.js";
import type { SupplierView } from "../sdk/types.js";
import type { PdfCaps, PoolSupplier } from "./types.js";
export const SUMMARIZE_CAPABILITY_ID = "llm.text.generate.v1";
const REF_RE = /^([0-9a-f]{64})#(\d+)$/;
function parseRef(ref: string): { txHash: string; index: number } | null {
const m = REF_RE.exec(ref);
return m ? { txHash: m[1], index: Number(m[2]) } : null;
}
/** Is `model` capable given the allow/deny substring lists? Deny wins. */
export function isCapableModel(
model: string,
allow: string[],
deny: string[],
): boolean {
const m = model.toLowerCase();
if (deny.some((d) => d.length > 0 && m.includes(d.toLowerCase()))) return false;
if (allow.length === 0) return true;
return allow.some((a) => a.length > 0 && m.includes(a.toLowerCase()));
}
/** Filter raw indexer views down to capable, payable suppliers. */
export function filterSuppliers(
views: SupplierView[],
caps: PdfCaps,
): PoolSupplier[] {
const out: PoolSupplier[] = [];
const seen = new Set<string>();
for (const v of views) {
if (v.capability_id !== SUMMARIZE_CAPABILITY_ID) continue;
if (!isCapableModel(v.model, caps.modelAllowlist, caps.modelDenylist)) continue;
if (seen.has(v.utxo_ref)) continue;
const ref = parseRef(v.utxo_ref);
if (!ref) continue; // demo/synthetic entries have non-parseable refs
let price: bigint;
try {
price = BigInt(v.price_lovelace);
} catch {
continue;
}
seen.add(v.utxo_ref);
out.push({
advertRef: ref,
utxoRef: v.utxo_ref,
supplierPkh: v.supplier_pkh,
model: v.model,
priceLovelace: price,
maxOutputTokens: v.max_output_tokens,
endpointUrl: v.endpoint_url,
});
}
return out;
}
export class SupplierPool {
private readonly suppliers: PoolSupplier[];
private cursor = 0;
constructor(suppliers: PoolSupplier[]) {
this.suppliers = suppliers;
}
get size(): number {
return this.suppliers.length;
}
all(): PoolSupplier[] {
return this.suppliers.slice();
}
/** Upper bound for cost estimates: the priciest supplier in the pool. */
maxPrice(): bigint {
return this.suppliers.reduce(
(max, s) => (s.priceLovelace > max ? s.priceLovelace : max),
0n,
);
}
/**
* Round-robin the next supplier, skipping any whose utxoRef is in `exclude`.
* Returns null only when every supplier is excluded (caller treats as
* "out of suppliers" → gap).
*/
next(exclude?: Set<string>): PoolSupplier | null {
const n = this.suppliers.length;
if (n === 0) return null;
for (let i = 0; i < n; i++) {
const s = this.suppliers[(this.cursor + i) % n];
if (!exclude || !exclude.has(s.utxoRef)) {
this.cursor = (this.cursor + i + 1) % n;
return s;
}
}
return null;
}
}
export async function buildSupplierPool(
marketplace: Marketplace,
caps: PdfCaps,
): Promise<SupplierPool> {
const views = await marketplace.discoverSuppliers({
capability_id: SUMMARIZE_CAPABILITY_ID,
});
return new SupplierPool(filterSuppliers(views, caps));
}

16
buyer/src/pdf/tokens.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* buyer/src/pdf/tokens.ts — single token-counting helper, shared by the
* chunker (chunk.ts) and the cost estimator (estimate.ts) so the estimate
* matches the chunk count exactly.
*
* Uses gpt-tokenizer's default cl100k_base encoder — pure JS, no native deps,
* no network. The exact tokenizer a given supplier model uses may differ, but
* cl100k is a good universal proxy for chunk sizing and cost bounds.
*/
import { encode } from "gpt-tokenizer";
export function countTokens(s: string): number {
if (s.length === 0) return 0;
return encode(s).length;
}

103
buyer/src/pdf/types.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* buyer/src/pdf/types.ts — shared types + errors for the PDF book summarizer.
*
* The summarizer is a server-side feature bolted onto the buyer web app: an
* operator uploads a PDF, the server chunks it and runs a hierarchical
* map-reduce summarization by paying marketplace suppliers per chunk (one
* escrow per map call + per reduce node). See pdf/summarize-job.ts for the
* orchestrator and pdf/routes.ts for the HTTP surface.
*/
import type { OutputReference } from "@marketplace/shared/chain";
/** One paragraph-aligned slice of the book, fed to a supplier as one prompt. */
export interface Chunk {
/** 0-based position in the book — also salts the map prompt so prompt_hash
* is unique even for identical text (the indexer lookup matches on hash). */
index: number;
text: string;
tokenEstimate: number;
}
/** Operator-tunable safety + sizing knobs, resolved from env at boot. */
export interface PdfCaps {
/** Reject books with more pages than this (hard cap). */
maxPages: number;
/** Reject books that chunk into more than this many calls (hard cap). */
maxChunks: number;
/** Refuse to start a job if projected spend would drop the wallet below
* this lovelace balance. */
walletFloorLovelace: bigint;
/** Concurrency for the orchestrator. v1 ships 1 (serial PostEscrow — the
* shared tx builder can't pin wallet inputs yet, so parallel escrows would
* double-spend). Kept configurable for the parallel fast-follow. */
laneCount: number;
/** Target tokens per chunk (greedy packing). */
chunkTargetTokens: number;
/** Hard ceiling per chunk; oversize paragraphs split on sentence bounds. */
chunkMaxTokens: number;
/** Reduce-tree fan-in: how many summaries collapse into one per level. */
reduceFanin: number;
/** Per-call retries on a *different* supplier before a chunk is a gap. */
retryK: number;
/** Model-name substrings considered capable (empty = allow all). */
modelAllowlist: string[];
/** Model-name substrings to always exclude (e.g. weak local models). */
modelDenylist: string[];
/** Max upload size in bytes (multer fileSize). */
maxPdfBytes: number;
}
/** A discovered, capable supplier ready to be paid. */
export interface PoolSupplier {
advertRef: OutputReference;
utxoRef: string; // "<txhash>#<index>"
supplierPkh: string;
model: string;
priceLovelace: bigint;
maxOutputTokens: number;
endpointUrl: string;
}
export type JobPhase =
| "estimated"
| "running"
| "completed"
| "completed_with_gaps"
| "failed";
/** Per-call result row, surfaced to the UI. */
export interface ChunkResult {
/** chunk index for map calls; -1 for reduce nodes. */
index: number;
/** "map:12" or "reduce:1.3". */
label: string;
status: "ok" | "gap" | "pending";
escrowRef?: string;
supplierModel?: string;
summary?: string;
}
/** JSON view returned by GET /v1/pdf-jobs/:id. */
export interface JobView {
job_id: string;
filename: string;
status: JobPhase;
page_count: number;
chunk_count: number;
coverage: { done: number; total: number };
running_cost_lovelace: string;
final_summary_md?: string;
chunk_results: ChunkResult[];
escrow_refs: string[];
}
/** Thrown by extractPdfText for unusable PDFs (e.g. scanned/image-only). */
export class PdfExtractionError extends Error {
readonly reason: string;
constructor(reason: string, message?: string) {
super(message ?? reason);
this.name = "PdfExtractionError";
this.reason = reason;
}
}

View File

@@ -28,6 +28,10 @@ import type { ChatMessage } from "@marketplace/shared/tx";
import { canonicalize } from "@marketplace/shared/cbor";
import { runAccept } from "./cli/acceptFlow.js";
import type { ResponseArchive } from "./db/archive.js";
import { registerPdfRoutes } from "./pdf/routes.js";
import { defaultPdfCaps } from "./pdf/caps.js";
import type { PdfCaps } from "./pdf/types.js";
import type { JobStore } from "./pdf/summarize-job.js";
import {
SESSION_COOKIE_NAME,
SESSION_MAX_AGE_MS,
@@ -99,6 +103,12 @@ export interface AppDeps {
* brute-force rate-limit window. Defaults to Date.now. Tests use a fake
* clock to drive expiry and rolling-renewal assertions deterministically. */
nowMs?: () => number;
/** PDF book summarizer job registry. When provided, the /v1/pdf-* routes
* are live; when omitted they respond 503 (feature disabled). Built in
* runMain from the same marketplace/chain/wallet/archive deps. */
jobStore?: JobStore;
/** Sizing/safety knobs for the PDF summarizer. Defaults applied when absent. */
pdfCaps?: PdfCaps;
}
interface ResolvedDeps {
@@ -943,6 +953,12 @@ export function createApp(deps: AppDeps): Express {
});
}
// ── PDF book summarizer (/v1/pdf-*) ─────────────────────────────────
// Registered after the other /v1/* routes (so it's behind the same auth
// gate) and before the SPA static fallback. When jobStore is absent the
// routes respond 503 (feature disabled).
registerPdfRoutes(app, deps.jobStore, deps.pdfCaps ?? defaultPdfCaps());
if (deps.distPath) {
let isDir = false;
try {

View File

@@ -13,6 +13,7 @@ import Dashboard from "./pages/Dashboard.js";
import TaskHistory from "./pages/TaskHistory.js";
import Wallet from "./pages/Wallet.js";
import PendingReceipts from "./pages/PendingReceipts.js";
import BookSummarizer from "./pages/BookSummarizer.js";
import { RequireAuth, useAuth } from "./state/AuthContext.js";
function SignOutButton() {
@@ -34,6 +35,7 @@ export default function App() {
<div className="min-h-screen bg-gray-50 text-gray-900">
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link>
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
<Link to="/pending" className="font-medium hover:underline">Pending</Link>
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
@@ -42,6 +44,7 @@ export default function App() {
<main className="p-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/book" element={<BookSummarizer />} />
<Route path="/tasks" element={<TaskHistory />} />
<Route path="/pending" element={<PendingReceipts />} />
<Route path="/wallet" element={<Wallet />} />

View File

@@ -0,0 +1,133 @@
/**
* buyer/src/ui/components/JobEstimate.tsx — step 2: show the pre-spend cost
* estimate and require an explicit "Proceed" before any escrow is posted.
*
* GET /v1/pdf-jobs/:id/estimate returns the map+reduce call count, an
* upper-bound AP3X cost, the wallet balance, and whether the spend would drop
* the wallet below its floor (in which case Proceed is disabled).
*/
import { useEffect, useState } from "react";
import type { UploadInfo } from "./PdfUploadForm.js";
interface EstimateData {
mapCalls: number;
reduceCalls: number;
totalCalls: number;
perCallMaxLovelace: string;
totalLovelace: string;
totalAp3x: string;
suppliers: { model: string; price_lovelace: string }[];
wallet_balance_lovelace: string;
wallet_floor_lovelace: string;
would_drop_below_floor: boolean;
no_capable_suppliers: boolean;
}
function ap3x(lovelace: string): string {
return (Number(lovelace) / 1e6).toFixed(2);
}
export default function JobEstimate({
info,
onStarted,
}: {
info: UploadInfo;
onStarted: () => void;
}) {
const [est, setEst] = useState<EstimateData | null>(null);
const [error, setError] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
useEffect(() => {
let cancelled = false;
fetch(`/v1/pdf-jobs/${info.job_id}/estimate`, { credentials: "same-origin" })
.then(async (r) => {
const body = await r.json();
if (!r.ok) throw new Error(`${body.error}: ${body.message ?? ""}`);
if (!cancelled) setEst(body as EstimateData);
})
.catch((e: Error) => !cancelled && setError(e.message));
return () => {
cancelled = true;
};
}, [info.job_id]);
const proceed = async (): Promise<void> => {
setStarting(true);
setError(null);
try {
const r = await fetch(`/v1/pdf-jobs/${info.job_id}/start`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
const body = await r.json();
if (!r.ok) throw new Error(`${body.error}: ${body.message ?? ""}`);
onStarted();
} catch (e) {
setError((e as Error).message);
setStarting(false);
}
};
if (error) {
return (
<div role="alert" className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
);
}
if (!est) return <div className="text-sm text-gray-500">Estimating cost…</div>;
const blocked = est.would_drop_below_floor || est.no_capable_suppliers || est.totalCalls === 0;
return (
<div className="space-y-4">
<div className="rounded border border-gray-200 bg-white p-4 text-sm">
<h3 className="mb-2 font-medium">{info.filename}</h3>
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-gray-700">
<dt>Pages</dt>
<dd className="text-right">{info.page_count}</dd>
<dt>Chunks (map calls)</dt>
<dd className="text-right">{est.mapCalls}</dd>
<dt>Reduce calls</dt>
<dd className="text-right">{est.reduceCalls}</dd>
<dt className="font-medium">Total paid calls</dt>
<dd className="text-right font-medium">{est.totalCalls}</dd>
<dt className="font-medium">Est. max cost</dt>
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
<dt>Wallet balance</dt>
<dd className="text-right">{ap3x(est.wallet_balance_lovelace)} AP3X</dd>
<dt>Wallet floor</dt>
<dd className="text-right">{ap3x(est.wallet_floor_lovelace)} AP3X</dd>
</dl>
<p className="mt-3 text-xs text-gray-500">
Suppliers: {est.suppliers.map((s) => `${s.model} (${ap3x(s.price_lovelace)})`).join(", ") || "none"}
</p>
</div>
{est.no_capable_suppliers && (
<div className="rounded border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-900">
No capable suppliers are available right now — try again shortly.
</div>
)}
{est.would_drop_below_floor && (
<div className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">
This job would drop the wallet below its floor. Fund the wallet or
reduce the book size before proceeding.
</div>
)}
<button
type="button"
onClick={() => void proceed()}
disabled={blocked || starting}
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
>
{starting ? "Starting…" : `Proceed & pay (~${est.totalAp3x} AP3X)`}
</button>
</div>
);
}

View File

@@ -0,0 +1,86 @@
/**
* buyer/src/ui/components/JobProgress.tsx — step 3: live progress via SSE.
*
* Opens EventSource('/v1/pdf-jobs/:id/events'). The server replays buffered
* frames on connect, so a refresh mid-job catches up. On the terminal `done`
* event we close the stream and hand control back to the page.
*/
import { useEffect, useRef, useState } from "react";
interface ProgressFrame {
phase: "map" | "reduce" | "done" | "error";
label?: string;
completed: number;
failed: number;
total: number;
reduce_level?: number;
running_cost_lovelace: string;
coverage: { done: number; total: number };
message?: string;
status?: string;
}
function ap3x(lovelace: string): string {
return (Number(lovelace) / 1e6).toFixed(2);
}
export default function JobProgress({
jobId,
total,
onDone,
}: {
jobId: string;
total: number;
onDone: () => void;
}) {
const [frame, setFrame] = useState<ProgressFrame | null>(null);
const [log, setLog] = useState<string[]>([]);
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
useEffect(() => {
const es = new EventSource(`/v1/pdf-jobs/${jobId}/events`);
es.addEventListener("progress", (e: MessageEvent) => {
const f = JSON.parse(e.data) as ProgressFrame;
setFrame(f);
if (f.message) setLog((prev) => [...prev.slice(-20), `${f.label ?? f.phase}: ${f.message}`]);
});
es.addEventListener("done", (e: MessageEvent) => {
setFrame(JSON.parse(e.data) as ProgressFrame);
es.close();
onDoneRef.current();
});
es.onerror = () => {
// Transient drop — EventSource auto-reconnects; the server replays
// buffered frames so we don't lose state.
};
return () => es.close();
}, [jobId]);
const done = frame?.coverage.done ?? 0;
const totalCount = frame?.total ?? total;
const pct = totalCount > 0 ? Math.round((done / totalCount) * 100) : 0;
return (
<div className="space-y-3">
<div className="h-3 w-full overflow-hidden rounded bg-gray-200">
<div className="h-full bg-indigo-600 transition-all" style={{ width: `${pct}%` }} />
</div>
<div className="flex justify-between text-sm text-gray-700">
<span>
{frame?.phase === "reduce" ? `Reducing (level ${frame.reduce_level ?? "?"})` : "Summarizing chunks"} —{" "}
{done}/{totalCount} ({pct}%)
</span>
<span>
{frame ? `${frame.failed} gaps · ${ap3x(frame.running_cost_lovelace)} AP3X spent` : "starting…"}
</span>
</div>
{log.length > 0 && (
<pre className="max-h-40 overflow-auto rounded border border-gray-200 bg-gray-50 p-2 text-xs text-gray-600">
{log.join("\n")}
</pre>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
/**
* buyer/src/ui/components/PdfUploadForm.tsx — step 1 of the book summarizer.
*
* Posts a PDF as multipart/form-data to POST /v1/pdf-upload. The server
* extracts + chunks it (no escrow yet) and returns a job_id + chunk count.
* No private key or chain access in the browser — everything goes through the
* same-origin /v1/* server routes.
*/
import { useState } from "react";
export interface UploadInfo {
job_id: string;
filename: string;
page_count: number;
chunk_count: number;
sample_chunk: string;
}
export default function PdfUploadForm({ onUploaded }: { onUploaded: (info: UploadInfo) => void }) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
if (!file) return;
setLoading(true);
setError(null);
try {
const fd = new FormData();
fd.append("file", file);
const resp = await fetch("/v1/pdf-upload", {
method: "POST",
credentials: "same-origin",
body: fd,
});
const body = (await resp.json()) as UploadInfo & { error?: string; message?: string };
if (!resp.ok) {
throw new Error(`${body.error ?? resp.statusText}: ${body.message ?? ""}`);
}
onUploaded(body);
} catch (err) {
setError((err as Error).message ?? "upload failed");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-gray-600">
Upload a text-based PDF book. The buyer will chunk it and pay marketplace
suppliers to summarize each chunk, then synthesize a full-book summary.
</p>
<input
type="file"
accept="application/pdf"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
disabled={loading}
aria-label="pdf file"
className="block text-sm text-gray-700 file:mr-3 file:rounded file:border-0 file:bg-indigo-600 file:px-4 file:py-2 file:text-white hover:file:bg-indigo-700"
/>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={loading || !file}
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
>
Upload &amp; analyze
</button>
{loading && <span className="text-sm text-gray-500">Extracting &amp; chunking…</span>}
</div>
{error && (
<div role="alert" className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
</form>
);
}

View File

@@ -0,0 +1,164 @@
/**
* buyer/src/ui/pages/BookSummarizer.tsx — the PDF book summarizer wizard.
*
* Upload → Estimate/Confirm → Live progress (SSE) → Result.
*
* Each step talks only to the same-origin /v1/pdf-* server routes (the browser
* holds no key). One book upload fans out into many on-chain escrows — the
* traffic the marketplace wants — while producing a real book summary.
*/
import { useState } from "react";
import PdfUploadForm, { type UploadInfo } from "../components/PdfUploadForm.js";
import JobEstimate from "../components/JobEstimate.js";
import JobProgress from "../components/JobProgress.js";
type Step = "upload" | "estimate" | "running" | "done";
interface ChunkResult {
index: number;
label: string;
status: "ok" | "gap" | "pending";
escrowRef?: string;
supplierModel?: string;
}
interface JobView {
job_id: string;
filename: string;
status: string;
coverage: { done: number; total: number };
running_cost_lovelace: string;
final_summary_md?: string;
chunk_results: ChunkResult[];
escrow_refs: string[];
}
function ap3x(lovelace: string): string {
return (Number(lovelace) / 1e6).toFixed(2);
}
export default function BookSummarizer() {
const [step, setStep] = useState<Step>("upload");
const [info, setInfo] = useState<UploadInfo | null>(null);
const [view, setView] = useState<JobView | null>(null);
const [error, setError] = useState<string | null>(null);
const reset = (): void => {
setStep("upload");
setInfo(null);
setView(null);
setError(null);
};
const loadResult = async (jobId: string): Promise<void> => {
try {
const r = await fetch(`/v1/pdf-jobs/${jobId}`, { credentials: "same-origin" });
const body = await r.json();
if (!r.ok) throw new Error(`${body.error}: ${body.message ?? ""}`);
setView(body as JobView);
setStep("done");
} catch (e) {
setError((e as Error).message);
setStep("done");
}
};
return (
<div className="max-w-3xl space-y-6">
<div>
<h1 className="text-2xl font-semibold">Book Summarizer</h1>
<p className="mt-1 text-sm text-gray-500">
Upload a PDF → pay suppliers to summarize each chunk → get a full-book summary.
</p>
</div>
{step === "upload" && (
<PdfUploadForm
onUploaded={(i) => {
setInfo(i);
setStep("estimate");
}}
/>
)}
{step === "estimate" && info && (
<JobEstimate info={info} onStarted={() => setStep("running")} />
)}
{step === "running" && info && (
<div className="space-y-4">
<h2 className="text-lg font-medium">Summarizing “{info.filename}”…</h2>
<JobProgress
jobId={info.job_id}
total={info.chunk_count}
onDone={() => void loadResult(info.job_id)}
/>
<p className="text-xs text-gray-400">
Each chunk is a separate on-chain escrow (PostEscrow → supplier → Accept),
so this can take a while — that’s real marketplace traffic.
</p>
</div>
)}
{step === "done" && (
<div className="space-y-4">
{error && (
<div role="alert" className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
{view && (
<>
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-sm">
<span className="font-medium">
{view.status === "completed"
? "✅ Completed"
: view.status === "completed_with_gaps"
? "⚠️ Completed with gaps"
: "❌ Failed"}
</span>
<span>
Coverage: {view.coverage.done}/{view.coverage.total} chunks
</span>
<span>Spent: {ap3x(view.running_cost_lovelace)} AP3X</span>
<span>{view.escrow_refs.length} escrows</span>
</div>
{view.final_summary_md && (
<a
href={`/v1/pdf-jobs/${view.job_id}/summary.md`}
className="inline-block rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
Download summary (.md)
</a>
)}
{view.final_summary_md && (
<pre className="max-h-[28rem] overflow-auto whitespace-pre-wrap rounded border border-gray-200 bg-white p-4 text-sm text-gray-800">
{view.final_summary_md}
</pre>
)}
<details className="text-xs text-gray-500">
<summary className="cursor-pointer">On-chain escrows ({view.escrow_refs.length})</summary>
<ul className="mt-2 space-y-0.5 font-mono">
{view.escrow_refs.map((ref) => (
<li key={ref}>{ref}</li>
))}
</ul>
</details>
</>
)}
<button
type="button"
onClick={reset}
className="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
Summarize another book
</button>
</div>
)}
</div>
);
}

116
pnpm-lock.yaml generated
View File

@@ -95,6 +95,15 @@ importers:
express:
specifier: ^4.21.2
version: 4.22.1
gpt-tokenizer:
specifier: ^2.8.1
version: 2.9.0
multer:
specifier: ^1.4.5-lts.1
version: 1.4.5-lts.2
pdf-parse:
specifier: ^1.1.1
version: 1.1.4
react:
specifier: ^18.3.1
version: 18.3.1
@@ -120,6 +129,12 @@ importers:
'@types/express':
specifier: ^5.0.3
version: 5.0.6
'@types/multer':
specifier: ^1.4.11
version: 1.4.13
'@types/pdf-parse':
specifier: ^1.1.4
version: 1.1.5
'@types/react':
specifier: ^18.3.0
version: 18.3.28
@@ -1165,9 +1180,15 @@ packages:
'@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
'@types/multer@1.4.13':
resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==}
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/pdf-parse@1.1.5':
resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -1281,6 +1302,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
append-field@1.0.0:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
@@ -1376,9 +1400,16 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -1450,6 +1481,10 @@ packages:
component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
concat-stream@1.6.2:
resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==}
engines: {'0': node >= 0.8}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -1779,6 +1814,9 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
gpt-tokenizer@2.9.0:
resolution: {integrity: sha512-YSpexBL/k4bfliAzMrRqn3M6+it02LutVyhVpDeMKrC/O9+pCe/5s8U2hYKa2vFLD5/vHhsKc8sOn/qGqII8Kg==}
happy-dom@15.11.7:
resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==}
engines: {node: '>=18.0.0'}
@@ -2062,12 +2100,21 @@ packages:
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multer@1.4.5-lts.2:
resolution: {integrity: sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==}
engines: {node: '>= 6.0.0'}
deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.
multiformats@13.4.2:
resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==}
@@ -2096,6 +2143,9 @@ packages:
resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==}
engines: {node: '>=10'}
node-ensure@0.0.0:
resolution: {integrity: sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@@ -2251,6 +2301,10 @@ packages:
resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==}
engines: {node: '>= 0.10'}
pdf-parse@1.1.4:
resolution: {integrity: sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==}
engines: {node: '>=6.8.1'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2544,6 +2598,10 @@ packages:
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -2686,6 +2744,9 @@ packages:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -2857,6 +2918,10 @@ packages:
utf-8-validate:
optional: true
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -3731,10 +3796,18 @@ snapshots:
'@types/methods@1.1.4': {}
'@types/multer@1.4.13':
dependencies:
'@types/express': 5.0.6
'@types/node@25.6.0':
dependencies:
undici-types: 7.19.2
'@types/pdf-parse@1.1.5':
dependencies:
'@types/node': 25.6.0
'@types/prop-types@15.7.15': {}
'@types/qs@6.15.0': {}
@@ -3874,6 +3947,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.2
append-field@1.0.0: {}
arg@5.0.2: {}
aria-query@5.3.0:
@@ -3978,11 +4053,17 @@ snapshots:
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer-from@1.1.2: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
bytes@3.1.2: {}
cac@6.7.14: {}
@@ -4068,6 +4149,13 @@ snapshots:
component-emitter@1.3.1: {}
concat-stream@1.6.2:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 2.3.8
typedarray: 0.0.6
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
@@ -4446,6 +4534,8 @@ snapshots:
gopd@1.2.0: {}
gpt-tokenizer@2.9.0: {}
happy-dom@15.11.7:
dependencies:
entities: 4.5.0
@@ -4692,10 +4782,24 @@ snapshots:
mkdirp-classic@0.5.3: {}
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
ms@2.0.0: {}
ms@2.1.3: {}
multer@1.4.5-lts.2:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
concat-stream: 1.6.2
mkdirp: 0.5.6
object-assign: 4.1.1
type-is: 1.6.18
xtend: 4.0.2
multiformats@13.4.2: {}
multipasta@0.2.7: {}
@@ -4718,6 +4822,8 @@ snapshots:
dependencies:
semver: 7.7.4
node-ensure@0.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
@@ -4782,6 +4888,10 @@ snapshots:
sha.js: 2.4.12
to-buffer: 1.2.2
pdf-parse@1.1.4:
dependencies:
node-ensure: 0.0.0
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -5125,6 +5235,8 @@ snapshots:
std-env@3.10.0: {}
streamsearch@1.1.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -5311,6 +5423,8 @@ snapshots:
es-errors: 1.3.0
is-typed-array: 1.1.15
typedarray@0.0.6: {}
typescript@5.9.3: {}
uint8-varint@2.0.4:
@@ -5469,4 +5583,6 @@ snapshots:
ws@8.20.0: {}
xtend@4.0.2: {}
yallist@3.1.1: {}

View 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([]);
});
});

View 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);
});
});

View 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();
}
});
});

View 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;
}

View 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");
});
});

View 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]);
});
});