"""Markovo lab 统一跑批:工具 × 语料 → //.md + runs.json。 用法: tools/lab-venv/bin/python tools/lab_run.py --lab /lab \ [--tools markitdown,pymupdf4llm,pdfplumber,pandoc,docling,marker,markovo] \ [--samples a,b,...] [--office-corpus []] 样本发现:/corpus/ 下所有受支持扩展名(pdf/docx/pptx/xlsx/html/epub)的文件; --samples 给不带扩展名的名字时解析到 .。--office-corpus 把约定语料目录 (默认 tools/lab-samples/corpus-office,与漏斗复制进 run lab/corpus/ 的同一来源) 里缺失的文件补进 /corpus/。 工具 × 格式:markitdown 全部六种;pymupdf4llm/pdfplumber/marker 只 pdf; pandoc {docx,html,epub};docling {pdf,docx,pptx,xlsx,html}; markovo 用 developer.md 的 capability_id(pdf + docx 等签名档格式)。 不支持的 工具×格式 直接跳过,不算错误。 pandoc/docling/marker 以子进程在各自环境跑(pandoc 走 PATH,docling/marker 走 tools/lab-*-venv),markovo 走 HTTP;四者的输出按样本 sha256 缓存在 tools/lab-samples/heavy-cache///,重跑命中即秒回。 单个 工具×样本 异常不中断,错误文本进 /.error.txt。 runs.json 记每个工具的 version / device / 总耗时 / error。 """ import argparse import hashlib import importlib import importlib.metadata import json import os import secrets import shutil import subprocess import sys import tempfile import time import urllib.request from datetime import date from pathlib import Path ROOT = Path(__file__).resolve().parents[1] OFFICE_CORPUS = ROOT / "tools" / "lab-samples" / "corpus-office" DOCLING_PY = ROOT / "tools" / "lab-docling-venv" / "bin" / "python" MARKER_PY = ROOT / "tools" / "lab-marker-venv" / "bin" / "python" SUPPORTED_EXTS = {".pdf", ".docx", ".pptx", ".xlsx", ".html", ".epub"} TOOL_FORMATS = { "markitdown": {"pdf", "docx", "pptx", "xlsx", "html", "epub"}, "pymupdf4llm": {"pdf"}, "pdfplumber": {"pdf"}, "pandoc": {"docx", "html", "epub"}, "docling": {"pdf", "docx", "pptx", "xlsx", "html"}, "marker": {"pdf"}, "markovo": {"pdf", "docx", "pptx", "xlsx", "html", "epub"}, } PANDOC_FMT = {"docx": "docx", "html": "html", "epub": "epub"} # developer.md 明示 docx-to-markdown(§MCP 契约);pptx/xlsx/html/epub 的 # capability_id 由文中指定的 GET /v1/capabilities 端点确认(均 status=available), # 命名与 pdf-to-markdown 同一约定。 MARKOVO_CAPS = { "pdf": "pdf-to-markdown", "docx": "docx-to-markdown", "pptx": "pptx-to-markdown", "xlsx": "xlsx-to-markdown", "html": "html-to-markdown", "epub": "epub-to-markdown", } MARKOVO_MIME = { "pdf": "application/pdf", "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "html": "text/html", "epub": "application/epub+zip", } # 子进程片段:argv[1]=输入文件,argv[2]=输出 md 路径(不依赖 stdout,日志不污染正文) DOCLING_SNIPPET = ( "import sys\n" "from docling.document_converter import DocumentConverter\n" "doc = DocumentConverter().convert(sys.argv[1]).document\n" "open(sys.argv[2], 'w', encoding='utf-8').write(doc.export_to_markdown())\n" ) MARKER_SNIPPET = ( "import sys\n" "from marker.converters.pdf import PdfConverter\n" "from marker.models import create_model_dict\n" "from marker.output import text_from_rendered\n" "rendered = PdfConverter(artifact_dict=create_model_dict())(sys.argv[1])\n" "text, _, _ = text_from_rendered(rendered)\n" "open(sys.argv[2], 'w', encoding='utf-8').write(text)\n" ) HEAVY_TIMEOUT_S = 1200 # docling/marker 首次加载模型较慢;超时的格子记 error 继续 IN_PROCESS = { "markitdown": { "pkg": "markitdown", "run": lambda path: __import__("markitdown").MarkItDown( enable_plugins=False).convert_local(str(path)).markdown, }, "pymupdf4llm": { "pkg": "pymupdf4llm", "run": lambda path: __import__("pymupdf4llm").to_markdown(str(path)), }, "pdfplumber": { "pkg": "pdfplumber", "run": lambda path: "\n\n".join( (p.extract_text() or "") for p in __import__("pdfplumber").open(str(path)).pages), }, } ALL_TOOLS = [*IN_PROCESS, "pandoc", "docling", "marker", "markovo"] def usable_tools(): out = [] for name, spec in IN_PROCESS.items(): try: importlib.import_module(spec["pkg"]) out.append(name) except ImportError: continue if shutil.which("pandoc"): out.append("pandoc") if DOCLING_PY.is_file(): out.append("docling") if MARKER_PY.is_file(): out.append("marker") if os.environ.get("MARKOVO_API_KEY"): out.append("markovo") return out # ---- 重工具磁盘缓存:heavy-cache///.md + .meta.json ---- def heavy_lookup(cache_root: Path | None, tool: str, sha: str): """任一版本目录命中即返回 (md_text, meta_dict)。""" if not cache_root: return None d = cache_root / tool if not d.is_dir(): return None for md_path in sorted(d.glob(f"*/{sha}.md")): meta_path = md_path.with_suffix(".meta.json") meta = {} if meta_path.is_file(): try: meta = json.loads(meta_path.read_text(encoding="utf-8")) except json.JSONDecodeError: meta = {} return md_path.read_text(encoding="utf-8"), meta return None def heavy_store(cache_root: Path | None, tool: str, version: str, sha: str, md: str, meta: dict): if not cache_root: return d = cache_root / tool / (version or "unknown") d.mkdir(parents=True, exist_ok=True) (d / f"{sha}.md").write_text(md, encoding="utf-8") payload = {"tool": tool, "version": version, "sha256": sha, **meta} (d / f"{sha}.meta.json").write_text( json.dumps(payload, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") # ---- Markovo 公开 API(HTTP,标准库 urllib;https_proxy 走环境变量)---- MARKOVO_POLL_S, MARKOVO_TIMEOUT_S = 3, 180 def _http_json(req): with urllib.request.urlopen(req, timeout=60) as resp: return resp.status, json.loads(resp.read().decode("utf-8")) def _multipart(fields, file_field, file_path: Path, ctype: str): boundary = "----labrun" + secrets.token_hex(12) parts = [] for k, v in fields.items(): parts.append( f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"' f'\r\n\r\n{v}\r\n'.encode()) parts.append( f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}";' f' filename="{file_path.name}"\r\nContent-Type: {ctype}\r\n\r\n' .encode() + file_path.read_bytes() + b"\r\n") parts.append(f"--{boundary}--\r\n".encode()) return boundary, b"".join(parts) def markovo_one(path: Path, base: str, key: str, cache_root: Path | None, legacy_cache: Path | None): """提交→轮询→下载。返回 (markdown, snapshot, from_cache, elapsed_s)。 缓存查找顺序:heavy-cache/markovo/<任意版本>/.md → 旧 markovo-cache/.md; 新结果只写 heavy-cache/markovo//。 """ sha = hashlib.sha256(path.read_bytes()).hexdigest() hit = heavy_lookup(cache_root, "markovo", sha) if hit: md, meta = hit return md, meta.get("snapshot", meta), True, 0.0 if legacy_cache: c_md, c_js = legacy_cache / f"{sha}.md", legacy_cache / f"{sha}.json" if c_md.is_file() and c_js.is_file(): return (c_md.read_text(encoding="utf-8"), json.loads(c_js.read_text(encoding="utf-8")), True, 0.0) ext = path.suffix.lstrip(".") t0 = time.perf_counter() boundary, body = _multipart( {"capability_id": MARKOVO_CAPS[ext], "mode": "fast", "max_credit_units": "5000"}, "file", path, MARKOVO_MIME[ext]) req = urllib.request.Request( f"{base}/v1/convert", data=body, method="POST", headers={"Authorization": f"Bearer {key}", "Content-Type": f"multipart/form-data; boundary={boundary}"}) status, convert = _http_json(req) if status != 200 or not convert.get("job_id"): raise RuntimeError(f"convert HTTP {status}: {str(convert)[:300]}") job_id = convert["job_id"] deadline = t0 + MARKOVO_TIMEOUT_S job = {} while time.perf_counter() < deadline: req = urllib.request.Request( f"{base}/v1/jobs/{job_id}", headers={"Authorization": f"Bearer {key}"}) _, payload = _http_json(req) job = payload.get("job", payload) if job.get("status") in ("succeeded", "failed"): break time.sleep(MARKOVO_POLL_S) if job.get("status") != "succeeded": raise RuntimeError( f"job {job_id} status={job.get('status')} " f"error={job.get('error') or job.get('error_code')}") req = urllib.request.Request( f"{base}/v1/jobs/{job_id}/download", headers={"Authorization": f"Bearer {key}"}) with urllib.request.urlopen(req, timeout=60) as resp: md = resp.read().decode("utf-8") ctype = resp.headers.get("Content-Type", "") elapsed = time.perf_counter() - t0 snap = {"job": job, "convert_response": convert, "download_content_type": ctype} if cache_root: rate_card = job.get("rate_card_version") or "unknown" heavy_store(cache_root, "markovo", rate_card, sha, md, {"elapsed_s": round(elapsed, 4), "capability_id": MARKOVO_CAPS[ext], "snapshot": snap}) return md, snap, False, elapsed def run_markovo(lab: Path, samples, key: str, cache_root: Path | None, legacy_cache: Path | None): out_dir = lab / "markovo" out_dir.mkdir(parents=True, exist_ok=True) base = os.environ.get("MARKOVO_BASE_URL", "https://markovo.net").rstrip("/") results = [] samples_meta = {} rate_card = None skipped = [] for path in samples: ext = path.suffix.lstrip(".") if ext not in MARKOVO_CAPS: skipped.append(path.stem) continue name = path.stem out_md = out_dir / f"{name}.md" out_js = out_dir / f"{name}.json" err_file = out_dir / f"{name}.error.txt" err = None snap, from_cache, elapsed = None, False, 0.0 try: md, snap, from_cache, elapsed = markovo_one( path, base, key, cache_root, legacy_cache) except Exception as e: # noqa: BLE001 err = f"{type(e).__name__}: {e}" err_file.write_text(err + "\n", encoding="utf-8") samples_meta[name] = {"status": "error", "error": err, "from_cache": False, "elapsed_s": round(elapsed, 4)} else: if err_file.exists(): err_file.unlink() out_md.write_text(md, encoding="utf-8") out_js.write_text( json.dumps(snap, ensure_ascii=False, indent=1), encoding="utf-8") job = snap["job"] rate_card = rate_card or job.get("rate_card_version") samples_meta[name] = { "job_id": job.get("id") or job.get("job_id"), "status": job.get("status"), "actual_credits": job.get("actual_credits") or (job.get("billing_metrics") or {}).get("actual_credits"), "pages_total": job.get("pages_total"), "pages_ocr": job.get("pages_ocr"), "elapsed_s": round(elapsed, 4), "from_cache": from_cache, } results.append({ "tool": "markovo", "sample": name, "output": str(out_md.relative_to(lab)) if err is None else None, "elapsed_s": round(elapsed, 4), "error": err, }) print(f"markovo x {name}: {'cache' if from_cache else ('ERR ' + err if err else 'ok')} " f"({elapsed:.2f}s)") if skipped: print(f"markovo: 跳过不支持的格式 {skipped}") total_credits = sum(m.get("actual_credits") or 0 for m in samples_meta.values()) (out_dir / "run.json").write_text(json.dumps({ "date": date.today().isoformat(), "base_url": base, "endpoint": "/v1/convert", "rate_card_version": rate_card, "credit_model_version": next( (m["job"].get("credit_model_version") for m in [json.loads(p.read_text()) for p in out_dir.glob("*.json") if p.name != "run.json"] if m.get("job", {}).get("credit_model_version")), None), "samples": samples_meta, "total_actual_credits": total_credits, }, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") meta = {"version": rate_card or "unknown", "device": None, "elapsed_s": round(sum(r["elapsed_s"] for r in results), 4), "error": None} if skipped: meta["skipped"] = skipped return results, meta # ---- 本地重工具(子进程,沙箱外):pandoc / docling / marker ---- def _probe(py: Path | str, pkg: str): """返回 {version, device};探针失败抛异常。""" code = ( "import importlib.metadata as m, json\n" "dev = 'cpu'\n" "try:\n" " import torch\n" " if torch.backends.mps.is_available():\n" " dev = 'mps'\n" "except Exception:\n" " pass\n" f"print(json.dumps({{'version': m.version('{pkg}'), 'device': dev}}))\n" ) r = subprocess.run([str(py), "-c", code], capture_output=True, text=True, timeout=120) if r.returncode != 0: raise RuntimeError(f"probe rc={r.returncode}: {r.stderr[-300:]}") return json.loads(r.stdout.strip().splitlines()[-1]) def _pandoc_version(): r = subprocess.run(["pandoc", "--version"], capture_output=True, text=True, timeout=30) if r.returncode != 0: raise RuntimeError(f"pandoc --version rc={r.returncode}") first = r.stdout.splitlines()[0] if r.stdout else "" return first.split()[1] if len(first.split()) > 1 else "unknown" def heavy_cmd(tool: str, path: Path, out_path: Path): """返回子进程 argv;pandoc 走 stdout,docling/marker 写文件。""" ext = path.suffix.lstrip(".") if tool == "pandoc": return ["pandoc", "-f", PANDOC_FMT[ext], "-t", "gfm", "--wrap=none", str(path)] if tool == "docling": return [str(DOCLING_PY), "-c", DOCLING_SNIPPET, str(path), str(out_path)] if tool == "marker": return [str(MARKER_PY), "-c", MARKER_SNIPPET, str(path), str(out_path)] raise ValueError(tool) def run_heavy(lab: Path, tool: str, samples, cache_root: Path | None, timeout: int): """pandoc/docling/marker 统一子进程跑批。返回 (results, tool_meta)。""" try: if tool == "pandoc": version, device = _pandoc_version(), "cpu" else: py = DOCLING_PY if tool == "docling" else MARKER_PY pkg = {"docling": "docling", "marker": "marker-pdf"}[tool] if not py.is_file(): raise RuntimeError(f"venv 不存在:{py}") info = _probe(py, pkg) version, device = info["version"], info["device"] except Exception as e: # noqa: BLE001 工具级失败:记录并整工具跳过 err = f"{type(e).__name__}: {e}" print(f"{tool}: 跳过({err})", file=sys.stderr) return [], {"version": None, "device": None, "elapsed_s": 0.0, "error": err} out_dir = lab / tool out_dir.mkdir(parents=True, exist_ok=True) results, skipped = [], [] for path in samples: ext = path.suffix.lstrip(".") name = path.stem if ext not in TOOL_FORMATS[tool]: skipped.append(name) continue out_md = out_dir / f"{name}.md" err_file = out_dir / f"{name}.error.txt" sha = hashlib.sha256(path.read_bytes()).hexdigest() hit = heavy_lookup(cache_root, tool, sha) if hit: md, _meta = hit if err_file.exists(): err_file.unlink() out_md.write_text(md, encoding="utf-8") results.append({"tool": tool, "sample": name, "output": str(out_md.relative_to(lab)), "elapsed_s": 0.0, "error": None}) print(f"{tool} x {name}: cache (0.00s)") continue t0 = time.perf_counter() err = None partial = None try: fd, tmp = tempfile.mkstemp(dir=out_dir, suffix=".partial.md") os.close(fd) partial = Path(tmp) argv = heavy_cmd(tool, path, partial) r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) if r.returncode != 0: raise RuntimeError( f"rc={r.returncode} stderr={r.stderr[-400:]}") if tool == "pandoc": md = r.stdout else: md = partial.read_text(encoding="utf-8") except subprocess.TimeoutExpired: err = f"TimeoutExpired: >{timeout}s" except Exception as e: # noqa: BLE001 err = f"{type(e).__name__}: {e}" finally: if partial is not None and partial.exists(): partial.unlink() elapsed = time.perf_counter() - t0 if err: err_file.write_text(err + "\n", encoding="utf-8") else: if err_file.exists(): err_file.unlink() out_md.write_text(md, encoding="utf-8") heavy_store(cache_root, tool, version, sha, md, {"elapsed_s": round(elapsed, 4), "device": device, "sample": name}) results.append({"tool": tool, "sample": name, "output": str(out_md.relative_to(lab)) if err is None else None, "elapsed_s": round(elapsed, 4), "error": err}) print(f"{tool} x {name}: {'ERR ' + err if err else 'ok'} " f"({elapsed:.2f}s)") meta = {"version": version, "device": device, "elapsed_s": round(sum(r["elapsed_s"] for r in results), 4), "error": None} if skipped: meta["skipped"] = skipped print(f"{tool}: 跳过不支持的格式 {skipped}") return results, meta def tool_version(name): try: return importlib.metadata.version(name) except importlib.metadata.PackageNotFoundError: return "unknown" def resolve_samples(corpus: Path, names: str | None): """默认取 corpus 下全部受支持扩展名文件;名字不带扩展名时补全。""" if names is None: files = [f for f in corpus.iterdir() if f.is_file() and f.suffix.lower() in SUPPORTED_EXTS] \ if corpus.is_dir() else [] else: files = [] for n in names.split(","): n = n.strip() if not n: continue if Path(n).suffix.lower() in SUPPORTED_EXTS: f = corpus / n if not f.is_file(): sys.exit(f"lab_run: 无此样本 {f}") files.append(f) else: found = sorted(f for f in corpus.glob(f"{n}.*") if f.suffix.lower() in SUPPORTED_EXTS) if not found: sys.exit(f"lab_run: {corpus} 下没有 {n}.<受支持扩展名>") files.extend(found) stems = {} for f in files: stems.setdefault(f.stem, []).append(f.name) dup = {k: v for k, v in stems.items() if len(v) > 1} if dup: sys.exit(f"lab_run: 样本 stem 冲突(同名的多扩展名无法区分输出): {dup}") return sorted(files, key=lambda f: f.stem) def run_in_process(lab: Path, tool: str, samples): """lab-venv 内 import 的轻量工具跑批。""" spec = IN_PROCESS[tool] results, skipped = [], [] out_dir = lab / tool out_dir.mkdir(parents=True, exist_ok=True) for path in samples: ext = path.suffix.lstrip(".") name = path.stem if ext not in TOOL_FORMATS[tool]: skipped.append(name) continue out_md = out_dir / f"{name}.md" err_file = out_dir / f"{name}.error.txt" t0 = time.perf_counter() err = None try: text = spec["run"](path) except Exception as e: # noqa: BLE001 单个格子失败不中断整轮 err = f"{type(e).__name__}: {e}" err_file.write_text(err + "\n", encoding="utf-8") else: if err_file.exists(): err_file.unlink() out_md.write_text(text or "", encoding="utf-8") elapsed = time.perf_counter() - t0 results.append({ "tool": tool, "sample": name, "output": str(out_md.relative_to(lab)) if err is None else None, "elapsed_s": round(elapsed, 4), "error": err, }) print(f"{tool} x {name}: {'ERR ' + err if err else 'ok'} " f"({elapsed:.2f}s)") meta = {"version": tool_version(tool), "device": "cpu", "elapsed_s": round(sum(r["elapsed_s"] for r in results), 4), "error": None} if skipped: meta["skipped"] = skipped print(f"{tool}: 跳过不支持的格式 {skipped}") return results, meta def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("--lab", required=True) ap.add_argument("--tools", default=None, help="逗号分隔,默认环境里可用的全部") ap.add_argument("--samples", default=None, help="逗号分隔样本名(可不带扩展名),默认 corpus 全部") ap.add_argument("--office-corpus", nargs="?", const=str(OFFICE_CORPUS), default=None, metavar="DIR", help="先把语料目录里缺失的文件补进 /corpus/" "(默认 tools/lab-samples/corpus-office)") ap.add_argument("--markovo-cache", default="tools/lab-samples/markovo-cache", help="旧版 Markovo 结果缓存目录(只读兼容查找);空字符串禁用") ap.add_argument("--heavy-cache", default="tools/lab-samples/heavy-cache", help="pandoc/docling/marker/markovo 磁盘缓存根目录;" "空字符串禁用") ap.add_argument("--timeout", type=int, default=HEAVY_TIMEOUT_S, help="重工具单样本子进程超时秒数") args = ap.parse_args(argv) lab = Path(args.lab) corpus = lab / "corpus" if args.office_corpus: src = Path(args.office_corpus) if src.is_dir(): corpus.mkdir(parents=True, exist_ok=True) for f in sorted(src.iterdir()): dst = corpus / f.name if f.is_file() and not dst.exists(): shutil.copy2(f, dst) tools = args.tools.split(",") if args.tools else usable_tools() samples = resolve_samples(corpus, args.samples) if not samples: sys.exit(f"lab_run: {corpus} 下没有受支持格式的语料 " f"({sorted(SUPPORTED_EXTS)})") results = [] tool_versions = {} heavy_root = Path(args.heavy_cache) if args.heavy_cache else None legacy_cache = Path(args.markovo_cache) if args.markovo_cache else None for tool in tools: if tool not in ALL_TOOLS: print(f"跳过未知工具 {tool}", file=sys.stderr) continue if tool == "markovo": key = os.environ.get("MARKOVO_API_KEY") if not key: print("跳过 markovo:未设 MARKOVO_API_KEY", file=sys.stderr) continue mk_results, meta = run_markovo( lab, samples, key, heavy_root, legacy_cache) elif tool in IN_PROCESS: spec = IN_PROCESS[tool] try: importlib.import_module(spec["pkg"]) except ImportError: print(f"跳过未安装工具 {tool}", file=sys.stderr) continue mk_results, meta = run_in_process(lab, tool, samples) else: mk_results, meta = run_heavy(lab, tool, samples, heavy_root, args.timeout) results += mk_results tool_versions[tool] = meta runs_path = lab / "runs.json" if runs_path.is_file(): old = json.loads(runs_path.read_text(encoding="utf-8")) old["tools"].update(tool_versions) tool_versions = old["tools"] results = [r for r in old.get("results", []) if r["tool"] not in tools] + results runs = { "python": ".".join(map(str, sys.version_info[:3])), "date": date.today().isoformat(), "tools": tool_versions, "results": results, } (lab / "runs.json").write_text( json.dumps(runs, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") return 0 if __name__ == "__main__": sys.exit(main())