Svngoku commited on
Commit
56dfa6d
·
verified ·
1 Parent(s): 9815544

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1382 -386
app.py CHANGED
@@ -1,111 +1,108 @@
 
 
 
 
1
  import gradio as gr
2
- from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
3
- from langchain.schema import Document
4
- from typing import List, Dict, Any, Tuple
5
  import logging
6
  import re
7
  import base64
 
8
  import mimetypes
9
- from datasets import Dataset
 
 
 
10
  from huggingface_hub import HfApi, get_token
11
  import huggingface_hub
12
  import os
13
  from mistralai import Mistral
14
- import gradio_client.utils as client_utils
 
 
 
15
 
16
  # Configure logging
17
- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
 
 
18
  logger = logging.getLogger(__name__)
19
 
20
- # --- Patch Gradio's get_type function to handle boolean schemas ---
21
- def patched_get_type(schema: Any) -> str:
22
- """Patched version of get_type to handle boolean schemas."""
23
- if isinstance(schema, bool):
24
- return "bool"
25
- if "const" in schema:
26
- return f"Literal[{repr(schema['const'])}]"
27
- if "enum" in schema:
28
- return f"Literal[{', '.join(repr(v) for v in schema['enum'])}]"
29
- if "type" not in schema:
30
- return "Any"
31
- type_ = schema["type"]
32
- if isinstance(type_, list):
33
- return f"Union[{', '.join(t for t in type_ if t != 'null')}]"
34
- if type_ == "array":
35
- items = schema.get("items", {})
36
- return f"List[{patched_json_schema_to_python_type(items, schema.get('$defs'))}]"
37
- if type_ == "object":
38
- return "Dict[str, Any]"
39
- if type_ == "null":
40
- return "None"
41
- if type_ == "integer":
42
- return "int"
43
- if type_ == "number":
44
- return "float"
45
- if type_ == "boolean":
46
- return "bool"
47
- return type_
48
-
49
- def patched_json_schema_to_python_type(schema: Any, defs: Dict[str, Any] = None) -> str:
50
- """Patched version of json_schema_to_python_type to use patched_get_type."""
51
- defs = defs or {}
52
- if not schema:
53
- return "Any"
54
- if "$ref" in schema:
55
- ref = schema["$ref"].split("/")[-1]
56
- return patched_json_schema_to_python_type(defs.get(ref, {}), defs)
57
- if "anyOf" in schema:
58
- types = [
59
- patched_json_schema_to_python_type(s, defs) for s in schema["anyOf"]
60
- ]
61
- return f"Union[{', '.join(t for t in types if t != 'None')}]"
62
- if "type" in schema and schema["type"] == "array":
63
- items = schema.get("items", {})
64
- elements = patched_json_schema_to_python_type(items, defs)
65
- return f"List[{elements}]"
66
- if "type" in schema and schema["type"] == "object":
67
- if "properties" in schema:
68
- des = [
69
- f"{n}: {patched_json_schema_to_python_type(v, defs)}{client_utils.get_desc(v)}"
70
- for n, v in schema["properties"].items()
71
- ]
72
- return f"Dict[str, Union[{', '.join(des)}]]"
73
- if "additionalProperties" in schema:
74
- return f"Dict[str, {patched_json_schema_to_python_type(schema['additionalProperties'], defs)}]"
75
- return "Dict[str, Any]"
76
- return patched_get_type(schema)
77
-
78
- # Override Gradio's json_schema_to_python_type
79
- client_utils.json_schema_to_python_type = patched_json_schema_to_python_type
80
-
81
- # --- Mistral OCR Setup ---
82
- api_key = os.environ.get("MISTRAL_API_KEY")
83
- hf_token_global = None
84
- client = None
85
-
86
- if not api_key:
87
- logger.warning("MISTRAL_API_KEY not set. Attempting to use Hugging Face token.")
88
- api_key = get_token()
89
- if api_key:
90
- logger.info("Using Hugging Face token as MISTRAL_API_KEY.")
91
- else:
92
- logger.warning("No API key found.")
93
 
