feat(hub): switch capability provider to Aliyun Doc Mind (ADR-0027) (#6)

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
This commit is contained in:
2026-07-18 16:42:42 +08:00
committed by 洪佳荣
parent b673dd1fe9
commit 64b3d1fc64
8 changed files with 593 additions and 113 deletions
+194
View File
@@ -0,0 +1,194 @@
/**
* ADR-0027: Alibaba Cloud Document Mind (docmind) client.
*
* 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
*
* 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 { basename } from "node:path";
import type { CapabilitySecretPayload } from "./types.js";
/** A single extracted image (page render) from the parsed document. */
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<DocmindParseResult>;
}
/** Errors raised by the docmind client. */
export class DocmindClientError extends Error {
constructor(
message: string,
readonly code: "docmind_unreachable" | "docmind_rejected" | "docmind_invalid_response" | "docmind_no_output" | "docmind_timeout",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "DocmindClientError";
}
}
/** Cost per page in USD (0.04 CNY/page ≈ 0.0056 USD, rate ~7.15). Updated when pricing confirmed. */
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<typeof $DocmindClient.default>[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<DocmindParseResult> {
const config: DocmindConfig = {
endpoint: credential.endpoint,
accessKeyId: credential.accessKeyId,
accessKeySecret: credential.accessKeySecret,
type: "access_key",
regionId: "cn-hangzhou",
} as DocmindConfig;
const client = new $DocmindClient.default(config);
// 1. Submit job with local file upload.
const fileName = basename(options.inputFilePath);
const fileBuffer = await readFile(options.inputFilePath);
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileBuffer,
fileName,
outputFormat: ["markdown"],
formulaEnhancement: true,
});
const runtime = new RuntimeOptions({});
let submitResponse;
try {
submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime);
} catch (e) {
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"docmind_unreachable",
);
}
const jobId = submitResponse.body?.data?.id;
if (jobId === undefined || jobId === null || jobId === "") {
throw new DocmindClientError("docmind submit returned no job id", "docmind_invalid_response");
}
// 2. Poll until completed.
const deadline = Date.now() + POLL_TIMEOUT_MS;
let completed = false;
let status = "";
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;
}
if (!completed) {
throw new DocmindClientError(`docmind job ${jobId} timed out (status: ${status})`, "docmind_timeout");
}
if (status === "Fail") {
throw new DocmindClientError(`docmind job ${jobId} failed`, "docmind_rejected");
}
// 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 ?? "";
if (markdown === "") {
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
}
// 4. Download page images.
const pages = resultBody.data?.docInfo?.pages ?? [];
const images: DocmindExtractedImage[] = [];
for (const page of pages) {
if (page.imageUrl === undefined || page.imageUrl === null || page.imageUrl === "") continue;
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 });
} 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;
return { markdown, images, pageCount, costUsd, requestId: jobId };
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/** 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;
}