feat(retrieval): add GLMOCRLoader and GLMOCRPDFLoader with structured OCR output via OCRResult#1224
feat(retrieval): add GLMOCRLoader and GLMOCRPDFLoader with structured OCR output via OCRResult#1224contactvishwav wants to merge 10 commits into
Conversation
…RResult return type
|
Hi @Amir-R25 — this is my first contribution to railtracks. I've implemented the GLMOCRLoader requested in #1175, following the PyPDFOCRLoader pattern and your stated recommendation (option ii) to extend BaseOCRLoader with One honest note: GLM-OCR's API response schema isn't fully published yet, so _parse_glmocr_response() and the call* methods contain documented stubs with TODO comments. Happy to update those once the schema is confirmed, or if you have access to the live SDK response format. Would appreciate a review when you have time! |
soulFood5632
left a comment
There was a problem hiding this comment.
Hello thanks for the PR. I appreciate you taking the time to go through this issue.
However, there are a couple of tweaks that will be required. I will be taking a deeper dive on the PR and ensuring functionality once you have addressed the concerns I laid out.
Please let me know if you have any questions or concerns with any of the above comments.
|
|
||
|
|
||
| def _parse_glmocr_response(raw: dict) -> OCRResult: | ||
| # TODO: Confirm actual GLM-OCR JSON response schema once SDK docs are |
There was a problem hiding this comment.
If you can please remove this loose TODO. I would rather just create an issue if and when the schema changes. We should also for version lock the glmocr library to prevent issues.
| """ | ||
| ... | ||
|
|
||
| async def _ocr_image_structured(self, image: Image) -> OCRResult: |
There was a problem hiding this comment.
| is an engine-specific dict representing one table. | ||
| """ | ||
|
|
||
| markdown: str |
| Encodes the image as PNG bytes in the calling thread, then offloads | ||
| the blocking network call to a worker thread. | ||
|
|
||
| # TODO: Replace with confirmed SDK signature once glmocr API docs are |
There was a problem hiding this comment.
same here, lets remove the loose Todo and again create an issue as required.
| # TODO: Replace with confirmed SDK signature once glmocr API docs are | ||
| # finalised. Current stub: glmocr.ocr(image_bytes) -> dict | ||
| """ | ||
| import io |
There was a problem hiding this comment.
If you can can you please get rid of this local import and use the top level as per PEP guidelines
|
|
||
| async def _stream_image(self, path: Path) -> AsyncGenerator[Document, None]: | ||
| """Yield a single Document from one image file.""" | ||
| from PIL import Image as PILImage |
There was a problem hiding this comment.
same here, please avoid local imports
| # TODO: Confirm local endpoint schema for PDF input. Current stub | ||
| # mirrors the image path with "pdf" as the key and format flag. | ||
| """ | ||
| import base64 |
There was a problem hiding this comment.
please avoid local imports here
| async def _call_local_pdf(self, pdf_bytes: bytes) -> OCRResult: | ||
| """POST PDF bytes (base64-encoded) to the local endpoint. | ||
|
|
||
| # TODO: Confirm local endpoint schema for PDF input. Current stub |
| async def _call_cloud_pdf(self, pdf_bytes: bytes) -> OCRResult: | ||
| """Send PDF bytes to the Zhipu cloud API. | ||
|
|
||
| # TODO: Confirm whether the SDK accepts raw bytes or requires base64, |
There was a problem hiding this comment.
please get rid of loose todo. either answer the question in your code or place an issue for it
|
|
||
| # Ignore Files copied during release workflow for PyPI packaging (for Flit) | ||
| packages/railtracks/docs/ No newline at end of file | ||
| packages/railtracks/docs/scripts/repro_issue_1175.py |
There was a problem hiding this comment.
This should not be included in the gitignore please revert
|
Hi @soulFood5632 — thanks for the thorough review! I've addressed all the requested changes:
On the |
What does this PR do?
Adds
GLMOCRLoaderandGLMOCRPDFLoaderto the retrieval loaders pipeline, enabling structured OCR extraction via Zhipu's GLM-OCR multimodal model.GLMOCRLoaderprocesses image files (.bmp,.jpeg,.jpg,.png,.tif,.tiff,.webp) andGLMOCRPDFLoadersends raw PDF bytes directly to the API — bypassing rasterization entirely — to preserve layout-aware output in a single round-trip. Both loaders support two deployment modes:cloud(delegates to theglmocrSDK, blocking call offloaded viaasyncio.to_thread) andlocal(POSTs to a self-hosted vLLM/Ollama endpoint usinghttpx.AsyncClient). A newOCRResultdataclass (models.py) carries the structured response —markdown: str,bboxes: list[dict],tables: list[dict]— and is exported fromrailtracks.retrieval.BaseOCRLoadergains an optional_ocr_image_structured()method that returnsOCRResult, leaving the existing_ocr_image() -> strabstract contract intact for all current subclasses. TheglmocrSDK andhttpxare declared as a new[glm]optional extra inpyproject.toml;pillowis co-listed sinceGLMOCRLoader._stream_image()uses PIL for image encoding.Why was this PR needed?
BaseOCRLoader._ocr_image()returns a flatstr, discarding everything the OCR engine knows about document structure. GLM-OCR's 0.9B multimodal model produces markdown-formatted text with heading hierarchy, bounding-box annotations per text region, and extracted table data — none of which survived thestrreturn type. Retaining that structure inOCRResultmeans downstream chunkers can make layout-aware decisions (split at headings, treat table cells as atomic units) rather than operating on a stripped plaintext blob. This is the core ask of issue #1175: surface structured OCR output as a first-class type in the retrieval pipeline.What are the relevant issue numbers?
Closes #1175
Design decisions made
1. Extended
BaseOCRLoaderwith_ocr_image_structured()rather than a parallel loader hierarchy.The maintainer's stated preference (option ii in #1175) was to add
_ocr_image_structured(image) -> OCRResultas a non-abstract method onBaseOCRLoaderwith a default implementation that delegates to_ocr_image()and wraps the result inOCRResult(markdown=text). This meansPyPDFOCRLoaderand any other existing subclass that only implements_ocr_image()continues to work without modification.GLMOCRLoaderinverts the delegation:_ocr_image_structured()is the real implementation (GLM-OCR natively returns structured output), and_ocr_image()satisfies the abstract contract by callingresult.to_text(). No breaking changes to the existingstrreturn contract.2. Followed
PyPDFOCRLoaderpatterns exactly.glm_ocr_loader.pymirrorspdf_ocr_loader.pyin every structural respect: a module-leveltry/except ImportErrorguard that re-raises with apip install "railtracks[glm]"hint;asyncio.to_threadfor all blocking I/O (image encoding, SDK calls,path.read_bytes); eager parameter validation in__init__(invalid mode, missing endpoint for local mode, invalidbreakdown_strategy) so errors surface at construction time rather than mid-stream; aBreakdownStrategy = Literal["page", "document"]alias at module level; and a_stream_dir()helper extracted fromastream()to keep McCabe complexity under the project's limit of 10.GLMOCRLoaderandGLMOCRPDFLoaderare not re-exported fromloaders/__init__.py, matchingPyPDFOCRLoader's precedent — users import directly fromrailtracks.retrieval.loaders.glm_ocr_loader.3.
[glm]is a standalone extra, not folded into[retrieval].glmocris a cloud SDK requiring API credentials, unlike the local tooling in[ocr](Tesseract, pypdfium2). Adding it to[retrieval]would force the SDK on all users who install the comprehensive retrieval stack regardless of whether they use GLM-OCR. It is available aspip install "railtracks[glm]"and can be promoted to[retrieval]later if it becomes a default.Known limitations / TODOs
GLM-OCR's JSON response schema is not yet fully published in the official SDK documentation. Five methods contain stub mappings with
# TODOcomments that must be updated once the live schema is confirmed:_parse_glmocr_response()— assumesraw["markdown"],raw.get("bboxes", []),raw.get("tables", []). Actual field names may differ._call_cloud()— assumesglmocr.ocr(image_bytes: bytes) -> dict. Positional argument and return type need verification._call_local()— assumes a JSON body of{"image": <base64-PNG>, "format": "markdown"}. Endpoint contract is a best-guess based on common vLLM patterns._call_cloud_pdf()— assumesglmocr.ocr(pdf_bytes, format="pdf"). It is not yet confirmed whether the SDK accepts raw bytes or requires base64 encoding for PDF input._call_local_pdf()— mirrors_call_local()with{"pdf": <base64>, "format": "pdf"}.The loader is complete end-to-end and all unit tests pass against a mock SDK. Real API call verification is blocked on SDK documentation and is the only outstanding item before this feature can be considered production-ready.
Does this PR meet the acceptance criteria?
test_glm_ocr_loader.pycovering init validation, error conditions, cloud-mode OCR paths, page/document breakdown strategies,OCRResultdataclass contract,GLMOCRPDFLoadererror paths, and theImportErrorinstall-hint guardruff checkandruff format --checkboth clean across all changed files_ocr_image() -> strabstract contract is preserved;PyPDFOCRLoaderis unchanged;OCRResultis additive# TODOcomments inglm_ocr_loader.py)Files changed
packages/railtracks/src/railtracks/retrieval/models.pyOCRResultdataclass withmarkdown,bboxes,tablesfields andto_text()packages/railtracks/src/railtracks/retrieval/__init__.pyOCRResultfrom.modelsand added to__all__packages/railtracks/src/railtracks/retrieval/loaders/base_ocr.py_ocr_image_structured(image) -> OCRResultwith default delegation to_ocr_image()packages/railtracks/src/railtracks/retrieval/loaders/glm_ocr_loader.pyGLMOCRLoaderandGLMOCRPDFLoaderimplementations (405 lines)packages/railtracks/pyproject.toml[glm]optional extra:glmocr >= 0.1.0,httpx >= 0.23.0,pillow >= 10.0.0packages/railtracks/tests/unit_tests/retrieval/loaders/test_glm_ocr_loader.py