forked from EduCraft/curriculum-project-hub
feat(hub): concurrent multi-PDF convert_pdf_to_md + readable skills (v0.0.40)
Teachers convert many PDFs in one tool call with bounded Docmind concurrency. Each item keeps its own output_dir/document.md and UsageFact; failures are per-file. Mirror role skills to .cph/runtime-skills and CPH_RUNTIME_SKILLS_DIR so agents can Read SKILL.md instead of dead .claude/sandbox stubs.
This commit is contained in:
@@ -9,7 +9,13 @@ import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.j
|
||||
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { createPdfToMdBundleAdapter } from "../capability/pdfToMdBundle.js";
|
||||
import {
|
||||
createPdfToMdBundleAdapter,
|
||||
invokePdfToMdBatch,
|
||||
readPdfToMdConcurrency,
|
||||
MAX_PDF_TO_MD_BATCH_ITEMS,
|
||||
type PdfToMdBatchItemResult,
|
||||
} from "../capability/pdfToMdBundle.js";
|
||||
import { AliyunDocmindClient } from "../capability/docmindClient.js";
|
||||
|
||||
export interface FileDeliveryToolOptions {
|
||||
@@ -246,21 +252,54 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
tools.push(
|
||||
tool(
|
||||
"convert_pdf_to_md",
|
||||
"Convert a PDF file in the workspace to a Markdown bundle (markdown + extracted images) using Alibaba Cloud Document Mind. The PDF must already be in the workspace (use feishu_download_resource first if it came from Feishu). Returns the path to the generated markdown file and the list of extracted image paths. Mathematical formulas are converted to LaTeX.",
|
||||
"Convert one or more PDF files in the workspace to Markdown bundles (markdown + extracted images) via Alibaba Cloud Document Mind. PDFs must already be in the workspace (use feishu_download_resource first for Feishu attachments). Prefer a single call with `items` for multiple PDFs — Hub converts them concurrently (bounded). Each item needs its own output_dir because the tool always writes document.md inside that directory. Formulas become LaTeX.",
|
||||
{
|
||||
input_path: z.string().describe("Relative path to the input PDF within the workspace."),
|
||||
output_dir: z.string().describe("Relative directory within the workspace to write the markdown and images into. Will be created if it does not exist."),
|
||||
input_path: z.string().optional().describe("Single-file mode: workspace-relative path to the input PDF. Required when `items` is omitted."),
|
||||
output_dir: z.string().optional().describe("Single-file mode: workspace-relative directory for document.md + images. Required when `items` is omitted."),
|
||||
items: z.array(z.object({
|
||||
input_path: z.string().describe("Workspace-relative path to one input PDF."),
|
||||
output_dir: z.string().describe("Workspace-relative output directory for this PDF (must be unique per item)."),
|
||||
})).min(1).max(MAX_PDF_TO_MD_BATCH_ITEMS).optional().describe(`Batch mode: multiple PDFs converted concurrently. Max ${MAX_PDF_TO_MD_BATCH_ITEMS} items. Do not reuse output_dir across items.`),
|
||||
concurrency: z.number().int().min(1).max(8).optional().describe("Optional parallel job limit for batch mode (1-8). Defaults to HUB_PDF_TO_MD_MAX_CONCURRENT (usually 3)."),
|
||||
},
|
||||
async (args) => {
|
||||
const base = {
|
||||
runId: options.runId,
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
workspaceDir: options.workspaceDir,
|
||||
prisma: options.prisma,
|
||||
};
|
||||
try {
|
||||
if (args.items !== undefined && args.items.length > 0) {
|
||||
const batchResults = await invokePdfToMdBatch(
|
||||
adapter,
|
||||
base,
|
||||
args.items.map((item) => ({
|
||||
inputPath: item.input_path,
|
||||
outputDir: item.output_dir,
|
||||
})),
|
||||
args.concurrency ?? readPdfToMdConcurrency(),
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: formatPdfToMdBatchResult(batchResults) }],
|
||||
...(batchResults.every((item) => item.ok) ? {} : { isError: true }),
|
||||
};
|
||||
}
|
||||
if (args.input_path === undefined || args.input_path.trim() === ""
|
||||
|| args.output_dir === undefined || args.output_dir.trim() === "") {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{
|
||||
type: "text",
|
||||
text: "convert_pdf_to_md requires either items[{input_path,output_dir},...] or both input_path and output_dir.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
const result = await adapter.invoke({
|
||||
runId: options.runId,
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
workspaceDir: options.workspaceDir,
|
||||
...base,
|
||||
inputPath: args.input_path,
|
||||
outputDir: args.output_dir,
|
||||
prisma: options.prisma,
|
||||
});
|
||||
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
|
||||
for (const artifact of result.artifacts) {
|
||||
@@ -292,6 +331,32 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function formatPdfToMdBatchResult(results: readonly PdfToMdBatchItemResult[]): string {
|
||||
const ok = results.filter((item) => item.ok);
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
const lines = [
|
||||
`Batch PDF→Markdown finished: ${ok.length} succeeded, ${failed.length} failed (of ${results.length}).`,
|
||||
];
|
||||
for (const item of results) {
|
||||
if (item.ok) {
|
||||
const md = item.result.artifacts.find((artifact) => artifact.kind === "markdown")?.path;
|
||||
lines.push(
|
||||
`OK ${item.inputPath} → ${item.outputDir}`
|
||||
+ (md !== undefined ? ` (${md})` : "")
|
||||
+ `; pages=${item.result.consumption.quantity}`
|
||||
+ `; cost=$${(item.result.consumption.costUsd ?? 0).toFixed(4)}`,
|
||||
);
|
||||
for (const artifact of item.result.artifacts) {
|
||||
lines.push(` - ${artifact.path} (${artifact.kind})`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`FAIL ${item.inputPath} → ${item.outputDir}: ${item.error}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
||||
const instructions: string[] = [];
|
||||
if (enabledTools.has("send_file")) {
|
||||
@@ -315,10 +380,14 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
||||
}
|
||||
if (enabledTools.has("convert_pdf_to_md")) {
|
||||
instructions.push(
|
||||
"Use convert_pdf_to_md when the user asks to convert a PDF to Markdown.",
|
||||
"If the PDF came from a Feishu message, first use feishu_download_resource to save it to the workspace, then call convert_pdf_to_md.",
|
||||
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md for accurate text, formula, and image extraction.",
|
||||
"Use convert_pdf_to_md when the user asks to convert a PDF (or several PDFs) to Markdown.",
|
||||
"If PDFs came from Feishu, download each with feishu_download_resource first, then convert.",
|
||||
"For multiple PDFs, call convert_pdf_to_md once with items=[{input_path,output_dir},...] so Hub converts them concurrently; give each file its own output_dir (the tool writes document.md inside it).",
|
||||
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md.",
|
||||
);
|
||||
}
|
||||
instructions.push(
|
||||
"Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.",
|
||||
);
|
||||
return instructions.join(" ");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user