94
- if api_key:
95
- try:
96
- client = Mistral(api_key=api_key)
97
- logger.info("Mistral client initialized successfully.")
98
- except Exception as e:
99
- logger.error(f"Failed to initialize Mistral client: {e}", exc_info=True)
100
- raise RuntimeError(f"Failed to initialize Mistral client: {e}")
101
- else:
102
- logger.error("Mistral API key not available. OCR will fail.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
  # --- Helper Functions ---
105
 
 
106
  def encode_image_bytes(image_bytes: bytes) -> str:
107
  """Encodes image bytes to a base64 string."""
108
- return base64.b64encode(image_bytes).decode('utf-8')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
  def extract_images_from_markdown(markdown_text: str) -> Dict[str, str]:
111
  """
@@ -113,12 +110,15 @@ def extract_images_from_markdown(markdown_text: str) -> Dict[str, str]:
113
  Returns a dictionary mapping reference IDs to base64 data URIs.
114
  """
115
  image_map = {}
116
- img_refs = re.findall(r"!\[.*?\]\((data:image/[a-zA-Z+]+;base64,[A-Za-z0-9+/=]+)\)", markdown_text)
 
 
117
  for idx, img_uri in enumerate(img_refs):
118
- ref_id = f"img_ref_{idx+1}"
119
  image_map[ref_id] = img_uri
120
  return image_map
121
 
 
122
  def replace_image_references(markdown_text: str, image_map: Dict[str, str]) -> str:
123
  """
124
  Replaces base64 image data URIs in markdown with reference IDs (e.g., img_ref_1).
@@ -130,145 +130,203 @@ def replace_image_references(markdown_text: str, image_map: Dict[str, str]) -> s
130
  updated_markdown = re.sub(pattern, f"\\1{ref_id}\\2", updated_markdown)
131
  return updated_markdown
132
 
133
- def get_combined_markdown(ocr_response: Any) -> Tuple[str, str, Dict[str, str]]:
 
134
  """Combines markdown from OCR pages, replacing image IDs with base64 data URIs."""
135
  processed_markdowns = []
136
  raw_markdowns = []
137
  image_data_map = {}
138
 
139
- if not hasattr(ocr_response, 'pages') or not ocr_response.pages:
140
- logger.warning("OCR response has no 'pages' attribute or pages list is empty.")
141
  return "", "", {}
142
 
143
- try:
144
- for page_idx, page in enumerate(ocr_response.pages):
145
- if hasattr(page, 'images') and page.images:
146
- logger.info(f"Page {page_idx}: Found {len(page.images)} images.")
147
- for img in page.images:
148
- if hasattr(img, 'id') and hasattr(img, 'image_base64') and img.image_base64:
149
- image_data_map[img.id] = img.image_base64
150
- logger.debug(f"Page {page_idx}: Image ID {img.id} added to image_data_map.")
151
- else:
152
- logger.warning(f"Page {page_idx}: Image object lacks 'id' or valid 'image_base64'. Image: {img}")
153
- else:
154
- logger.info(f"Page {page_idx}: No images found.")
 
 
 
 
155
 
156
- if not hasattr(page, 'markdown'):
157
- logger.warning(f"Page {page_idx} lacks 'markdown' attribute. Skipping.")
158
- continue
159
 
160
- current_raw_markdown = page.markdown if page.markdown else ""
161
- raw_markdowns.append(current_raw_markdown)
162
- current_processed_markdown = current_raw_markdown
163
-
164
- img_refs = re.findall(r"!\[.*?\]\((.*?)\)", current_processed_markdown)
165
- logger.debug(f"Page {page_idx}: Found {len(img_refs)} image references in markdown.")
166
- for img_id in img_refs:
167
- if img_id in image_data_map:
168
- base64_data_uri = image_data_map[img_id]
169
- escaped_img_id = re.escape(img_id)
170
- pattern = r"(!\[.*?\]\()" + escaped_img_id + r"(\))"
171
- if re.search(pattern, current_processed_markdown):
172
- current_processed_markdown = re.sub(
173
- pattern,
174
- r"\1" + base64_data_uri + r"\2",
175
- current_processed_markdown
176
- )
177
- logger.debug(f"Page {page_idx}: Replaced image ID {img_id} with base64 data URI.")
178
- elif not img_id.startswith(('http:', 'https:', 'data:')):
179
- logger.warning(f"Page {page_idx}: Image ID '{img_id}' not in image data.")
180
 
181
- processed_markdowns.append(current_processed_markdown)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
- logger.info(f"Processed {len(processed_markdowns)} pages with {len(image_data_map)} images.")
184
- return "\n\n".join(processed_markdowns), "\n\n".join(raw_markdowns), image_data_map
185
 
186
- except Exception as e:
187
- logger.error(f"Error processing OCR response markdown: {e}", exc_info=True)
188
- raise
 
189
 
190
- def perform_ocr_file(file_obj: Any) -> Tuple[str, str, Dict[str, str]]:
191
- """Performs OCR on an uploaded file using Mistral API."""
192
- if not client:
193
- return "Error: Mistral client not initialized.", "", {}
194
- if not file_obj:
195
- return "Error: No file provided.", "", {}
196
 
197
- try:
198
- file_path = file_obj.name
199
- file_name = getattr(file_obj, 'orig_name', os.path.basename(file_path))
200
- logger.info(f"Performing OCR on file: {file_name}")
201
- file_ext = os.path.splitext(file_name)[1].lower()
202
 
203
- ocr_response = None
204
- uploaded_file_id = None
205
 
206
- if file_ext == '.pdf':
207
- try:
208
- with open(file_path, "rb") as f:
209
- file_content = f.read()
210
-
211
- logger.info(f"Uploading PDF {file_name} to Mistral...")
212
- uploaded_pdf = client.files.upload(
213
- file={
214
- "file_name": file_name,
215
- "content": file_content,
216
- },
217
- purpose="ocr"
218
- )
219
- uploaded_file_id = uploaded_pdf.id
220
- logger.info(f"PDF uploaded successfully. File ID: {uploaded_file_id}")
221
-
222
- signed_url_response = client.files.get_signed_url(file_id=uploaded_file_id)
223
- ocr_response = client.ocr.process(
224
- model="mistral-ocr-latest",
225
- document={"type": "document_url", "document_url": signed_url_response.url},
226
- include_image_base64=True
227
- )
228
- logger.info(f"OCR response received: {ocr_response}")
229
- finally:
230
- if uploaded_file_id:
231
- try:
232
- client.files.delete(file_id=uploaded_file_id)
233
- except Exception as delete_err:
234
- logger.warning(f"Failed to delete temporary file {uploaded_file_id}: {delete_err}")
235
-
236
- elif file_ext in ['.png', '.jpg', '.jpeg', '.webp', '.bmp']:
237
  with open(file_path, "rb") as f:
238
- image_bytes = f.read()
239
- if not image_bytes:
240
- return f"Error: Uploaded image file '{file_name}' is empty.", "", {}
241
- base64_encoded_image = encode_image_bytes(image_bytes)
242
- mime_type, _ = mimetypes.guess_type(file_path)
243
- mime_type = mime_type or 'image/jpeg'
244
- data_uri = f"data:{mime_type};base64,{base64_encoded_image}"
245
- ocr_response = client.ocr.process(
246
- model="mistral-ocr-latest",
247
- document={"type": "image_url", "image_url": data_uri},
248
- include_image_base64=True
249
- )
250
- logger.info(f"OCR response received: {ocr_response}")
251
 
252
- else:
253
- return f"Unsupported file type: '{file_name}'.", "", {}
 
 
 
 
 
254
 
255
- if ocr_response:
256
- processed_md, raw_md, img_map = get_combined_markdown(ocr_response)
257
- logger.info(f"Processed markdown length: {len(processed_md)}")
258
- return processed_md, raw_md, img_map
259
- return f"Error: OCR failed for '{file_name}'.", "", {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
- except Exception as e:
262
- logger.error(f"Error during OCR: {e}", exc_info=True)
263
- return f"Error during OCR: {str(e)}", "", {}
264
 
265
  def chunk_markdown(
266
  markdown_text_with_images: str,
267
- chunk_size: int = 1000,
268
- chunk_overlap: int = 200,
269
- strip_headers: bool = True
270
- ) -> List[Document]:
271
- """Chunks markdown text, preserving headers in metadata and extracting images."""
 
 
 
 
 
 
272
  if not markdown_text_with_images or not markdown_text_with_images.strip():
273
  logger.warning("chunk_markdown received empty input.")
274
  return []
@@ -278,168 +336,730 @@ def chunk_markdown(
278
  updated_markdown = replace_image_references(markdown_text_with_images, image_map)
279
  logger.info(f"Extracted {len(image_map)} images from markdown.")
280
 
281
- headers_to_split_on = [
282
- ("#", "Header 1"), ("##", "Header 2"), ("###", "Header 3"),
283
- ("####", "Header 4"), ("#####", "Header 5"), ("######", "Header 6"),
284
- ]
285
- markdown_splitter = MarkdownHeaderTextSplitter(
286
- headers_to_split_on=headers_to_split_on, strip_headers=strip_headers
 
 
287
  )
288
- header_chunks = markdown_splitter.split_text(updated_markdown)
289
-
290
- if not header_chunks:
291
- logger.warning("No header chunks created. Treating entire text as one chunk.")
292
- return [Document(page_content=updated_markdown, metadata={"images_base64": list(image_map.values())})]
293
-
294
- final_chunks = []
295
- if chunk_size > 0:
296
- text_splitter = RecursiveCharacterTextSplitter(
297
- chunk_size=chunk_size, chunk_overlap=chunk_overlap, length_function=len,
298
- separators=["\n\n", "\n", "(?<=\. )", "(?<=\? )", "(?<=! )", ", ", "; ", " ", ""],
299
- add_start_index=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  )
301
- for i, header_chunk in enumerate(header_chunks):
302
- if header_chunk.page_content:
303
- sub_chunks = text_splitter.split_documents([header_chunk])
304
- final_chunks.extend(sub_chunks)
305
- logger.debug(f"Header chunk {i}: Split into {len(sub_chunks)} sub-chunks.")
306
- else:
307
- logger.debug(f"Header chunk {i}: Empty, skipping.")
308
- else:
309
- final_chunks = [chunk for chunk in header_chunks if chunk.page_content]
310
-
311
- # Add image references to metadata for each chunk
312
- for chunk in final_chunks:
313
- if not hasattr(chunk, 'metadata'):
314
- chunk.metadata = {}
315
- # Find image references in this chunk
316
- chunk_img_refs = re.findall(r"!\[.*?\]\((img_ref_\d+)\)", chunk.page_content)
317
- chunk_images = [image_map[ref_id] for ref_id in chunk_img_refs if ref_id in image_map]
318
- chunk.metadata["images_base64"] = chunk_images
319
- chunk.metadata["image_references"] = chunk_img_refs
320
- logger.debug(f"Chunk {chunk.metadata.get('start_index', 'unknown')}: Found {len(chunk_images)} images.")
321
-
322
- logger.info(f"Created {len(final_chunks)} final chunks.")
323
- return final_chunks
324
-
325
- def get_hf_token(explicit_token: str = None) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  """Retrieve Hugging Face token with fallback mechanisms."""
327
- global hf_token_global
328
-
329
- if explicit_token and explicit_token.strip() and explicit_token.startswith('hf_'):
330
  return explicit_token.strip()
331
-
332
- if hf_token_global:
333
- return hf_token_global
334
-
335
  env_token = os.environ.get("HF_TOKEN")
336
- if env_token and env_token.startswith('hf_'):
337
- hf_token_global = env_token
338
  return env_token
339
-
340
  try:
341
  stored_token = huggingface_hub.get_token()
342
  if stored_token:
343
- hf_token_global = stored_token
344
  return stored_token
345
  except Exception as e:
346
  logger.warning(f"Could not retrieve token from Hugging Face config: {e}")
347
-
348
  return None
349
 
350
- def process_file_and_save(
351
- file_objs: Any, chunk_size: int, chunk_overlap: int,
352
- strip_headers: bool, hf_token: str, repo_name: str
 
 
 
 
 
 
353
  ) -> str:
354
- """Orchestrates OCR, chunking, and saving to Hugging Face for multiple files."""
355
- # Handle case where file_objs is a single file or None
356
- if not file_objs:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  return "Error: No files uploaded."
358
- if not isinstance(file_objs, list):
359
- file_objs = [file_objs]
360
-
361
- if not repo_name or '/' not in repo_name:
362
  return "Error: Invalid repository name (use 'username/dataset-name')."
363
 
364
- if chunk_size < 0:
365
- chunk_size = 0
366
- if chunk_overlap < 0:
367
- chunk_overlap = 0
368
- if chunk_size > 0 and chunk_overlap >= chunk_size:
369
- chunk_overlap = min(200, chunk_size // 2)
370
 
371
  effective_hf_token = get_hf_token(hf_token)
372
  if not effective_hf_token:
373
- return """Error: No valid Hugging Face token found.
374
- Please either:
375
- 1. Provide a token in the input field (starts with 'hf_')
376
- 2. Set HF_TOKEN environment variable
377
- 3. Run `huggingface-cli login` in your terminal"""
 
 
378
 
379
  try:
380
- all_data = {
381
- "chunk_id": [],
382
- "text": [],
383
- "metadata": [],
384
- "source_filename": []
385
- }
386
- total_chunks = 0
387
  files_processed = 0
388
  error_messages = []
389
 
390
- for file_idx, file_obj in enumerate(file_objs, 1):
391
- source_filename = getattr(file_obj, 'orig_name', os.path.basename(file_obj.name))
392
- logger.info(f"--- Processing file {file_idx}/{len(file_objs)}: {source_filename} ---")
 
 
393
 
394
- processed_markdown, raw_markdown, img_map = perform_ocr_file(file_obj)
395
- if processed_markdown.startswith("Error:"):
396
- error_messages.append(f"File '{source_filename}': {processed_markdown}")
397
- logger.error(f"Failed to process file {source_filename}: {processed_markdown}")
 
398
  continue
399
 
400
- chunks = chunk_markdown(processed_markdown, chunk_size, chunk_overlap, strip_headers)
401
  if not chunks:
402
- error_messages.append(f"File '{source_filename}': Failed to chunk the document.")
 
 
403
  logger.error(f"Failed to chunk file {source_filename}")
404
  continue
405
 
406
- all_data["chunk_id"].extend([f"{source_filename}_chunk_{i}" for i in range(len(chunks))])
407
- all_data["text"].extend([chunk.page_content or "" for chunk in chunks])
408
- all_data["metadata"].extend([chunk.metadata for chunk in chunks])
409
- all_data["source_filename"].extend([source_filename] * len(chunks))
410
- total_chunks += len(chunks)
411
  files_processed += 1
412
- logger.info(f"File {source_filename}: Added {len(chunks)} chunks. Total chunks: {total_chunks}")
 
 
413
 
414
- if not all_data["chunk_id"]:
415
- return "Error: No valid data processed from any files.\n" + "\n".join(error_messages)
 
 
416
 
417
- dataset = Dataset.from_dict(all_data)
418
- api = HfApi(token=effective_hf_token)
419
-
420
- try:
421
- user_info = api.whoami()
422
- logger.info(f"Authenticated as: {user_info['name']}")
423
- except Exception as auth_err:
424
- return f"Error: Invalid HF token - authentication failed: {auth_err}"
425
 
426
- try:
427
- api.repo_info(repo_id=repo_name, repo_type="dataset")
428
- logger.info(f"Repository '{repo_name}' exists.")
429
- except huggingface_hub.utils.RepositoryNotFoundError:
430
- api.create_repo(repo_id=repo_name, repo_type="dataset", private=False)
431
- logger.info(f"Created repository '{repo_name}'.")
 
 
 
 
432
 
433
- dataset.push_to_hub(repo_name, token=effective_hf_token,
434
- commit_message=f"Add OCR data from {files_processed} files")
435
- repo_url = f"https://huggingface.co/datasets/{repo_name}"
436
- result = f"Success! Dataset with {total_chunks} chunks from {files_processed}/{len(file_objs)} files saved to: {repo_url}"
 
 
 
 
 
 
 
437
  if error_messages:
438
- result += "\n\nErrors encountered:\n" + "\n".join(error_messages)
439
- return result
 
 
440
 
441
  except huggingface_hub.utils.HfHubHTTPError as hf_http_err:
442
- status = getattr(hf_http_err.response, 'status_code', 'Unknown')
443
  if status == 401:
444
  return "Error: Invalid or unauthorized Hugging Face token."
445
  elif status == 403:
@@ -447,81 +1067,457 @@ def process_file_and_save(
447
  return f"Error: Hugging Face Hub Error (Status {status}): {hf_http_err}"
448
  except Exception as e:
449
  logger.error(f"Unexpected error: {e}", exc_info=True)
450
- return f"Unexpected error: {str(e)}\n" + "\n".join(error_messages)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
 
452
  # --- Gradio Interface ---
453
- with gr.Blocks(title="Mistral OCR & Dataset Creator",
454
- theme=gr.themes.Soft(primary_hue="blue", secondary_hue="cyan")) as demo:
455
- gr.Markdown("# Mistral OCR, Markdown Chunking, and Hugging Face Dataset Creator")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  gr.Markdown(
457
  """
458
- Upload one or more PDF or image files. The application will:
459
- 1. Extract text and images using Mistral OCR for each file
460
- 2. Embed images as base64 data URIs in markdown
461
- 3. Chunk markdown by headers and optionally character count
462
- 4. Store embedded images in chunk metadata
463
- 5. Create/update a Hugging Face Dataset with all processed data
464
- """
465
  )
466
 
467
- with gr.Row():
468
- with gr.Column(scale=1):
469
- file_input = gr.File(
470
- label="Upload PDF or Image Files",
471
- file_types=['.pdf', '.png', '.jpg', '.jpeg', '.webp', '.bmp'],
472
- type="filepath",
473
- file_count="multiple" # Allow multiple file uploads
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  )
475
- gr.Markdown("## Chunking Options")
476
- chunk_size = gr.Slider(minimum=0, maximum=8000, value=1000, step=100,
477
- label="Max Chunk Size (Characters)")
478
- chunk_overlap = gr.Slider(minimum=0, maximum=1000, value=200, step=50,
479
- label="Chunk Overlap (Characters)")
480
- strip_headers = gr.Checkbox(label="Strip Headers from Content", value=True)
481
- gr.Markdown("## Hugging Face Output Options")
482
- repo_name = gr.Textbox(label="HF Dataset Repository",
483
- placeholder="your-username/your-dataset-name")
484
- hf_token = gr.Textbox(label="Hugging Face Token", type="password",
485
- placeholder="hf_...")
486
- submit_btn = gr.Button("Process and Save", variant="primary")
487
-
488
- with gr.Column(scale=1):
489
- output = gr.Textbox(label="Result Status", lines=20, interactive=False)
490
 
491
  submit_btn.click(
492
- fn=process_file_and_save,
493
- inputs=[file_input, chunk_size, chunk_overlap, strip_headers, hf_token, repo_name],
494
- outputs=output
 
 
 
 
 
 
 
 
495
  )
496
 
497
  gr.Examples(
498
  examples=[
499
- [None, 1000, 200, True, "", "hf-username/my-first-ocr-dataset"],
500
- [None, 2000, 400, True, "", "hf-username/large-chunk-ocr-data"],
501
- [None, 0, 0, False, "", "hf-username/header-only-ocr-data"],
 
 
 
 
 
 
 
 
 
502
  ],
503
- inputs=[file_input, chunk_size, chunk_overlap, strip_headers, hf_token, repo_name],
504
  outputs=output,
505
- fn=process_file_and_save,
506
- cache_examples=False
507
  )
508
-
509
- gr.Markdown("*Requires MISTRAL_API_KEY or HF token*")
510
 
511
- if __name__ == "__main__":
512
- import gradio
513
- logger.info(f"Using Gradio version: {gradio.__version__}")
514
- if not gradio.__version__.startswith("4."):
515
- logger.warning("Gradio version is not 4.x. Updating to the latest version is recommended.")
516
- print("Consider running: pip install --upgrade gradio")
517
-
518
- initial_token = get_hf_token()
519
- if not initial_token and not client:
520
- print("\nWARNING: Neither Mistral API key nor HF token found.")
521
- print("Set MISTRAL_API_KEY and/or HF_TOKEN, or use `huggingface-cli login`")
522
-
523
  demo.launch(
524
- share=os.getenv('GRADIO_SHARE', 'False').lower() == 'true',
525
  debug=True,
526
- auth_message="Provide a valid Hugging Face token if prompted"
527
  )
 
 
 
 
 
1
+ from dotenv import load_dotenv
2
+
3
+ load_dotenv()
4
+
5
  import gradio as gr
6
+ from chonkie import RecursiveChunker
7
+ from typing import Dict, Any, List, Optional
8
+ from dataclasses import dataclass, field
9
  import logging
10
  import re
11
  import base64
12
+ import hashlib
13
  import mimetypes
14
+ import json
15
+ from collections import Counter
16
+ from datasets import Dataset, Features, Value, Sequence, load_dataset
17
+ from datasets.features import Image as HFImage
18
  from huggingface_hub import HfApi, get_token
19
  import huggingface_hub
20
  import os
21
  from mistralai import Mistral
22
+ import fitz # pymupdf
23
+ from PIL import Image
24
+ import io
25
+ import tempfile
26
 
27
  # Configure logging
28
+ logging.basicConfig(
29
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
30
+ )
31
  logger = logging.getLogger(__name__)
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
+ # --- Exceptions ---
35
+
36
+
37
+ class OCRError(Exception):
38
+ """Raised when OCR processing fails."""
39
+
40
+ pass
41
+
42
+
43
+ # --- Mistral Client (lazy init) ---
44
+
45
+ _client: Mistral | None = None
46
+
47
+
48
+ def get_mistral_client() -> Mistral:
49
+ """Get or initialize the Mistral client."""
50
+ global _client
51
+ if _client is not None:
52
+ return _client
53
+
54
+ api_key = os.environ.get("MISTRAL_API_KEY")
55
+ if not api_key:
56
+ logger.warning("MISTRAL_API_KEY not set. Attempting to use Hugging Face token.")
57
+ api_key = get_token()
58
+ if api_key:
59
+ logger.info("Using Hugging Face token as MISTRAL_API_KEY.")
60
+
61
+ if not api_key:
62
+ raise OCRError(
63
+ "No API key found. Set MISTRAL_API_KEY or run `huggingface-cli login`."
64
+ )
65
+
66
+ _client = Mistral(api_key=api_key)
67
+ logger.info("Mistral client initialized successfully.")
68
+ return _client
69
+
70
 
71
  # --- Helper Functions ---
72
 
73
+
74
  def encode_image_bytes(image_bytes: bytes) -> str:
75
  """Encodes image bytes to a base64 string."""
76
+ return base64.b64encode(image_bytes).decode("utf-8")
77
+
78
+
79
+ def decode_base64_data_uri(data_uri: str) -> Optional[dict]:
80
+ """Decode a base64 data URI to a HF-compatible image bytes dict.
81
+
82
+ Args:
83
+ data_uri: A string like "data:image/jpeg;base64,/9j/4AAQ..." or raw base64.
84
+
85
+ Returns:
86
+ Dict with {"bytes": <raw bytes>, "path": None} for datasets.Image feature,
87
+ or None if decoding fails.
88
+ """
89
+ try:
90
+ if data_uri.startswith("data:"):
91
+ # Strip the "data:image/...;base64," prefix
92
+ _, encoded = data_uri.split(",", 1)
93
+ else:
94
+ encoded = data_uri
95
+ raw_bytes = base64.b64decode(encoded)
96
+ # Validate it's a real image by opening it
97
+ img = Image.open(io.BytesIO(raw_bytes))
98
+ # Re-encode as PNG for consistency
99
+ buf = io.BytesIO()
100
+ img.save(buf, format="PNG")
101
+ return {"bytes": buf.getvalue(), "path": None}
102
+ except Exception as e:
103
+ logger.warning(f"Failed to decode base64 image ({len(data_uri)} chars): {e}")
104
+ return None
105
+
106
 
107
  def extract_images_from_markdown(markdown_text: str) -> Dict[str, str]:
108
  """
 
110
  Returns a dictionary mapping reference IDs to base64 data URIs.
111
  """
112
  image_map = {}
113
+ img_refs = re.findall(
114
+ r"!\[.*?\]\((data:image/[a-zA-Z+]+;base64,[A-Za-z0-9+/=]+)\)", markdown_text
115
+ )
116
  for idx, img_uri in enumerate(img_refs):
117
+ ref_id = f"img_ref_{idx + 1}"
118
  image_map[ref_id] = img_uri
119
  return image_map
120
 
121
+
122
  def replace_image_references(markdown_text: str, image_map: Dict[str, str]) -> str:
123
  """
124
  Replaces base64 image data URIs in markdown with reference IDs (e.g., img_ref_1).
 
130
  updated_markdown = re.sub(pattern, f"\\1{ref_id}\\2", updated_markdown)
131
  return updated_markdown
132
 
133
+
134
+ def get_combined_markdown(ocr_response: Any) -> tuple[str, str, Dict[str, str]]:
135
  """Combines markdown from OCR pages, replacing image IDs with base64 data URIs."""
136
  processed_markdowns = []
137
  raw_markdowns = []
138
  image_data_map = {}
139
 
140
+ if not hasattr(ocr_response, "pages") or not ocr_response.pages:
141
+ logger.warning("OCR response has no pages.")
142
  return "", "", {}
143
 
144
+ for page_idx, page in enumerate(ocr_response.pages):
145
+ if hasattr(page, "images") and page.images:
146
+ logger.info(f"Page {page_idx}: Found {len(page.images)} images.")
147
+ for img in page.images:
148
+ if (
149
+ hasattr(img, "id")
150
+ and hasattr(img, "image_base64")
151
+ and img.image_base64
152
+ ):
153
+ image_data_map[img.id] = img.image_base64
154
+ else:
155
+ logger.warning(
156
+ f"Page {page_idx}: Image object lacks 'id' or valid 'image_base64'."
157
+ )
158
+ else:
159
+ logger.info(f"Page {page_idx}: No images found.")
160
 
161
+ if not hasattr(page, "markdown"):
162
+ logger.warning(f"Page {page_idx} lacks 'markdown' attribute. Skipping.")
163
+ continue
164
 
165
+ current_raw_markdown = page.markdown or ""
166
+ raw_markdowns.append(current_raw_markdown)
167
+ current_processed_markdown = current_raw_markdown
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
+ img_refs = re.findall(r"!\[.*?\]\((.*?)\)", current_processed_markdown)
170
+ for img_id in img_refs:
171
+ if img_id in image_data_map:
172
+ base64_data_uri = image_data_map[img_id]
173
+ escaped_img_id = re.escape(img_id)
174
+ pattern = r"(!\[.*?\]\()" + escaped_img_id + r"(\))"
175
+ current_processed_markdown = re.sub(
176
+ pattern,
177
+ r"\1" + base64_data_uri + r"\2",
178
+ current_processed_markdown,
179
+ )
180
+ elif not img_id.startswith(("http:", "https:", "data:")):
181
+ logger.warning(
182
+ f"Page {page_idx}: Image ID '{img_id}' not in image data."
183
+ )
184
 
185
+ processed_markdowns.append(current_processed_markdown)
 
186
 
187
+ logger.info(
188
+ f"Processed {len(processed_markdowns)} pages with {len(image_data_map)} images."
189
+ )
190
+ return "\n\n".join(processed_markdowns), "\n\n".join(raw_markdowns), image_data_map
191
 
 
 
 
 
 
 
192
 
193
+ def perform_ocr(file_path: str) -> tuple[str, str, Dict[str, str]]:
194
+ """Performs OCR on a file using Mistral API.
 
 
 
195
 
196
+ Args:
197
+ file_path: Path to the file on disk.
198
 
199
+ Returns:
200
+ Tuple of (processed_markdown, raw_markdown, image_data_map).
201
+
202
+ Raises:
203
+ OCRError: If OCR processing fails.
204
+ """
205
+ client = get_mistral_client()
206
+ file_name = os.path.basename(file_path)
207
+ file_ext = os.path.splitext(file_name)[1].lower()
208
+ logger.info(f"Performing OCR on file: {file_name}")
209
+
210
+ ocr_response = None
211
+ supported_images = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
212
+
213
+ if file_ext == ".pdf":
214
+ uploaded_file_id = None
215
+ try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  with open(file_path, "rb") as f:
217
+ file_content = f.read()
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
+ logger.info(f"Uploading PDF {file_name} to Mistral...")
220
+ uploaded_pdf = client.files.upload(
221
+ file={"file_name": file_name, "content": file_content},
222
+ purpose="ocr",
223
+ )
224
+ uploaded_file_id = uploaded_pdf.id
225
+ logger.info(f"PDF uploaded. File ID: {uploaded_file_id}")
226
 
227
+ signed_url_response = client.files.get_signed_url(file_id=uploaded_file_id)
228
+ ocr_response = client.ocr.process(
229
+ model="mistral-ocr-latest",
230
+ document={
231
+ "type": "document_url",
232
+ "document_url": signed_url_response.url,
233
+ },
234
+ include_image_base64=True,
235
+ )
236
+ finally:
237
+ if uploaded_file_id:
238
+ try:
239
+ client.files.delete(file_id=uploaded_file_id)
240
+ except Exception as delete_err:
241
+ logger.warning(
242
+ f"Failed to delete temporary file {uploaded_file_id}: {delete_err}"
243
+ )
244
+
245
+ elif file_ext in supported_images:
246
+ with open(file_path, "rb") as f:
247
+ image_bytes = f.read()
248
+ if not image_bytes:
249
+ raise OCRError(f"Uploaded image file '{file_name}' is empty.")
250
+
251
+ base64_encoded = encode_image_bytes(image_bytes)
252
+ mime_type, _ = mimetypes.guess_type(file_path)
253
+ mime_type = mime_type or "image/jpeg"
254
+ data_uri = f"data:{mime_type};base64,{base64_encoded}"
255
+ ocr_response = client.ocr.process(
256
+ model="mistral-ocr-latest",
257
+ document={"type": "image_url", "image_url": data_uri},
258
+ include_image_base64=True,
259
+ )
260
+ else:
261
+ raise OCRError(f"Unsupported file type: '{file_ext}'")
262
+
263
+ if not ocr_response:
264
+ raise OCRError(f"OCR returned no response for '{file_name}'.")
265
+
266
+ processed_md, raw_md, img_map = get_combined_markdown(ocr_response)
267
+ logger.info(f"Processed markdown length: {len(processed_md)}")
268
+ return processed_md, raw_md, img_map
269
+
270
+
271
+ def _build_header_index(markdown_text: str) -> list[tuple[int, int, str]]:
272
+ """Build a sorted index of (position, level, title) for all markdown headers."""
273
+ headers = []
274
+ for match in re.finditer(r"^(#{1,6})\s+(.+)$", markdown_text, re.MULTILINE):
275
+ level = len(match.group(1))
276
+ title = match.group(2).strip()
277
+ headers.append((match.start(), level, title))
278
+ return headers
279
+
280
+
281
+ def _get_headers_for_position(
282
+ headers: list[tuple[int, int, str]], position: int
283
+ ) -> dict[str, str]:
284
+ """Given a character position, find the active chapter/section/subsection.
285
+
286
+ Maps header levels: H1 -> chapter, H2 -> section, H3+ -> subsection.
287
+ """
288
+ active: dict[int, str] = {}
289
+ for hdr_pos, level, title in headers:
290
+ if hdr_pos > position:
291
+ break
292
+ active[level] = title
293
+ # Clear deeper levels when a higher-level header appears
294
+ for deeper in list(active.keys()):
295
+ if deeper > level:
296
+ del active[deeper]
297
+
298
+ return {
299
+ "chapter": active.get(1, ""),
300
+ "section": active.get(2, ""),
301
+ "subsection": active.get(3, active.get(4, active.get(5, active.get(6, "")))),
302
+ }
303
+
304
+
305
+ def _clean_text(text: str) -> str:
306
+ """Remove markdown formatting, image refs, and extra whitespace."""
307
+ cleaned = re.sub(r"!\[.*?\]\(.*?\)", "", text)
308
+ cleaned = re.sub(r"#{1,6}\s+", "", cleaned)
309
+ cleaned = re.sub(r"\*\*(.+?)\*\*", r"\1", cleaned)
310
+ cleaned = re.sub(r"\*(.+?)\*", r"\1", cleaned)
311
+ cleaned = re.sub(r"`(.+?)`", r"\1", cleaned)
312
+ cleaned = re.sub(r"\[(.+?)\]\(.*?\)", r"\1", cleaned)
313
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
314
+ return cleaned.strip()
315
 
 
 
 
316
 
317
  def chunk_markdown(
318
  markdown_text_with_images: str,
319
+ chunk_size: int = 512,
320
+ ) -> list[dict]:
321
+ """Chunks markdown text using chonkie's RecursiveChunker with markdown recipe.
322
+
323
+ Args:
324
+ markdown_text_with_images: Markdown text possibly containing base64 image references.
325
+ chunk_size: Maximum character count per chunk.
326
+
327
+ Returns:
328
+ List of chunk dicts with the full dataset schema fields.
329
+ """
330
  if not markdown_text_with_images or not markdown_text_with_images.strip():
331
  logger.warning("chunk_markdown received empty input.")
332
  return []
 
336
  updated_markdown = replace_image_references(markdown_text_with_images, image_map)
337
  logger.info(f"Extracted {len(image_map)} images from markdown.")
338
 
339
+ # Build header index for chapter/section/subsection lookup
340
+ header_index = _build_header_index(updated_markdown)
341
+
342
+ # Use chonkie's RecursiveChunker with markdown recipe
343
+ chunker = RecursiveChunker.from_recipe(
344
+ "markdown",
345
+ lang="en",
346
+ chunk_size=chunk_size,
347
  )
348
+ chunks = chunker.chunk(updated_markdown)
349
+
350
+ if not chunks:
351
+ logger.warning("No chunks created. Treating entire text as one chunk.")
352
+ all_refs = list(image_map.keys())
353
+ all_images = [
354
+ decoded
355
+ for uri in image_map.values()
356
+ if (decoded := decode_base64_data_uri(uri)) is not None
357
+ ]
358
+ headers = _get_headers_for_position(header_index, 0)
359
+ return [
360
+ {
361
+ "text": updated_markdown,
362
+ "text_clean": _clean_text(updated_markdown),
363
+ "chapter": headers["chapter"],
364
+ "section": headers["section"],
365
+ "subsection": headers["subsection"],
366
+ "images": all_images,
367
+ "image_refs": all_refs,
368
+ "num_images": len(all_images),
369
+ "has_images": len(all_images) > 0,
370
+ "start_index": 0,
371
+ "char_count": len(updated_markdown),
372
+ }
373
+ ]
374
+
375
+ result = []
376
+ for chunk in chunks:
377
+ chunk_img_refs = re.findall(r"!\[.*?\]\((img_ref_\d+)\)", chunk.text)
378
+ chunk_images = [
379
+ decoded
380
+ for ref_id in chunk_img_refs
381
+ if ref_id in image_map
382
+ and (decoded := decode_base64_data_uri(image_map[ref_id])) is not None
383
+ ]
384
+ headers = _get_headers_for_position(header_index, chunk.start_index)
385
+ text_clean = _clean_text(chunk.text)
386
+
387
+ result.append(
388
+ {
389
+ "text": chunk.text,
390
+ "text_clean": text_clean,
391
+ "chapter": headers["chapter"],
392
+ "section": headers["section"],
393
+ "subsection": headers["subsection"],
394
+ "images": chunk_images,
395
+ "image_refs": chunk_img_refs,
396
+ "num_images": len(chunk_images),
397
+ "has_images": len(chunk_images) > 0,
398
+ "start_index": chunk.start_index,
399
+ "char_count": len(chunk.text),
400
+ }
401
  )
402
+
403
+ logger.info(f"Created {len(result)} chunks.")
404
+ return result
405
+
406
+
407
+ ### --- Dataset Builder Pipeline ---
408
+
409
+
410
+ DATASET_SCHEMA = {
411
+ "chunk_id": str,
412
+ "text": str,
413
+ "text_clean": str,
414
+ "chapter": str,
415
+ "section": str,
416
+ "subsection": str,
417
+ "images": list,
418
+ "image_refs": list,
419
+ "num_images": int,
420
+ "has_images": bool,
421
+ "source_filename": str,
422
+ "start_index": int,
423
+ "char_count": int,
424
+ }
425
+
426
+
427
+ @dataclass
428
+ class QualityConfig:
429
+ """Configuration for quality filtering thresholds."""
430
+
431
+ min_char_count: int = 20
432
+ min_clean_char_count: int = 10
433
+ max_char_count: int = 50_000
434
+ min_word_count: int = 3
435
+ max_image_refs_without_text: int = 0
436
+ remove_empty_text: bool = True
437
+ remove_whitespace_only: bool = True
438
+
439
+
440
+ @dataclass
441
+ class PipelineStats:
442
+ """Statistics collected during pipeline execution."""
443
+
444
+ total_input_chunks: int = 0
445
+ chunks_after_validation: int = 0
446
+ chunks_after_dedup: int = 0
447
+ chunks_after_quality: int = 0
448
+ duplicates_removed: int = 0
449
+ quality_filtered: int = 0
450
+ validation_errors: List[str] = field(default_factory=list)
451
+ quality_reasons: Counter = field(default_factory=Counter)
452
+ source_file_counts: Counter = field(default_factory=Counter)
453
+ avg_char_count: float = 0.0
454
+ avg_images_per_chunk: float = 0.0
455
+ chapters_found: List[str] = field(default_factory=list)
456
+
457
+ def summary(self) -> str:
458
+ """Generate a human-readable pipeline summary."""
459
+ lines = [
460
+ "--- Dataset Pipeline Report ---",
461
+ f"Input chunks: {self.total_input_chunks}",
462
+ f"After validation: {self.chunks_after_validation}",
463
+ f"Duplicates removed: {self.duplicates_removed}",
464
+ f"After deduplication: {self.chunks_after_dedup}",
465
+ f"Quality filtered out: {self.quality_filtered}",
466
+ f"Final dataset size: {self.chunks_after_quality}",
467
+ "",
468
+ f"Avg chars/chunk: {self.avg_char_count:.0f}",
469
+ f"Avg images/chunk: {self.avg_images_per_chunk:.2f}",
470
+ ]
471
+
472
+ if self.source_file_counts:
473
+ lines.append("")
474
+ lines.append("Chunks per source file:")
475
+ for fname, count in sorted(self.source_file_counts.items()):
476
+ lines.append(f" {fname}: {count}")
477
+
478
+ if self.chapters_found:
479
+ unique_chapters = sorted(set(c for c in self.chapters_found if c))
480
+ if unique_chapters:
481
+ lines.append("")
482
+ lines.append(f"Chapters found ({len(unique_chapters)}):")
483
+ for ch in unique_chapters[:20]:
484
+ lines.append(f" - {ch}")
485
+ if len(unique_chapters) > 20:
486
+ lines.append(f" ... and {len(unique_chapters) - 20} more")
487
+
488
+ if self.quality_reasons:
489
+ lines.append("")
490
+ lines.append("Quality filter reasons:")
491
+ for reason, count in self.quality_reasons.most_common():
492
+ lines.append(f" {reason}: {count}")
493
+
494
+ if self.validation_errors:
495
+ lines.append("")
496
+ lines.append(f"Validation errors ({len(self.validation_errors)}):")
497
+ for err in self.validation_errors[:10]:
498
+ lines.append(f" - {err}")
499
+ if len(self.validation_errors) > 10:
500
+ lines.append(f" ... and {len(self.validation_errors) - 10} more")
501
+
502
+ lines.append("-------------------------------")
503
+ return "\n".join(lines)
504
+
505
+
506
+ class DatasetBuilder:
507
+ """Pipeline for building high-quality datasets before pushing to HF Hub.
508
+
509
+ Stages:
510
+ 1. Validate -- ensure every chunk matches the expected schema
511
+ 2. Deduplicate -- remove chunks with identical content hashes
512
+ 3. Quality filter -- remove empty, too-short, or malformed chunks
513
+ 4. Statistics -- compute summary stats for review
514
+ 5. Push -- incremental append or full overwrite to HF Hub
515
+ """
516
+
517
+ def __init__(
518
+ self,
519
+ quality_config: Optional[QualityConfig] = None,
520
+ ):
521
+ self.quality_config = quality_config or QualityConfig()
522
+ self.stats = PipelineStats()
523
+ self._chunks: List[Dict[str, Any]] = []
524
+ self._seen_hashes: set = set()
525
+
526
+ def add_chunks(self, chunks: List[Dict[str, Any]], source_filename: str) -> None:
527
+ """Add raw chunks from a processed file into the pipeline.
528
+
529
+ Each chunk gets its source_filename attached and is tracked for stats.
530
+ """
531
+ for chunk in chunks:
532
+ chunk_with_source = {**chunk, "source_filename": source_filename}
533
+ self._chunks.append(chunk_with_source)
534
+ self.stats.source_file_counts[source_filename] += len(chunks)
535
+
536
+ def _content_hash(self, chunk: Dict[str, Any]) -> str:
537
+ """Compute a stable hash of chunk content for deduplication."""
538
+ text = chunk.get("text_clean", chunk.get("text", ""))
539
+ source = chunk.get("source_filename", "")
540
+ return hashlib.sha256(f"{source}::{text}".encode("utf-8")).hexdigest()
541
+
542
+ # --- Stage 1: Validation ---
543
+
544
+ def _validate(self, chunks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
545
+ """Validate every chunk conforms to the expected schema.
546
+
547
+ Drops chunks with missing required fields and logs errors.
548
+ """
549
+ valid = []
550
+ required_keys = set(DATASET_SCHEMA.keys())
551
+
552
+ for i, chunk in enumerate(chunks):
553
+ missing = required_keys - set(chunk.keys())
554
+ if missing:
555
+ self.stats.validation_errors.append(
556
+ f"Chunk {i} ({chunk.get('chunk_id', '?')}): missing fields {missing}"
557
+ )
558
+ continue
559
+
560
+ type_ok = True
561
+ for key, expected_type in DATASET_SCHEMA.items():
562
+ val = chunk[key]
563
+ if not isinstance(val, expected_type):
564
+ self.stats.validation_errors.append(
565
+ f"Chunk {i} ({chunk.get('chunk_id', '?')}): "
566
+ f"field '{key}' expected {expected_type.__name__}, "
567
+ f"got {type(val).__name__}"
568
+ )
569
+ type_ok = False
570
+ break
571
+
572
+ if type_ok:
573
+ valid.append(chunk)
574
+
575
+ return valid
576
+
577
+ # --- Stage 2: Deduplication ---
578
+
579
+ def _deduplicate(self, chunks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
580
+ """Remove chunks with identical content hashes."""
581
+ unique = []
582
+ for chunk in chunks:
583
+ h = self._content_hash(chunk)
584
+ if h not in self._seen_hashes:
585
+ self._seen_hashes.add(h)
586
+ unique.append(chunk)
587
+ return unique
588
+
589
+ # --- Stage 3: Quality Filter ---
590
+
591
+ def _quality_filter(self, chunks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
592
+ """Filter out low-quality chunks based on configurable thresholds."""
593
+ cfg = self.quality_config
594
+ passed = []
595
+
596
+ for chunk in chunks:
597
+ text = chunk.get("text", "")
598
+ text_clean = chunk.get("text_clean", "")
599
+ char_count = chunk.get("char_count", len(text))
600
+
601
+ # Empty text
602
+ if cfg.remove_empty_text and not text.strip():
603
+ self.stats.quality_reasons["empty_text"] += 1
604
+ continue
605
+
606
+ # Whitespace only
607
+ if cfg.remove_whitespace_only and not text_clean.strip():
608
+ self.stats.quality_reasons["whitespace_only"] += 1
609
+ continue
610
+
611
+ # Too short
612
+ if char_count < cfg.min_char_count:
613
+ self.stats.quality_reasons[
614
+ f"below_min_chars({cfg.min_char_count})"
615
+ ] += 1
616
+ continue
617
+
618
+ # Clean text too short
619
+ if len(text_clean.strip()) < cfg.min_clean_char_count:
620
+ self.stats.quality_reasons[
621
+ f"clean_text_too_short({cfg.min_clean_char_count})"
622
+ ] += 1
623
+ continue
624
+
625
+ # Too long (likely malformed)
626
+ if char_count > cfg.max_char_count:
627
+ self.stats.quality_reasons[
628
+ f"above_max_chars({cfg.max_char_count})"
629
+ ] += 1
630
+ continue
631
+
632
+ # Too few words
633
+ word_count = len(text_clean.split())
634
+ if word_count < cfg.min_word_count:
635
+ self.stats.quality_reasons[
636
+ f"below_min_words({cfg.min_word_count})"
637
+ ] += 1
638
+ continue
639
+
640
+ # Image-only chunk with no text
641
+ if (
642
+ cfg.max_image_refs_without_text == 0
643
+ and chunk.get("num_images", 0) > 0
644
+ and word_count == 0
645
+ ):
646
+ self.stats.quality_reasons["image_only_no_text"] += 1
647
+ continue
648
+
649
+ passed.append(chunk)
650
+
651
+ return passed
652
+
653
+ # --- Stage 4: Compute Stats ---
654
+
655
+ def _compute_stats(self, chunks: List[Dict[str, Any]]) -> None:
656
+ """Compute summary statistics on the final dataset."""
657
+ if not chunks:
658
+ return
659
+
660
+ total_chars = sum(c.get("char_count", 0) for c in chunks)
661
+ total_images = sum(c.get("num_images", 0) for c in chunks)
662
+ self.stats.avg_char_count = total_chars / len(chunks)
663
+ self.stats.avg_images_per_chunk = total_images / len(chunks)
664
+ self.stats.chapters_found = [c.get("chapter", "") for c in chunks]
665
+
666
+ # Update source file counts to reflect final dataset
667
+ final_counts: Counter = Counter()
668
+ for c in chunks:
669
+ final_counts[c.get("source_filename", "unknown")] += 1
670
+ self.stats.source_file_counts = final_counts
671
+
672
+ # --- Run Full Pipeline ---
673
+
674
+ def build(self) -> tuple[Dict[str, list], PipelineStats]:
675
+ """Run the full pipeline and return columnar data + stats.
676
+
677
+ Returns:
678
+ Tuple of (columnar_data_dict, pipeline_stats).
679
+ """
680
+ chunks = list(self._chunks)
681
+ self.stats.total_input_chunks = len(chunks)
682
+ logger.info(f"Pipeline: {len(chunks)} input chunks")
683
+
684
+ # Stage 1: Validate
685
+ chunks = self._validate(chunks)
686
+ self.stats.chunks_after_validation = len(chunks)
687
+ logger.info(f"Pipeline: {len(chunks)} after validation")
688
+
689
+ # Stage 2: Deduplicate
690
+ before_dedup = len(chunks)
691
+ chunks = self._deduplicate(chunks)
692
+ self.stats.duplicates_removed = before_dedup - len(chunks)
693
+ self.stats.chunks_after_dedup = len(chunks)
694
+ logger.info(
695
+ f"Pipeline: {len(chunks)} after dedup ({self.stats.duplicates_removed} removed)"
696
+ )
697
+
698
+ # Stage 3: Quality filter
699
+ before_quality = len(chunks)
700
+ chunks = self._quality_filter(chunks)
701
+ self.stats.quality_filtered = before_quality - len(chunks)
702
+ self.stats.chunks_after_quality = len(chunks)
703
+ logger.info(
704
+ f"Pipeline: {len(chunks)} after quality filter ({self.stats.quality_filtered} removed)"
705
+ )
706
+
707
+ # Stage 4: Stats
708
+ self._compute_stats(chunks)
709
+
710
+ # Convert to columnar format
711
+ all_data: Dict[str, list] = {key: [] for key in DATASET_SCHEMA.keys()}
712
+ for chunk in chunks:
713
+ for key in DATASET_SCHEMA.keys():
714
+ all_data[key].append(chunk[key])
715
+
716
+ return all_data, self.stats
717
+
718
+ # --- Push to Hub ---
719
+
720
+ @staticmethod
721
+ def push(
722
+ all_data: Dict[str, list],
723
+ repo_name: str,
724
+ hf_token: str,
725
+ stats: PipelineStats,
726
+ append: bool = False,
727
+ ) -> str:
728
+ """Push the built dataset to Hugging Face Hub.
729
+
730
+ Args:
731
+ all_data: Columnar data dict from build().
732
+ repo_name: HF repo in 'username/dataset-name' format.
733
+ hf_token: Hugging Face API token.
734
+ stats: Pipeline stats for the dataset card.
735
+ append: If True, append to existing dataset instead of overwriting.
736
+
737
+ Returns:
738
+ Status message string.
739
+ """
740
+ if not all_data or not all_data.get("chunk_id"):
741
+ return "Error: No data to push after pipeline."
742
+
743
+ api = HfApi(token=hf_token)
744
+
745
+ try:
746
+ user_info = api.whoami()
747
+ logger.info(f"Authenticated as: {user_info['name']}")
748
+ except Exception as auth_err:
749
+ return f"Error: Invalid HF token - authentication failed: {auth_err}"
750
+
751
+ # Create repo if needed
752
+ try:
753
+ api.repo_info(repo_id=repo_name, repo_type="dataset")
754
+ logger.info(f"Repository '{repo_name}' exists.")
755
+ except huggingface_hub.utils.RepositoryNotFoundError:
756
+ api.create_repo(repo_id=repo_name, repo_type="dataset", private=False)
757
+ logger.info(f"Created repository '{repo_name}'.")
758
+
759
+ if append:
760
+ # Incremental append: load existing, concatenate, push
761
+ try:
762
+ existing_ds = load_dataset(repo_name, token=hf_token, split="train")
763
+ existing_data = existing_ds.to_dict()
764
+ for key in all_data:
765
+ if key in existing_data:
766
+ existing_data[key].extend(all_data[key])
767
+ else:
768
+ existing_data[key] = all_data[key]
769
+ # Deduplicate by chunk_id across old + new
770
+ seen_ids = set()
771
+ deduped: Dict[str, list] = {key: [] for key in existing_data}
772
+ for i, cid in enumerate(existing_data["chunk_id"]):
773
+ if cid not in seen_ids:
774
+ seen_ids.add(cid)
775
+ for key in existing_data:
776
+ deduped[key].append(existing_data[key][i])
777
+ merged_dataset = Dataset.from_dict(deduped)
778
+ total_chunks = len(deduped["chunk_id"])
779
+ new_chunks = total_chunks - len(existing_ds)
780
+ commit_msg = f"Append {new_chunks} new chunks (total: {total_chunks})"
781
+ except Exception as e:
782
+ logger.warning(
783
+ f"Could not load existing dataset for append, doing full push: {e}"
784
+ )
785
+ merged_dataset = Dataset.from_dict(all_data)
786
+ total_chunks = len(all_data["chunk_id"])
787
+ commit_msg = f"Add {total_chunks} chunks"
788
+ else:
789
+ merged_dataset = Dataset.from_dict(all_data)
790
+ total_chunks = len(all_data["chunk_id"])
791
+ commit_msg = f"Add OCR data: {total_chunks} chunks"
792
+
793
+ # Cast the images column so the HF Dataset Viewer renders actual images
794
+ # instead of showing raw base64 strings
795
+ try:
796
+ merged_dataset = merged_dataset.cast_column("images", Sequence(HFImage()))
797
+ logger.info(
798
+ "Cast 'images' column to Sequence(Image()) for viewer rendering."
799
+ )
800
+ except Exception as e:
801
+ logger.warning(f"Could not cast images column to Image feature: {e}")
802
+
803
+ merged_dataset.push_to_hub(
804
+ repo_name,
805
+ token=hf_token,
806
+ commit_message=commit_msg,
807
+ )
808
+
809
+ # Generate and upload dataset card
810
+ card_content = DatasetBuilder._generate_dataset_card(repo_name, stats, all_data)
811
+ try:
812
+ api.upload_file(
813
+ path_or_fileobj=card_content.encode("utf-8"),
814
+ path_in_repo="README.md",
815
+ repo_id=repo_name,
816
+ repo_type="dataset",
817
+ commit_message="Update dataset card with pipeline stats",
818
+ )
819
+ except Exception as e:
820
+ logger.warning(f"Failed to update dataset card: {e}")
821
+
822
+ repo_url = f"https://huggingface.co/datasets/{repo_name}"
823
+ return f"Success! {total_chunks} chunks pushed to: {repo_url}"
824
+
825
+ @staticmethod
826
+ def _generate_dataset_card(
827
+ repo_name: str,
828
+ stats: PipelineStats,
829
+ all_data: Dict[str, list],
830
+ ) -> str:
831
+ """Generate a dataset card (README.md) with schema and stats."""
832
+ total = stats.chunks_after_quality
833
+ sources = sorted(stats.source_file_counts.items())
834
+ unique_chapters = sorted(set(c for c in stats.chapters_found if c))
835
+
836
+ card = f"""---
837
+ license: mit
838
+ task_categories:
839
+ - text-generation
840
+ - question-answering
841
+ language:
842
+ - en
843
+ tags:
844
+ - pdf2dataset
845
+ - ocr
846
+ - chunked
847
+ size_categories:
848
+ - {"1K<n<10K" if total >= 1000 else "n<1K"}
849
+ ---
850
+
851
+ # {repo_name.split("/")[-1]}
852
+
853
+ Dataset created with [PDF2Dataset](https://github.com/svngoku/PDF2Dataset) -- OCR + structure-aware chunking pipeline.
854
+
855
+ ## Dataset Summary
856
+
857
+ | Metric | Value |
858
+ |---|---|
859
+ | Total chunks | {total} |
860
+ | Avg chars/chunk | {stats.avg_char_count:.0f} |
861
+ | Avg images/chunk | {stats.avg_images_per_chunk:.2f} |
862
+ | Source files | {len(sources)} |
863
+ | Duplicates removed | {stats.duplicates_removed} |
864
+ | Quality filtered | {stats.quality_filtered} |
865
+
866
+ ## Schema
867
+
868
+ | Column | Type | Description |
869
+ |---|---|---|
870
+ | `chunk_id` | `string` | Unique identifier: `filename_chunk_N` |
871
+ | `text` | `string` | Raw markdown chunk with image refs |
872
+ | `text_clean` | `string` | Cleaned text without markdown formatting |
873
+ | `chapter` | `string` | H1 header active at chunk position |
874
+ | `section` | `string` | H2 header active at chunk position |
875
+ | `subsection` | `string` | H3+ header active at chunk position |
876
+ | `images` | `list[Image]` | Rendered images extracted from chunk (viewable in Dataset Viewer) |
877
+ | `image_refs` | `list[string]` | Image reference IDs in chunk text |
878
+ | `num_images` | `int` | Number of images in chunk |
879
+ | `has_images` | `bool` | Whether chunk contains images |
880
+ | `source_filename` | `string` | Original source file name |
881
+ | `start_index` | `int` | Character offset in source document |
882
+ | `char_count` | `int` | Character count of chunk text |
883
+
884
+ ## Source Files
885
+
886
+ | File | Chunks |
887
+ |---|---|
888
+ """
889
+ for fname, count in sources:
890
+ card += f"| `{fname}` | {count} |\n"
891
+
892
+ if unique_chapters:
893
+ card += "\n## Document Structure\n\n"
894
+ card += "Chapters found in the source documents:\n\n"
895
+ for ch in unique_chapters[:30]:
896
+ card += f"- {ch}\n"
897
+ if len(unique_chapters) > 30:
898
+ card += f"- ... and {len(unique_chapters) - 30} more\n"
899
+
900
+ card += """
901
+ ## Pipeline
902
+
903
+ This dataset was processed through the PDF2Dataset pipeline:
904
+
905
+ 1. **OCR** -- Mistral OCR extracts text and images from PDF/image files
906
+ 2. **Chunking** -- Structure-aware recursive splitting preserves document hierarchy
907
+ 3. **Validation** -- Schema validation ensures every chunk has required fields
908
+ 4. **Deduplication** -- Content-hash based dedup removes identical chunks
909
+ 5. **Quality Filtering** -- Removes empty, too-short, or malformed chunks
910
+ """
911
+ return card
912
+
913
+
914
+ def get_hf_token(explicit_token: str | None = None) -> str | None:
915
  """Retrieve Hugging Face token with fallback mechanisms."""
916
+ if explicit_token and explicit_token.strip() and explicit_token.startswith("hf_"):
 
 
917
  return explicit_token.strip()
918
+
 
 
 
919
  env_token = os.environ.get("HF_TOKEN")
920
+ if env_token and env_token.startswith("hf_"):
 
921
  return env_token
922
+
923
  try:
924
  stored_token = huggingface_hub.get_token()
925
  if stored_token:
 
926
  return stored_token
927
  except Exception as e:
928
  logger.warning(f"Could not retrieve token from Hugging Face config: {e}")
929
+
930
  return None
931
 
932
+
933
+ def process_files(
934
+ file_paths: list[str],
935
+ chunk_size: int,
936
+ hf_token: str,
937
+ repo_name: str,
938
+ append_mode: bool = False,
939
+ min_chunk_chars: int = 20,
940
+ min_words: int = 3,
941
  ) -> str:
942
+ """Orchestrates OCR, chunking, pipeline processing, and push to HF Hub.
943
+
944
+ Pipeline stages:
945
+ 1. OCR each file with Mistral
946
+ 2. Chunk markdown with structure-aware splitting
947
+ 3. Validate schema on every chunk
948
+ 4. Deduplicate by content hash
949
+ 5. Quality-filter (min chars, min words, empty, etc.)
950
+ 6. Compute statistics and generate report
951
+ 7. Push to HF Hub (overwrite or append)
952
+
953
+ Args:
954
+ file_paths: List of file paths to process.
955
+ chunk_size: Maximum character count per chunk.
956
+ hf_token: Explicit HF token (optional).
957
+ repo_name: HF dataset repository in 'username/dataset-name' format.
958
+ append_mode: If True, append to existing dataset instead of replacing.
959
+ min_chunk_chars: Minimum characters per chunk for quality filter.
960
+ min_words: Minimum words per chunk for quality filter.
961
+
962
+ Returns:
963
+ Status message string with pipeline report.
964
+ """
965
+ if not file_paths:
966
  return "Error: No files uploaded."
967
+
968
+ if not repo_name or "/" not in repo_name:
 
 
969
  return "Error: Invalid repository name (use 'username/dataset-name')."
970
 
971
+ chunk_size = max(0, chunk_size)
 
 
 
 
 
972
 
973
  effective_hf_token = get_hf_token(hf_token)
974
  if not effective_hf_token:
975
+ return (
976
+ "Error: No valid Hugging Face token found.\n"
977
+ "Please either:\n"
978
+ "1. Provide a token in the input field (starts with 'hf_')\n"
979
+ "2. Set HF_TOKEN environment variable\n"
980
+ "3. Run `huggingface-cli login` in your terminal"
981
+ )
982
 
983
  try:
984
+ # Initialize pipeline with quality config
985
+ quality_cfg = QualityConfig(
986
+ min_char_count=min_chunk_chars,
987
+ min_word_count=min_words,
988
+ )
989
+ builder = DatasetBuilder(quality_config=quality_cfg)
990
+
991
  files_processed = 0
992
  error_messages = []
993
 
994
+ for file_idx, file_path in enumerate(file_paths, 1):
995
+ source_filename = os.path.basename(file_path)
996
+ logger.info(
997
+ f"--- Processing file {file_idx}/{len(file_paths)}: {source_filename} ---"
998
+ )
999
 
1000
+ try:
1001
+ processed_markdown, raw_markdown, img_map = perform_ocr(file_path)
1002
+ except OCRError as e:
1003
+ error_messages.append(f"File '{source_filename}': {e}")
1004
+ logger.error(f"Failed to process file {source_filename}: {e}")
1005
  continue
1006
 
1007
+ chunks = chunk_markdown(processed_markdown, chunk_size)
1008
  if not chunks:
1009
+ error_messages.append(
1010
+ f"File '{source_filename}': Failed to chunk the document."
1011
+ )
1012
  logger.error(f"Failed to chunk file {source_filename}")
1013
  continue
1014
 
1015
+ # Assign chunk_id before adding to pipeline
1016
+ for i, chunk in enumerate(chunks):
1017
+ chunk["chunk_id"] = f"{source_filename}_chunk_{i}"
1018
+
1019
+ builder.add_chunks(chunks, source_filename)
1020
  files_processed += 1
1021
+ logger.info(
1022
+ f"File {source_filename}: queued {len(chunks)} chunks for pipeline"
1023
+ )
1024
 
1025
+ if files_processed == 0:
1026
+ return "Error: No files were processed successfully.\n" + "\n".join(
1027
+ error_messages
1028
+ )
1029
 
1030
+ # Run the pipeline
1031
+ all_data, stats = builder.build()
 
 
 
 
 
 
1032
 
1033
+ if not all_data or not all_data.get("chunk_id"):
1034
+ return (
1035
+ "Error: All chunks were filtered out by the pipeline.\n"
1036
+ + stats.summary()
1037
+ + (
1038
+ "\n\nOCR Errors:\n" + "\n".join(error_messages)
1039
+ if error_messages
1040
+ else ""
1041
+ )
1042
+ )
1043
 
1044
+ # Push to Hub
1045
+ push_result = DatasetBuilder.push(
1046
+ all_data=all_data,
1047
+ repo_name=repo_name,
1048
+ hf_token=effective_hf_token,
1049
+ stats=stats,
1050
+ append=append_mode,
1051
+ )
1052
+
1053
+ # Build final report
1054
+ report_parts = [push_result, "", stats.summary()]
1055
  if error_messages:
1056
+ report_parts.append(f"\nOCR Errors ({len(error_messages)}):")
1057
+ report_parts.extend(f" - {e}" for e in error_messages)
1058
+
1059
+ return "\n".join(report_parts)
1060
 
1061
  except huggingface_hub.utils.HfHubHTTPError as hf_http_err:
1062
+ status = getattr(hf_http_err.response, "status_code", "Unknown")
1063
  if status == 401:
1064
  return "Error: Invalid or unauthorized Hugging Face token."
1065
  elif status == 403:
 
1067
  return f"Error: Hugging Face Hub Error (Status {status}): {hf_http_err}"
1068
  except Exception as e:
1069
  logger.error(f"Unexpected error: {e}", exc_info=True)
1070
+ return f"Unexpected error: {e}"
1071
+
1072
+
1073
+ # --- Preview ---
1074
+
1075
+
1076
+ def render_preview(file_objs) -> list[Image.Image]:
1077
+ """Render uploaded files as preview images.
1078
+
1079
+ PDFs are rendered page-by-page using PyMuPDF. Images are returned directly.
1080
+ """
1081
+ if not file_objs:
1082
+ return []
1083
+ if not isinstance(file_objs, list):
1084
+ file_objs = [file_objs]
1085
+
1086
+ images = []
1087
+ for file_obj in file_objs:
1088
+ file_path = file_obj.name if hasattr(file_obj, "name") else str(file_obj)
1089
+ ext = os.path.splitext(file_path)[1].lower()
1090
+
1091
+ if ext == ".pdf":
1092
+ try:
1093
+ doc = fitz.open(file_path)
1094
+ for page in doc:
1095
+ pix = page.get_pixmap(dpi=150)
1096
+ img = Image.open(io.BytesIO(pix.tobytes("png")))
1097
+ images.append(img)
1098
+ doc.close()
1099
+ except Exception as e:
1100
+ logger.error(f"Failed to render PDF preview: {e}")
1101
+ elif ext in {".png", ".jpg", ".jpeg", ".webp", ".bmp"}:
1102
+ try:
1103
+ images.append(Image.open(file_path))
1104
+ except Exception as e:
1105
+ logger.error(f"Failed to open image preview: {e}")
1106
+
1107
+ return images
1108
+
1109
 
1110
  # --- Gradio Interface ---
1111
+
1112
+
1113
+ MISTRAL_CSS = """
1114
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700;800&display=swap');
1115
+
1116
+ :root {
1117
+ --mistral-bg: #FFFAEB;
1118
+ --mistral-bg-grid: #E9E2CB;
1119
+ --mistral-panel: #FFFAEB;
1120
+ --mistral-panel-warm: #FFF0C3;
1121
+ --mistral-border: #E9E2CB;
1122
+ --mistral-text: #1E1E1E;
1123
+ --mistral-muted: #444444;
1124
+ --mistral-soft-muted: #766B54;
1125
+ --mistral-accent: #FF8205;
1126
+ --mistral-accent-hover: #E67200;
1127
+ --mistral-shadow: rgba(30, 30, 30, 0.08);
1128
+ --mistral-grid-opacity: 0.05;
1129
+ }
1130
+
1131
+ html,
1132
+ body,
1133
+ gradio-app,
1134
+ .gradio-container,
1135
+ .app,
1136
+ main {
1137
+ background-color: var(--mistral-bg) !important;
1138
+ background-image:
1139
+ linear-gradient(var(--mistral-bg-grid) 1px, transparent 1px),
1140
+ linear-gradient(90deg, var(--mistral-bg-grid) 1px, transparent 1px) !important;
1141
+ background-size: 40px 40px !important;
1142
+ color: var(--mistral-text) !important;
1143
+ font-family: 'Inter', sans-serif !important;
1144
+ }
1145
+
1146
+ html,
1147
+ body,
1148
+ gradio-app {
1149
+ min-height: 100% !important;
1150
+ width: 100% !important;
1151
+ }
1152
+
1153
+ .gradio-container {
1154
+ box-sizing: border-box !important;
1155
+ margin: 0 auto !important;
1156
+ max-width: 1440px !important;
1157
+ padding: clamp(1rem, 2.5vw, 2rem) !important;
1158
+ width: 100% !important;
1159
+ }
1160
+
1161
+ .app,
1162
+ main {
1163
+ min-height: 100vh !important;
1164
+ max-width: 100% !important;
1165
+ width: 100% !important;
1166
+ }
1167
+
1168
+ footer {
1169
+ display: none !important;
1170
+ }
1171
+
1172
+ #app-shell {
1173
+ gap: 1rem !important;
1174
+ }
1175
+
1176
+ #brand-hero {
1177
+ background: linear-gradient(135deg, var(--mistral-panel) 0%, var(--mistral-panel-warm) 100%) !important;
1178
+ border: 2px solid var(--mistral-border) !important;
1179
+ border-top: 5px solid var(--mistral-accent) !important;
1180
+ box-shadow: 0 8px 32px var(--mistral-shadow) !important;
1181
+ padding: clamp(1.25rem, 3vw, 2rem) !important;
1182
+ }
1183
+
1184
+ #brand-hero h1 {
1185
+ color: var(--mistral-text) !important;
1186
+ font-size: clamp(2rem, 4vw, 4.5rem) !important;
1187
+ font-weight: 800 !important;
1188
+ line-height: 0.95 !important;
1189
+ letter-spacing: 0 !important;
1190
+ margin: 0 0 0.75rem !important;
1191
+ }
1192
+
1193
+ #brand-hero p {
1194
+ color: var(--mistral-muted) !important;
1195
+ font-size: clamp(1rem, 1.6vw, 1.25rem) !important;
1196
+ line-height: 1.55 !important;
1197
+ margin: 0 !important;
1198
+ max-width: 62rem !important;
1199
+ }
1200
+
1201
+ #brand-hero strong {
1202
+ color: var(--mistral-text) !important;
1203
+ font-weight: 700 !important;
1204
+ }
1205
+
1206
+ .mistral-panel {
1207
+ background: var(--mistral-panel) !important;
1208
+ border: 2px solid var(--mistral-border) !important;
1209
+ box-shadow: 0 8px 32px var(--mistral-shadow) !important;
1210
+ padding: clamp(1rem, 2vw, 1.5rem) !important;
1211
+ }
1212
+
1213
+ .mistral-panel .markdown h3,
1214
+ .mistral-section h3 {
1215
+ color: var(--mistral-text) !important;
1216
+ font-size: 0.82rem !important;
1217
+ font-weight: 800 !important;
1218
+ letter-spacing: 0.08em !important;
1219
+ margin: 0.35rem 0 0.85rem !important;
1220
+ text-transform: uppercase !important;
1221
+ }
1222
+
1223
+ .mistral-section {
1224
+ border-top: 1px solid var(--mistral-border) !important;
1225
+ margin-top: 1rem !important;
1226
+ padding-top: 1rem !important;
1227
+ }
1228
+
1229
+ .mistral-panel label,
1230
+ .mistral-panel .wrap label,
1231
+ .mistral-panel span {
1232
+ color: var(--mistral-text) !important;
1233
+ font-family: 'Inter', sans-serif !important;
1234
+ }
1235
+
1236
+ .mistral-panel input,
1237
+ .mistral-panel textarea,
1238
+ .mistral-panel select {
1239
+ background: #FFF8E3 !important;
1240
+ border-color: var(--mistral-border) !important;
1241
+ color: var(--mistral-text) !important;
1242
+ font-family: 'Inter', sans-serif !important;
1243
+ }
1244
+
1245
+ .mistral-panel input:focus,
1246
+ .mistral-panel textarea:focus {
1247
+ border-color: var(--mistral-accent) !important;
1248
+ box-shadow: 0 0 0 2px rgba(255, 130, 5, 0.18) !important;
1249
+ }
1250
+
1251
+ #process-button {
1252
+ background: var(--mistral-accent) !important;
1253
+ border: 0 !important;
1254
+ border-radius: 0 !important;
1255
+ color: #FFFFFF !important;
1256
+ font-weight: 800 !important;
1257
+ letter-spacing: 0.06em !important;
1258
+ min-height: 3rem !important;
1259
+ text-transform: uppercase !important;
1260
+ }
1261
+
1262
+ #process-button:hover {
1263
+ background: var(--mistral-accent-hover) !important;
1264
+ }
1265
+
1266
+ .mistral-panel .gr-group,
1267
+ .mistral-panel .styler {
1268
+ background: transparent !important;
1269
+ border-color: var(--mistral-border) !important;
1270
+ }
1271
+
1272
+ .mistral-panel button:not(#process-button):not(.reset-button):not(.center) {
1273
+ background: #FFF8E3 !important;
1274
+ border: 1px solid var(--mistral-border) !important;
1275
+ color: var(--mistral-text) !important;
1276
+ font-weight: 700 !important;
1277
+ letter-spacing: 0 !important;
1278
+ text-transform: none !important;
1279
+ }
1280
+
1281
+ .mistral-panel button:not(#process-button):not(.reset-button):not(.center):hover {
1282
+ background: var(--mistral-panel-warm) !important;
1283
+ border-color: var(--mistral-accent) !important;
1284
+ }
1285
+
1286
+ .mistral-panel button.center.boundedheight {
1287
+ border: 2px dashed var(--mistral-border) !important;
1288
+ color: var(--mistral-muted) !important;
1289
+ min-height: 12rem !important;
1290
+ }
1291
+
1292
+ .mistral-panel button.center.boundedheight svg {
1293
+ color: var(--mistral-accent) !important;
1294
+ }
1295
+
1296
+ .mistral-panel button.reset-button {
1297
+ background: transparent !important;
1298
+ border: 0 !important;
1299
+ color: var(--mistral-soft-muted) !important;
1300
+ min-height: auto !important;
1301
+ }
1302
+
1303
+ #pipeline-report textarea {
1304
+ background-color: var(--mistral-panel) !important;
1305
+ background-image:
1306
+ linear-gradient(rgba(0, 0, 0, var(--mistral-grid-opacity)) 1px, transparent 1px),
1307
+ linear-gradient(90deg, rgba(0, 0, 0, var(--mistral-grid-opacity)) 1px, transparent 1px) !important;
1308
+ background-size: 20px 20px !important;
1309
+ color: var(--mistral-text) !important;
1310
+ font-family: 'JetBrains Mono', monospace !important;
1311
+ font-size: 0.95rem !important;
1312
+ line-height: 1.7 !important;
1313
+ }
1314
+
1315
+ #document-preview {
1316
+ background: var(--mistral-panel) !important;
1317
+ border: 2px solid var(--mistral-border) !important;
1318
+ box-shadow: 0 8px 32px var(--mistral-shadow) !important;
1319
+ padding: clamp(1rem, 2vw, 1.5rem) !important;
1320
+ }
1321
+
1322
+ #document-preview h3 {
1323
+ color: var(--mistral-text) !important;
1324
+ font-size: 0.82rem !important;
1325
+ font-weight: 800 !important;
1326
+ letter-spacing: 0.08em !important;
1327
+ text-transform: uppercase !important;
1328
+ }
1329
+
1330
+ #document-preview .grid-wrap,
1331
+ #document-preview .thumbnail-item {
1332
+ background: #FFF8E3 !important;
1333
+ }
1334
+
1335
+ .mistral-note {
1336
+ color: var(--mistral-soft-muted) !important;
1337
+ font-size: 0.9rem !important;
1338
+ margin-top: 0.5rem !important;
1339
+ }
1340
+ """
1341
+
1342
+
1343
+ MISTRAL_THEME = gr.themes.Soft(primary_hue="orange", secondary_hue="yellow")
1344
+ GRADIO_BLOCKS_KWARGS = {"title": "PDF2Dataset -- Mistral OCR Pipeline"}
1345
+ GRADIO_LAUNCH_KWARGS = {}
1346
+
1347
+ try:
1348
+ _GRADIO_MAJOR_VERSION = int(gr.__version__.split(".", 1)[0])
1349
+ except (AttributeError, ValueError):
1350
+ _GRADIO_MAJOR_VERSION = 5
1351
+
1352
+ if _GRADIO_MAJOR_VERSION >= 6:
1353
+ GRADIO_LAUNCH_KWARGS.update(theme=MISTRAL_THEME, css=MISTRAL_CSS)
1354
+ else:
1355
+ GRADIO_BLOCKS_KWARGS.update(theme=MISTRAL_THEME, css=MISTRAL_CSS)
1356
+
1357
+
1358
+ def _gradio_process(
1359
+ file_objs, chunk_size, hf_token, repo_name, append_mode, min_chars, min_words
1360
+ ):
1361
+ """Bridge between Gradio file objects and core processing logic."""
1362
+ if not file_objs:
1363
+ return "Error: No files uploaded."
1364
+ if not isinstance(file_objs, list):
1365
+ file_objs = [file_objs]
1366
+ file_paths = [f.name if hasattr(f, "name") else str(f) for f in file_objs]
1367
+ return process_files(
1368
+ file_paths,
1369
+ chunk_size,
1370
+ hf_token,
1371
+ repo_name,
1372
+ append_mode=append_mode,
1373
+ min_chunk_chars=min_chars,
1374
+ min_words=min_words,
1375
+ )
1376
+
1377
+
1378
+ with gr.Blocks(**GRADIO_BLOCKS_KWARGS) as demo:
1379
  gr.Markdown(
1380
  """
1381
+ # PDF2Dataset
1382
+
1383
+ Convert PDFs and images into clean Hugging Face datasets with **Mistral OCR**,
1384
+ structure-aware chunking, validation, deduplication, and quality filtering.
1385
+ """,
1386
+ elem_id="brand-hero",
 
1387
  )
1388
 
1389
+ with gr.Column(elem_id="app-shell"):
1390
+ with gr.Row():
1391
+ with gr.Column(scale=1, elem_classes=["mistral-panel"]):
1392
+ file_input = gr.File(
1393
+ label="Source documents",
1394
+ file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp", ".bmp"],
1395
+ type="filepath",
1396
+ file_count="multiple",
1397
+ )
1398
+
1399
+ with gr.Group(elem_classes=["mistral-section"]):
1400
+ gr.Markdown("### Chunking")
1401
+ chunk_size = gr.Slider(
1402
+ minimum=0,
1403
+ maximum=4096,
1404
+ value=512,
1405
+ step=64,
1406
+ label="Max chunk size",
1407
+ info="Character budget per chunk. Use 0 to keep each document as one chunk.",
1408
+ )
1409
+
1410
+ with gr.Group(elem_classes=["mistral-section"]):
1411
+ gr.Markdown("### Quality filters")
1412
+ with gr.Row():
1413
+ min_chars = gr.Slider(
1414
+ minimum=0,
1415
+ maximum=500,
1416
+ value=20,
1417
+ step=5,
1418
+ label="Minimum characters",
1419
+ )
1420
+ min_words = gr.Slider(
1421
+ minimum=0,
1422
+ maximum=50,
1423
+ value=3,
1424
+ step=1,
1425
+ label="Minimum words",
1426
+ )
1427
+
1428
+ with gr.Group(elem_classes=["mistral-section"]):
1429
+ gr.Markdown("### Hugging Face output")
1430
+ repo_name = gr.Textbox(
1431
+ label="Dataset repository",
1432
+ placeholder="your-username/your-dataset-name",
1433
+ )
1434
+ hf_token = gr.Textbox(
1435
+ label="Hugging Face token",
1436
+ type="password",
1437
+ placeholder="hf_... or set HF_TOKEN",
1438
+ )
1439
+ append_mode = gr.Checkbox(
1440
+ label="Append to existing dataset",
1441
+ value=False,
1442
+ )
1443
+
1444
+ submit_btn = gr.Button(
1445
+ "Process and push",
1446
+ variant="primary",
1447
+ elem_id="process-button",
1448
+ )
1449
+
1450
+ with gr.Column(scale=1, elem_classes=["mistral-panel"]):
1451
+ output = gr.Textbox(
1452
+ label="Pipeline report",
1453
+ lines=30,
1454
+ interactive=False,
1455
+ elem_id="pipeline-report",
1456
+ )
1457
+
1458
+ with gr.Group(elem_id="document-preview"):
1459
+ gr.Markdown("### Document preview")
1460
+ preview_gallery = gr.Gallery(
1461
+ label="Uploaded documents",
1462
+ columns=2,
1463
+ height="auto",
1464
+ object_fit="contain",
1465
  )
1466
+
1467
+ gr.Markdown(
1468
+ "*Requires `MISTRAL_API_KEY`, a Hugging Face token, or an active Hugging Face CLI login.*",
1469
+ elem_classes=["mistral-note"],
1470
+ )
1471
+
1472
+ file_input.change(
1473
+ fn=render_preview,
1474
+ inputs=[file_input],
1475
+ outputs=[preview_gallery],
1476
+ )
 
 
 
 
1477
 
1478
  submit_btn.click(
1479
+ fn=_gradio_process,
1480
+ inputs=[
1481
+ file_input,
1482
+ chunk_size,
1483
+ hf_token,
1484
+ repo_name,
1485
+ append_mode,
1486
+ min_chars,
1487
+ min_words,
1488
+ ],
1489
+ outputs=output,
1490
  )
1491
 
1492
  gr.Examples(
1493
  examples=[
1494
+ [None, 512, "", "hf-username/my-first-ocr-dataset", False, 20, 3],
1495
+ [None, 1024, "", "hf-username/large-chunk-ocr-data", True, 50, 5],
1496
+ [None, 0, "", "hf-username/no-split-ocr-data", False, 0, 0],
1497
+ ],
1498
+ inputs=[
1499
+ file_input,
1500
+ chunk_size,
1501
+ hf_token,
1502
+ repo_name,
1503
+ append_mode,
1504
+ min_chars,
1505
+ min_words,
1506
  ],
 
1507
  outputs=output,
1508
+ fn=_gradio_process,
1509
+ cache_examples=False,
1510
  )
 
 
1511
 
1512
+
1513
+ def main():
1514
+ """Entry point for the application."""
 
 
 
 
 
 
 
 
 
1515
  demo.launch(
1516
+ share=os.getenv("GRADIO_SHARE", "False").lower() == "true",
1517
  debug=True,
1518
+ **GRADIO_LAUNCH_KWARGS,
1519
  )
1520
+
1521
+
1522
+ if __name__ == "__main__":
1523
+ main()