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:
@@ -63,6 +63,135 @@ export interface PdfToMdBundleDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
}
|
||||
|
||||
/** Default max concurrent Docmind jobs for one convert_pdf_to_md batch call. */
|
||||
export const DEFAULT_PDF_TO_MD_CONCURRENCY = 3;
|
||||
/** Hard ceiling for agent-requested concurrency (also clamps env). */
|
||||
export const MAX_PDF_TO_MD_CONCURRENCY = 8;
|
||||
/** Max PDFs accepted in one batch tool call. */
|
||||
export const MAX_PDF_TO_MD_BATCH_ITEMS = 32;
|
||||
|
||||
export function clampPdfToMdConcurrency(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_PDF_TO_MD_CONCURRENCY;
|
||||
const n = Math.trunc(value);
|
||||
if (n < 1) return 1;
|
||||
if (n > MAX_PDF_TO_MD_CONCURRENCY) return MAX_PDF_TO_MD_CONCURRENCY;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Read HUB_PDF_TO_MD_MAX_CONCURRENT (default 3, max 8). */
|
||||
export function readPdfToMdConcurrency(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): number {
|
||||
const raw = env["HUB_PDF_TO_MD_MAX_CONCURRENT"]?.trim();
|
||||
if (raw === undefined || raw === "") return DEFAULT_PDF_TO_MD_CONCURRENCY;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
throw new Error(`HUB_PDF_TO_MD_MAX_CONCURRENT must be a positive integer, got ${raw}`);
|
||||
}
|
||||
return clampPdfToMdConcurrency(parsed);
|
||||
}
|
||||
|
||||
export interface PdfToMdBatchItem {
|
||||
readonly inputPath: string;
|
||||
readonly outputDir: string;
|
||||
}
|
||||
|
||||
export type PdfToMdBatchItemResult =
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly inputPath: string;
|
||||
readonly outputDir: string;
|
||||
readonly result: CapabilityInvocationResult;
|
||||
}
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly inputPath: string;
|
||||
readonly outputDir: string;
|
||||
readonly error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run worker over items with bounded parallelism. Order of results matches
|
||||
* input order. Rejects in worker are not swallowed — caller should catch.
|
||||
*/
|
||||
export async function mapPool<T, R>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
if (items.length === 0) return [];
|
||||
const limit = Math.max(1, Math.min(Math.trunc(concurrency), items.length));
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
async function runWorker(): Promise<void> {
|
||||
for (;;) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await worker(items[index]!, index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: limit }, () => runWorker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert multiple PDFs with bounded concurrency. Each item is attribute-
|
||||
* independent (own paths + own UsageFact). Failures are per-item and do not
|
||||
* cancel siblings; order matches `items`.
|
||||
*/
|
||||
export async function invokePdfToMdBatch(
|
||||
adapter: CapabilityAdapter,
|
||||
base: Omit<CapabilityInvocationInput, "inputPath" | "outputDir">,
|
||||
items: readonly PdfToMdBatchItem[],
|
||||
concurrency: number = DEFAULT_PDF_TO_MD_CONCURRENCY,
|
||||
): Promise<PdfToMdBatchItemResult[]> {
|
||||
if (items.length === 0) {
|
||||
throw new Error("pdf_to_md batch requires at least one item");
|
||||
}
|
||||
if (items.length > MAX_PDF_TO_MD_BATCH_ITEMS) {
|
||||
throw new Error(
|
||||
`pdf_to_md batch supports at most ${MAX_PDF_TO_MD_BATCH_ITEMS} items per call (got ${items.length})`,
|
||||
);
|
||||
}
|
||||
const seenOutputDirs = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (item.inputPath.trim() === "" || item.outputDir.trim() === "") {
|
||||
throw new Error("pdf_to_md batch items require non-empty inputPath and outputDir");
|
||||
}
|
||||
const key = item.outputDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (seenOutputDirs.has(key)) {
|
||||
throw new Error(
|
||||
`pdf_to_md batch items must use distinct output_dir values; duplicate: ${item.outputDir}`,
|
||||
);
|
||||
}
|
||||
seenOutputDirs.add(key);
|
||||
}
|
||||
const limit = clampPdfToMdConcurrency(concurrency);
|
||||
return mapPool(items, limit, async (item) => {
|
||||
try {
|
||||
const result = await adapter.invoke({
|
||||
...base,
|
||||
inputPath: item.inputPath,
|
||||
outputDir: item.outputDir,
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
inputPath: item.inputPath,
|
||||
outputDir: item.outputDir,
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
inputPath: item.inputPath,
|
||||
outputDir: item.outputDir,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the pdf_to_md_bundle adapter. The client is injectable for testing. */
|
||||
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user