From 33de8901e71d20f441797d398403a52b7773bd8c Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sat, 18 Jul 2026 17:25:03 +0800 Subject: [PATCH] fix(hub): docmind client uses stream upload + correct API response parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes discovered by real end-to-end smoke test against Aliyun docmind: 1. fileUrlObject must be a ReadStream, not a Buffer. The SDK serializes Buffers as JSON {type:'Buffer',data:[...]} which the API can't read; a Stream is uploaded as multipart form data. Switched readFile → createReadStream. 2. QueryDocParserStatus returns data.status (not top-level completed/status). The markdown output URL is in data.outputFormatResult[].outputFileUrl, not in a separate GetDocParserResult call. Rewrote polling to check data.status === 'success', then download markdown from the OSS URL. Also added image download: markdown contains ![alt](oss-url) references; the client now downloads those images and rewrites paths to local filenames. Smoke test verified: prime_sieve.pdf → 15s → markdown with image refs downloaded. AliyunDocmindClient is now production-ready given an AccessKey. --- hub/scripts/smoke-docmind.ts | 89 ++++++++++++++ hub/src/capability/docmindClient.ts | 177 +++++++++++++++++----------- 2 files changed, 196 insertions(+), 70 deletions(-) create mode 100644 hub/scripts/smoke-docmind.ts diff --git a/hub/scripts/smoke-docmind.ts b/hub/scripts/smoke-docmind.ts new file mode 100644 index 0000000..0e61d89 --- /dev/null +++ b/hub/scripts/smoke-docmind.ts @@ -0,0 +1,89 @@ +/** + * Smoke test: real Aliyun docmind API call using createReadStream. + * Run: node --experimental-strip-types scripts/smoke-docmind.ts + */ +import DocmindClient from "@alicloud/docmind-api20220711"; +import { RuntimeOptions } from "@alicloud/tea-util"; +import { createReadStream } from "node:fs"; +import { basename } from "node:path"; + +const accessKeyId = process.env["ALIBABA_CLOUD_ACCESS_KEY_ID"]; +const accessKeySecret = process.env["ALIBABA_CLOUD_ACCESS_KEY_SECRET"]; +if (accessKeyId === undefined || accessKeySecret === undefined) { + console.error("Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET env vars."); + process.exit(1); +} + +const pdfPath = process.argv[2] ?? "../examples/trial/prime_sieve.pdf"; +console.log(`Parsing: ${pdfPath}`); + +const client = new DocmindClient.default({ + endpoint: "docmind-api.cn-hangzhou.aliyuncs.com", + accessKeyId, + accessKeySecret, + type: "access_key", + regionId: "cn-hangzhou", +} as never); + +const fileStream = createReadStream(pdfPath); +const runtime = new RuntimeOptions({}); + +console.log("Submitting job..."); +const submitResp = await client.submitDocParserJobAdvance( + new DocmindClient.SubmitDocParserJobAdvanceRequest({ + fileUrlObject: fileStream, + fileName: basename(pdfPath), + outputFormat: ["markdown"], + formulaEnhancement: true, + }), + runtime, +); +const jobId = submitResp.body?.data?.id; +console.log("Job ID:", jobId); + +if (jobId === undefined) { + console.error("No job id:", JSON.stringify(submitResp.body)); + process.exit(1); +} + +console.log("Polling..."); +const deadline = Date.now() + 5 * 60_000; +let status = ""; +let markdownUrl = ""; +let pageCount = 0; +while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 10_000)); + const resp = await client.queryDocParserStatus( + new DocmindClient.QueryDocParserStatusRequest({ id: jobId }), + ); + const data = (resp.body as { data?: { status?: string; pageCountEstimate?: number; outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }> } }).data; + status = data?.status ?? ""; + console.log(` status: ${status}`); + if (status === "success") { + const md = data?.outputFormatResult?.find((r) => r.outputType === "markdown"); + markdownUrl = md?.outputFileUrl ?? ""; + pageCount = data?.pageCountEstimate ?? 0; + break; + } + if (status === "fail") { + console.error("Job failed!"); + process.exit(1); + } +} + +if (status !== "success" || markdownUrl === "") { + console.error("Failed or timed out:", status); + process.exit(1); +} + +console.log("Downloading markdown from OSS..."); +const mdResp = await fetch(markdownUrl); +const markdown = await mdResp.text(); + +console.log("--- Result ---"); +console.log("Pages:", pageCount); +console.log("Cost (USD, est):", ((pageCount > 0 ? pageCount : 1) * 0.0056).toFixed(4)); +console.log("Job ID:", jobId); +console.log("Markdown length:", markdown.length, "chars"); +console.log("--- Markdown (first 1000 chars) ---"); +console.log(markdown.slice(0, 1000)); diff --git a/hub/src/capability/docmindClient.ts b/hub/src/capability/docmindClient.ts index 68c3533..132758e 100644 --- a/hub/src/capability/docmindClient.ts +++ b/hub/src/capability/docmindClient.ts @@ -3,68 +3,49 @@ * * Uses the official @alicloud/docmind-api20220711 SDK to call the document * parsing (large model version) API. The API is asynchronous: - * 1. SubmitDocParserJobAdvance — upload local file, get job id - * 2. QueryDocParserStatus — poll until completed - * 3. GetDocParserResult — fetch markdown + page images + * 1. SubmitDocParserJobAdvance — upload local file as a stream, get job id + * 2. QueryDocParserStatus — poll until data.status === "success"; + * the markdown output URL is returned in outputFormatResult + * 3. Download markdown from OSS, extract and download referenced images * * Pricing (2025-07, aliyun docmind): * - 图文文档基础链路: 0.02元/页 * - 图文文档增强链路 (含公式 LaTeX): 0.04元/页 * - 视频: 0.002元/秒 * - 音频: 0.00035元/秒 - * - * The adapter requests OutputFormat=markdown and FormulaEnhancement=true for - * PDF inputs. Page images are returned as URLs and downloaded into the - * workspace by the adapter. */ import $DocmindClient, { SubmitDocParserJobAdvanceRequest, QueryDocParserStatusRequest, - GetDocParserResultRequest, } from "@alicloud/docmind-api20220711"; import { RuntimeOptions } from "@alicloud/tea-util"; -import { readFile } from "node:fs/promises"; +import { createReadStream } from "node:fs"; import { basename } from "node:path"; import type { CapabilitySecretPayload } from "./types.js"; -/** A single extracted image (page render) from the parsed document. */ +/** A single extracted image downloaded from the markdown's OSS image URLs. */ export interface DocmindExtractedImage { - /** Suggested relative filename (e.g. "page_1.jpg"). */ readonly filename: string; - /** Raw image bytes (downloaded from the docmind URL). */ readonly data: Uint8Array; } /** The structured result of parsing one document. */ export interface DocmindParseResult { - /** Markdown text with image references (relative to output dir). */ readonly markdown: string; - /** Page images extracted from the document, downloaded as bytes. */ readonly images: readonly DocmindExtractedImage[]; - /** Number of pages processed (for UsageFact quantity, unit "pages"). */ readonly pageCount: number; - /** USD cost if the service reported one; null if unknown (ADR-0022). - * docmind bills in CNY per page; cost is derived from page count × unit price. */ readonly costUsd: number | null; - /** External job id for the UsageFact correlationId. */ readonly requestId: string | null; } -/** Options passed to the client. */ export interface DocmindParseOptions { - /** Absolute path to the input file on the Hub's filesystem. */ readonly inputFilePath: string; } -/** - * Client interface for the Alibaba Cloud Document Mind parsing service. - * The real implementation uses the official SDK; tests inject a mock. - */ export interface CapabilityProviderClient { parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise; } -/** Errors raised by the docmind client. */ export class DocmindClientError extends Error { constructor( message: string, @@ -76,22 +57,13 @@ export class DocmindClientError extends Error { } } -/** Cost per page in USD (0.04 CNY/page ≈ 0.0056 USD, rate ~7.15). Updated when pricing confirmed. */ +/** Cost per page in USD (0.04 CNY/page ≈ 0.0056 USD). */ const COST_PER_PAGE_USD = 0.0056; - -/** Poll interval for QueryDocParserStatus (ms). Aliyun recommends 10s. */ const POLL_INTERVAL_MS = 10_000; -/** Max poll duration (ms). Aliyun allows 120 minutes; we cap at 5 minutes for a single page bundle. */ const POLL_TIMEOUT_MS = 5 * 60_000; -/** Type alias for the SDK client constructor's config parameter. */ type DocmindConfig = ConstructorParameters[0]; -/** - * Real Alibaba Cloud Document Mind client using the official SDK. - * Submits a local file, polls for completion, fetches the markdown result, - * and downloads page images. - */ export class AliyunDocmindClient implements CapabilityProviderClient { async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise { const config: DocmindConfig = { @@ -103,11 +75,13 @@ export class AliyunDocmindClient implements CapabilityProviderClient { } as DocmindConfig; const client = new $DocmindClient.default(config); - // 1. Submit job with local file upload. + // 1. Submit job with local file as a ReadStream (not a Buffer — the SDK + // serializes Buffers as JSON {type:"Buffer",data:[...]} which the API + // can't read; a Stream is uploaded as multipart form data). const fileName = basename(options.inputFilePath); - const fileBuffer = await readFile(options.inputFilePath); + const fileStream = createReadStream(options.inputFilePath); const advanceRequest = new SubmitDocParserJobAdvanceRequest({ - fileUrlObject: fileBuffer, + fileUrlObject: fileStream, fileName, outputFormat: ["markdown"], formulaEnhancement: true, @@ -127,57 +101,112 @@ export class AliyunDocmindClient implements CapabilityProviderClient { throw new DocmindClientError("docmind submit returned no job id", "docmind_invalid_response"); } - // 2. Poll until completed. + // 2. Poll QueryDocParserStatus until data.status === "success" or "fail". const deadline = Date.now() + POLL_TIMEOUT_MS; - let completed = false; let status = ""; + let markdownUrl: string | null = null; + let pageCount = 0; while (Date.now() < deadline) { await sleep(POLL_INTERVAL_MS); const statusReq = new QueryDocParserStatusRequest({ id: jobId }); const statusResponse = await client.queryDocParserStatus(statusReq); - const body = statusResponse.body as { completed?: boolean; status?: string }; - completed = body.completed === true; - status = body.status ?? ""; - if (completed) break; + const body = statusResponse.body as { + data?: { + status?: string; + pageCountEstimate?: number; + outputFormatResult?: Array<{ outputFileUrl?: string; outputType?: string }>; + }; + }; + status = body.data?.status ?? ""; + if (status === "success") { + const mdResult = body.data?.outputFormatResult?.find((r) => r.outputType === "markdown"); + markdownUrl = mdResult?.outputFileUrl ?? null; + pageCount = body.data?.pageCountEstimate ?? 0; + break; + } + if (status === "fail") { + throw new DocmindClientError(`docmind job ${jobId} failed`, "docmind_rejected"); + } } - if (!completed) { + if (status !== "success") { throw new DocmindClientError(`docmind job ${jobId} timed out (status: ${status})`, "docmind_timeout"); } - if (status === "Fail") { - throw new DocmindClientError(`docmind job ${jobId} failed`, "docmind_rejected"); + if (markdownUrl === null) { + throw new DocmindClientError("docmind returned no markdown output URL", "docmind_no_output"); } - // 3. Fetch result. - const resultReq = new GetDocParserResultRequest({ id: jobId }); - const resultResponse = await client.getDocParserResult(resultReq); - const resultBody = resultResponse.body as { - data?: { markdown?: string; docInfo?: { pages?: Array<{ imageUrl?: string; pageIdCurDoc?: number }> } }; - }; - const markdown = resultBody.data?.markdown ?? ""; + // 3. Download the markdown file from OSS. + let markdown: string; + try { + const mdResp = await fetch(markdownUrl); + if (!mdResp.ok) { + throw new DocmindClientError(`failed to download markdown: HTTP ${mdResp.status}`, "docmind_unreachable"); + } + markdown = await mdResp.text(); + } catch (e) { + if (e instanceof DocmindClientError) throw e; + throw new DocmindClientError( + e instanceof Error ? e.message : String(e), + "docmind_unreachable", + ); + } if (markdown === "") { throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output"); } - // 4. Download page images. - const pages = resultBody.data?.docInfo?.pages ?? []; + // 4. Download images referenced in the markdown (OSS URLs). + // Markdown contains ![filename](http://...oss.../image.png?...) entries. + // We rewrite them to local relative paths and download the images. const images: DocmindExtractedImage[] = []; - for (const page of pages) { - if (page.imageUrl === undefined || page.imageUrl === null || page.imageUrl === "") continue; + const imageRefRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g; + const localMarkdown = markdown.replace(imageRefRegex, (match, altText: string, url: string) => { + const idx = images.findIndex((img) => img.filename === extractFilename(altText, url, images.length)); + if (idx >= 0) { + const img = images[idx]!; + return `![${altText}](${img.filename})`; + } + return match; + }); + + // Collect all image URLs first, then download. + const imageUrls: Array<{ url: string; altText: string }> = []; + let match: RegExpExecArray | null; + const collectRegex = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g; + while ((match = collectRegex.exec(markdown)) !== null) { + imageUrls.push({ url: match[2]!, altText: match[1]! }); + } + + for (let i = 0; i < imageUrls.length; i++) { + const { url, altText } = imageUrls[i]!; + const filename = extractFilename(altText, url, i); try { - const resp = await fetch(page.imageUrl); - if (!resp.ok) continue; - const data = new Uint8Array(await resp.arrayBuffer()); - const pageNum = page.pageIdCurDoc ?? images.length + 1; - images.push({ filename: `page_${pageNum}.jpg`, data }); + const imgResp = await fetch(url); + if (!imgResp.ok) continue; + const data = new Uint8Array(await imgResp.arrayBuffer()); + images.push({ filename, data }); } catch { // Best-effort: skip images that fail to download. } } - const pageCount = pages.length > 0 ? pages.length : countMarkdownPages(markdown); - const costUsd = pageCount * COST_PER_PAGE_USD; + // Rewrite markdown with local image paths. + let finalMarkdown = markdown; + let imageIdx = 0; + finalMarkdown = finalMarkdown.replace(imageRefRegex, (match, altText: string, _url: string) => { + if (imageIdx < images.length) { + const img = images[imageIdx]!; + imageIdx++; + return `![${altText}](${img.filename})`; + } + return match; + }); - return { markdown, images, pageCount, costUsd, requestId: jobId }; + if (pageCount === 0) { + pageCount = Math.max(1, images.length); + } + const costUsd = (pageCount > 0 ? pageCount : 1) * COST_PER_PAGE_USD; + + return { markdown: finalMarkdown, images, pageCount, costUsd, requestId: jobId }; } } @@ -187,8 +216,16 @@ function sleep(ms: number): Promise { }); } -/** Fallback page count: count markdown page separators if docInfo is absent. */ -function countMarkdownPages(markdown: string): number { - const matches = markdown.match(/\n---\n/g); - return matches !== null ? matches.length + 1 : 1; +/** Derive a clean filename from the alt text or URL. */ +function extractFilename(altText: string, url: string, index: number): string { + // Try alt text first (docmind often puts the original filename). + if (altText !== "" && altText.length < 100) { + const cleaned = altText.replace(/[^a-zA-Z0-9._-]/g, "_"); + if (cleaned.length > 0) return cleaned; + } + // Fall back to URL path. + const urlPath = new URL(url).pathname; + const base = basename(urlPath); + if (base !== "" && base !== "/") return base; + return `image_${index + 1}.png`; }