forked from EduCraft/curriculum-project-hub
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:
@@ -63,8 +63,8 @@ export async function resolveCapabilityCredential(
|
||||
organizationId: connection.organizationId,
|
||||
capabilityId: connection.capabilityId,
|
||||
schemaVersion: 1,
|
||||
baseUrl: payload.baseUrl,
|
||||
apiToken: payload.apiToken,
|
||||
projectId: payload.projectId,
|
||||
accessKeyId: payload.accessKeyId,
|
||||
accessKeySecret: payload.accessKeySecret,
|
||||
endpoint: payload.endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* ADR-0027: MinerU client interface. Isolates the real HTTP client so the
|
||||
* adapter is testable without network access. The real implementation (filling
|
||||
* in actual MinerU API calls) is deferred until credentials and pricing are
|
||||
* confirmed; the interface and a mock implementation land now so the adapter
|
||||
* wiring, UsageFact attribution, and workspace containment are provable.
|
||||
*
|
||||
* MinerU cloud API shape (from mineru.net docs / GitHub README):
|
||||
* - REST API, async task endpoint POST /tasks (v3.0+) + sync POST /file_parse
|
||||
* - Auth: Bearer token (apiToken)
|
||||
* - Output: Markdown + extracted images, returned as a zip or structured JSON
|
||||
* - Cost: reported per-page (pricing requires account confirmation)
|
||||
*
|
||||
* The interface models the synchronous parse path for simplicity; the real
|
||||
* client may poll the async endpoint internally and is free to do so behind
|
||||
* this signature.
|
||||
*/
|
||||
import type { CapabilitySecretPayload } from "./types.js";
|
||||
|
||||
/** A single extracted image from the parsed document. */
|
||||
export interface MineruExtractedImage {
|
||||
/** Suggested relative filename (e.g. "page_1_fig_0.jpg"). */
|
||||
readonly filename: string;
|
||||
/** Raw image bytes. */
|
||||
readonly data: Uint8Array;
|
||||
}
|
||||
|
||||
/** The structured result of parsing one document. */
|
||||
export interface MineruParseResult {
|
||||
/** Markdown text with image references (relative to output dir). */
|
||||
readonly markdown: string;
|
||||
/** Images extracted from the document, to be written alongside the md. */
|
||||
readonly images: readonly MineruExtractedImage[];
|
||||
/** Number of pages processed (for UsageFact quantity, unit "pages"). */
|
||||
readonly pageCount: number;
|
||||
/** USD cost if the API reported it; null if unknown (ADR-0022). */
|
||||
readonly costUsd: number | null;
|
||||
/** External task/request id for the UsageFact correlationId. */
|
||||
readonly requestId: string | null;
|
||||
}
|
||||
|
||||
/** Options passed to the client. */
|
||||
export interface MineruParseOptions {
|
||||
/** Absolute path to the input PDF on the Hub's filesystem. */
|
||||
readonly inputFilePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client interface for the MinerU document parsing service. The real
|
||||
* implementation makes authenticated HTTP calls; tests inject a mock.
|
||||
*/
|
||||
export interface MineruClient {
|
||||
parse(credential: CapabilitySecretPayload, options: MineruParseOptions): Promise<MineruParseResult>;
|
||||
}
|
||||
|
||||
/** Errors raised by the MinerU client. */
|
||||
export class MineruClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: "mineru_unreachable" | "mineru_rejected" | "mineru_invalid_response" | "mineru_no_output",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "MineruClientError";
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { join, resolve, relative, isAbsolute } from "node:path";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { resolveCapabilityCredential } from "./capabilityConnections.js";
|
||||
import { MineruClientError, type MineruClient } from "./mineruClient.js";
|
||||
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
|
||||
import {
|
||||
CAPABILITIES,
|
||||
type CapabilityAdapter,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "./types.js";
|
||||
|
||||
const CAPABILITY_ID = "pdf_to_md_bundle" as const;
|
||||
const PROVIDER_ID = "mineru";
|
||||
const PROVIDER_ID = "aliyun_docmind";
|
||||
|
||||
/** Thrown when a requested path escapes the workspace root (ADR-0018). */
|
||||
export class CapabilityPathEscape extends Error {
|
||||
@@ -59,11 +59,11 @@ function confineToWorkspace(requestedPath: string, workspaceDir: string): string
|
||||
|
||||
export interface PdfToMdBundleDeps {
|
||||
readonly secrets: LocalSecretEnvelope;
|
||||
readonly client: MineruClient;
|
||||
readonly client: CapabilityProviderClient;
|
||||
readonly prisma: PrismaClient;
|
||||
}
|
||||
|
||||
/** Build the pdf_to_md_bundle adapter. The MineruClient is injectable for testing. */
|
||||
/** Build the pdf_to_md_bundle adapter. The client is injectable for testing. */
|
||||
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
|
||||
return {
|
||||
capabilityId: CAPABILITY_ID,
|
||||
@@ -85,14 +85,14 @@ export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityA
|
||||
try {
|
||||
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
|
||||
} catch (e) {
|
||||
if (e instanceof MineruClientError) throw e;
|
||||
throw new MineruClientError(
|
||||
if (e instanceof DocmindClientError) throw e;
|
||||
throw new DocmindClientError(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
"mineru_unreachable",
|
||||
"docmind_unreachable",
|
||||
);
|
||||
}
|
||||
if (result.markdown === "") {
|
||||
throw new MineruClientError("MinerU returned empty markdown", "mineru_no_output");
|
||||
throw new DocmindClientError("docmind returned empty markdown", "docmind_no_output");
|
||||
}
|
||||
|
||||
// 4. Write outputs into the workspace.
|
||||
|
||||
@@ -80,12 +80,13 @@ export interface CapabilityAdapter {
|
||||
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
|
||||
}
|
||||
|
||||
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027). */
|
||||
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
|
||||
* Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */
|
||||
export interface CapabilitySecretPayload {
|
||||
readonly schemaVersion: 1;
|
||||
readonly baseUrl: string;
|
||||
readonly apiToken: string;
|
||||
readonly projectId: string | null;
|
||||
readonly accessKeyId: string;
|
||||
readonly accessKeySecret: string;
|
||||
readonly endpoint: string;
|
||||
}
|
||||
|
||||
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
|
||||
|
||||
Reference in New Issue
Block a user