11import hashlib
22import json
33import shutil
4+ import tempfile
45import zipfile
56from dataclasses import dataclass
67from pathlib import Path
@@ -45,6 +46,8 @@ def _split_name(stable_key: str) -> str:
4546
4647 @staticmethod
4748 def _json_list (raw : str | None ) -> list [str ]:
49+ if isinstance (raw , list ):
50+ return [str (item ) for item in raw ]
4851 if not raw :
4952 return []
5053 try :
@@ -68,15 +71,19 @@ def export_run(
6871 documents : list [dict ],
6972 pages_by_document : dict [str , list [dict ]],
7073 catalog_by_document : dict [str , dict ],
74+ document_ids : set [str ] | None = None ,
7175 dpi : int = 160 ,
7276 text_mode : str = "clean" ,
7377 ) -> OCRPairExportResult :
7478 if text_mode not in {"clean" , "raw" }:
7579 raise ValueError ("text_mode must be clean or raw" )
7680
77- if self .export_dir .exists ():
78- shutil .rmtree (self .export_dir )
79- images_dir = self .export_dir / "images"
81+ tmp_parent = self .export_dir .parent
82+ tmp_parent .mkdir (parents = True , exist_ok = True )
83+ tmp_path = Path (
84+ tempfile .mkdtemp (prefix = f"{ self .export_dir .name } ." , dir = tmp_parent )
85+ )
86+ images_dir = tmp_path / "images"
8087 images_dir .mkdir (parents = True , exist_ok = True )
8188
8289 split_rows : dict [str , list [dict ]] = {"train" : [], "validation" : [], "test" : []}
@@ -86,6 +93,8 @@ def export_run(
8693 if doc .get ("status" ) != "completed" :
8794 continue
8895 document_id = doc ["document_id" ]
96+ if document_ids is not None and document_id not in document_ids :
97+ continue
8998 catalog = catalog_by_document .get (document_id , {})
9099 pdf_path_str = doc .get ("artifact_source_pdf" ) or doc .get (
91100 "document_source_path"
@@ -121,16 +130,24 @@ def export_run(
121130 raw_text = raw_pages [page_num - 1 ]
122131 clean_text = clean_pages [page_num - 1 ]
123132 text = clean_text if text_mode == "clean" else raw_text
124- split = self ._split_name (page_id )
133+ split_key = doc .get ("file_sha256" ) or document_id
134+ split = self ._split_name (split_key )
135+ image_path = images_dir / f"{ page_id } .png"
136+ image_hash = hashlib .sha256 (image_path .read_bytes ()).hexdigest ()
125137
126138 split_rows [split ].append (
127139 {
128140 "id" : page_id ,
141+ "run_id" : run ["id" ],
129142 "image" : image_rel ,
130143 "text" : text ,
131144 "raw_text" : raw_text ,
132145 "clean_text" : clean_text ,
133146 "text_mode" : text_mode ,
147+ "label_source" : "cleaned_machine_ocr"
148+ if text_mode == "clean"
149+ else "machine_ocr" ,
150+ "review_status" : "unreviewed" ,
134151 "document_id" : document_id ,
135152 "document_name" : doc .get ("document_filename" ),
136153 "page" : page_num ,
@@ -155,6 +172,10 @@ def export_run(
155172 "extraction_mode" : page_meta .get ("extraction_mode" ),
156173 "extraction_attempt" : page_meta .get ("extraction_attempt" ),
157174 "dpi_used" : page_meta .get ("dpi_used" ),
175+ "render_dpi" : dpi ,
176+ "image_width" : image .width ,
177+ "image_height" : image .height ,
178+ "image_sha256" : image_hash ,
158179 "source_file" : doc .get ("document_filename" ),
159180 "source_pdf_sha256" : doc .get ("file_sha256" ),
160181 "ocr_model" : run .get ("model_used" ),
@@ -163,8 +184,11 @@ def export_run(
163184 )
164185 pages_count += 1
165186
166- self ._write_jsonl (split_rows )
167- self ._write_manifest (run , pages_count , dpi , text_mode )
187+ self ._write_jsonl (tmp_path , split_rows )
188+ self ._write_manifest (tmp_path , run , pages_count , dpi , text_mode )
189+ if self .export_dir .exists ():
190+ shutil .rmtree (self .export_dir )
191+ tmp_path .replace (self .export_dir )
168192 bundle = self .export_dir .with_suffix (".zip" )
169193 if bundle .exists ():
170194 bundle .unlink ()
@@ -181,16 +205,16 @@ def _read_text(path_str: str | None) -> str:
181205 path = Path (path_str )
182206 return path .read_text (encoding = "utf-8" ) if path .exists () else ""
183207
184- def _write_jsonl (self , split_rows : dict [str , list [dict ]]) -> None :
208+ def _write_jsonl (self , export_dir : Path , split_rows : dict [str , list [dict ]]) -> None :
185209 for split , rows in split_rows .items ():
186- path = self . export_dir / f"{ split } .jsonl"
210+ path = export_dir / f"{ split } .jsonl"
187211 path .write_text (
188212 "" .join (json .dumps (row , ensure_ascii = False ) + "\n " for row in rows ),
189213 encoding = "utf-8" ,
190214 )
191215
192216 def _write_manifest (
193- self , run : dict , pages_count : int , dpi : int , text_mode : str
217+ self , export_dir : Path , run : dict , pages_count : int , dpi : int , text_mode : str
194218 ) -> None :
195219 payload = {
196220 "export_type" : "ocr_pairs" ,
@@ -200,13 +224,23 @@ def _write_manifest(
200224 "image_format" : "png" ,
201225 "dpi" : dpi ,
202226 "text_mode" : text_mode ,
227+ "dataset_purpose" : "ocr_audit" ,
228+ "label_source" : "cleaned_machine_ocr"
229+ if text_mode == "clean"
230+ else "machine_ocr" ,
231+ "review_status" : "unreviewed" ,
203232 "schema_version" : 1 ,
233+ "split_strategy" : {
234+ "method" : "sha256_bucket" ,
235+ "key" : "source_pdf_sha256" ,
236+ "ratios" : {"train" : 0.90 , "validation" : 0.05 , "test" : 0.05 },
237+ },
204238 "ocr_model" : run .get ("model_used" ) or settings .model_name ,
205239 "pipeline_version" : run .get ("pipeline_version" )
206240 or settings .pipeline_version ,
207241 "splits" : ["train" , "validation" , "test" ],
208242 }
209- (self . export_dir / "manifest.json" ).write_text (
243+ (export_dir / "manifest.json" ).write_text (
210244 json .dumps (payload , indent = 2 , ensure_ascii = False ),
211245 encoding = "utf-8" ,
212246 )
0 commit comments