fastdocparse 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,262 @@
1
+ """PDF utilities: render pages to images, base64 encoding, box coordinate transforms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from dataclasses import dataclass
7
+ from typing import List, Optional, Tuple
8
+
9
+ import pymupdf # PyMuPDF
10
+ from PIL import Image
11
+
12
+ # Rendering constants
13
+ PDF_RENDER_DPI = 150
14
+
15
+
16
+ def _safe_open_pdf(pdf_bytes: bytes) -> Optional["pymupdf.Document"]:
17
+ """pymupdf.open() raises pymupdf.FileDataError/EmptyFileError (both RuntimeError
18
+ subclasses, not ValueError) on corrupt or non-PDF bytes — a raw file upload gone
19
+ wrong is an expected, common failure mode here, not a programming error, so it
20
+ shouldn't propagate as an unhandled crash. Returns None on failure; callers treat
21
+ that the same as "no text found," which the rest of the pipeline already handles."""
22
+ try:
23
+ return pymupdf.open(stream=pdf_bytes, filetype="pdf")
24
+ except Exception:
25
+ return None
26
+
27
+
28
+ @dataclass
29
+ class PageImage:
30
+ """A rendered PDF page."""
31
+ index: int # 0-based page index
32
+ png_bytes: bytes # PNG image bytes
33
+ width: int # image width in pixels
34
+ height: int # image height in pixels
35
+
36
+
37
+ def pdf_to_page_images(pdf_bytes: bytes, max_pages: int = 3, dpi: int = PDF_RENDER_DPI, max_dim: int = 1536) -> List[PageImage]:
38
+ """Render up to max_pages of a PDF to a PNG image at the given DPI.
39
+
40
+ Returns a list of PageImage in page order.
41
+ """
42
+ pages: List[PageImage] = []
43
+ doc = _safe_open_pdf(pdf_bytes)
44
+ if doc is None:
45
+ return pages
46
+ try:
47
+ zoom = dpi / 72.0 # 72 DPI is the PDF default
48
+ matrix = pymupdf.Matrix(zoom, zoom)
49
+ for i in range(min(len(doc), max_pages)):
50
+ page = doc[i]
51
+ pix = page.get_pixmap(matrix=matrix, alpha=False)
52
+
53
+ # Convert to PIL Image for resizing
54
+ img = Image.open(io.BytesIO(pix.tobytes("png")))
55
+
56
+ # Resize to max_dim for better OCR fidelity
57
+ if img.width > max_dim or img.height > max_dim:
58
+ ratio = min(max_dim / img.width, max_dim / img.height)
59
+ new_size = (int(img.width * ratio), int(img.height * ratio))
60
+ img = img.resize(new_size, Image.Resampling.LANCZOS)
61
+
62
+ # Convert back to bytes
63
+ buf = io.BytesIO()
64
+ img.save(buf, format="PNG")
65
+ png = buf.getvalue()
66
+
67
+ pages.append(
68
+ PageImage(index=i, png_bytes=png, width=img.width, height=img.height)
69
+ )
70
+ finally:
71
+ doc.close()
72
+ return pages
73
+
74
+
75
+ def image_bytes_to_page_image(img_bytes: bytes) -> PageImage:
76
+ """Convert image bytes (PNG/JPG) to a PageImage.
77
+
78
+ The image is loaded, resized for faster processing, and re-encoded as PNG.
79
+ """
80
+ img = Image.open(io.BytesIO(img_bytes))
81
+ # Convert to RGB if necessary (for JPEGs with transparency, etc.)
82
+ if img.mode != "RGB":
83
+ img = img.convert("RGB")
84
+
85
+ # Resize to max 1536x1536 for better OCR fidelity
86
+ max_dim = 1536
87
+ if img.width > max_dim or img.height > max_dim:
88
+ ratio = min(max_dim / img.width, max_dim / img.height)
89
+ new_size = (int(img.width * ratio), int(img.height * ratio))
90
+ img = img.resize(new_size, Image.Resampling.LANCZOS)
91
+
92
+ buf = io.BytesIO()
93
+ img.save(buf, format="PNG")
94
+ return PageImage(index=0, png_bytes=buf.getvalue(), width=img.width, height=img.height)
95
+
96
+
97
+ def _grid_to_markdown(grid: List[List[str]]) -> str:
98
+ """Convert a 2D table array into a clean Markdown table string."""
99
+ if not grid or not grid[0]:
100
+ return ""
101
+ header = [str(c or "").strip().replace("\n", " ") for c in grid[0]]
102
+ if not any(header):
103
+ return ""
104
+ lines = ["| " + " | ".join(header) + " |", "| " + " | ".join(["---"] * len(header)) + " |"]
105
+ for row in grid[1:]:
106
+ row_cells = [str(c or "").strip().replace("\n", " ") for c in row]
107
+ if not any(row_cells):
108
+ continue
109
+ while len(row_cells) < len(header):
110
+ row_cells.append("")
111
+ lines.append("| " + " | ".join(row_cells[:len(header)]) + " |")
112
+ return "\n".join(lines)
113
+
114
+
115
+ def _bbox_overlaps(b1: Tuple[float, float, float, float], b2: Tuple[float, float, float, float], thresh: float = 0.5) -> bool:
116
+ """Check if block b1 overlaps significantly with table bbox b2."""
117
+ x0 = max(b1[0], b2[0])
118
+ y0 = max(b1[1], b2[1])
119
+ x1 = min(b1[2], b2[2])
120
+ y1 = min(b1[3], b2[3])
121
+ if x1 <= x0 or y1 <= y0:
122
+ return False
123
+ inter_area = (x1 - x0) * (y1 - y0)
124
+ b1_area = (b1[2] - b1[0]) * (b1[3] - b1[1])
125
+ return (inter_area / b1_area) > thresh if b1_area > 0 else False
126
+
127
+
128
+ def extract_layout_markdown_from_pdf(pdf_bytes: bytes, max_pages: int = 15, structured_mode: bool = False) -> str:
129
+ """Phase 1 Engine: Extract digital PDF into layout-preserved Markdown text."""
130
+ doc = _safe_open_pdf(pdf_bytes)
131
+ pages_md: List[str] = []
132
+ if doc is None:
133
+ return ""
134
+ try:
135
+ for i in range(min(len(doc), max_pages)):
136
+ page = doc[i]
137
+
138
+ # 1. Extract tables
139
+ table_bboxes: List[Tuple[float, float, float, float]] = []
140
+ table_markdowns: List[Tuple[float, str]] = [] # (y0, markdown_str)
141
+ try:
142
+ tabs = page.find_tables()
143
+ for tab in tabs.tables:
144
+ grid = tab.extract()
145
+ md = _grid_to_markdown(grid)
146
+ if md:
147
+ table_bboxes.append(tab.bbox)
148
+ table_markdowns.append((tab.bbox[1], md))
149
+ except Exception:
150
+ pass
151
+
152
+ # 2. Extract text blocks outside tables
153
+ blocks = page.get_text("blocks")
154
+ non_table_blocks: List[Tuple[float, float, str]] = [] # (y0, x0, text)
155
+ for b in blocks:
156
+ if len(b) >= 5 and b[4].strip():
157
+ bbox = (b[0], b[1], b[2], b[3])
158
+ # Check overlap with any extracted table
159
+ if not any(_bbox_overlaps(bbox, tb) for tb in table_bboxes):
160
+ if structured_mode:
161
+ non_table_blocks.append((b[1], b[0], f"[X:{int(b[0])}] {b[4].strip()}"))
162
+ else:
163
+ non_table_blocks.append((b[1], b[0], b[4].strip()))
164
+
165
+ # 3. Merge blocks & tables spatially top-to-bottom
166
+ all_elements: List[Tuple[float, float, str]] = []
167
+ for y0, x0, text in non_table_blocks:
168
+ all_elements.append((y0, x0, text))
169
+ for y0, md in table_markdowns:
170
+ all_elements.append((y0, 0.0, f"\n{md}\n"))
171
+
172
+ # Sort by Y position (top-to-bottom) then X position
173
+ all_elements.sort(key=lambda elem: (round(elem[0] / 15), elem[1]))
174
+
175
+ page_content = "\n\n".join(text for _, _, text in all_elements)
176
+ if page_content.strip():
177
+ pages_md.append(f"--- PAGE {i + 1} ---\n{page_content}")
178
+ finally:
179
+ doc.close()
180
+ return "\n\n".join(pages_md)
181
+
182
+
183
+ def extract_text_from_pdf(pdf_bytes: bytes, max_pages: int = 15, structured_mode: bool = False) -> str:
184
+ """Extract layout-preserved markdown text from a digital PDF."""
185
+ layout_md = extract_layout_markdown_from_pdf(pdf_bytes, max_pages=max_pages, structured_mode=structured_mode)
186
+ if layout_md.strip():
187
+ return layout_md
188
+
189
+ # Fallback to plain text
190
+ doc = _safe_open_pdf(pdf_bytes)
191
+ texts: List[str] = []
192
+ if doc is None:
193
+ return ""
194
+ try:
195
+ for i in range(min(len(doc), max_pages)):
196
+ page_text = doc[i].get_text("text")
197
+ if page_text.strip():
198
+ texts.append(f"--- Page {i + 1} ---\n{page_text}")
199
+ finally:
200
+ doc.close()
201
+ return "\n\n".join(texts)
202
+
203
+
204
+ def is_digital_pdf(pdf_bytes: bytes, min_chars: int = 80) -> bool:
205
+ """Return True if the PDF has a readable text layer (i.e. is not a scan)."""
206
+ doc = _safe_open_pdf(pdf_bytes)
207
+ if doc is None:
208
+ return False
209
+ total_chars = 0
210
+ try:
211
+ for i in range(min(len(doc), 3)):
212
+ total_chars += len(doc[i].get_text("text").strip())
213
+ if total_chars >= min_chars:
214
+ return True
215
+ finally:
216
+ doc.close()
217
+ return False
218
+
219
+
220
+ def chunk_document_text(text: str, max_tokens: int = 3000) -> List[str]:
221
+ """
222
+ Split document text into chunks based on page delimiters.
223
+ Approximates tokens via character count (4 chars ~ 1 token).
224
+ """
225
+ max_chars = max_tokens * 4
226
+ pages = text.split("--- PAGE ")
227
+ # The first element might be empty or preamble before the first page.
228
+ # We need to re-attach the delimiter for the rest.
229
+ page_texts = []
230
+ for i, p in enumerate(pages):
231
+ if not p.strip():
232
+ continue
233
+ prefix = "--- PAGE " if i > 0 else ""
234
+ page_texts.append(prefix + p)
235
+
236
+ chunks = []
237
+ current_chunk = ""
238
+
239
+ for pt in page_texts:
240
+ if len(current_chunk) + len(pt) > max_chars and current_chunk:
241
+ chunks.append(current_chunk.strip())
242
+ current_chunk = pt
243
+ else:
244
+ current_chunk += "\n\n" + pt if current_chunk else pt
245
+
246
+ if current_chunk:
247
+ chunks.append(current_chunk.strip())
248
+
249
+ # A single page (or a document with no "--- PAGE" delimiters at all) can itself
250
+ # exceed max_chars — the loop above only splits *between* pages, so that chunk
251
+ # would otherwise sail past the configured budget by an arbitrary amount. Hard-split
252
+ # anything still oversized; a mid-sentence cut here is far better than silently
253
+ # blowing an LLM's context window.
254
+ final_chunks = []
255
+ for chunk in chunks:
256
+ if len(chunk) <= max_chars:
257
+ final_chunks.append(chunk)
258
+ else:
259
+ for start in range(0, len(chunk), max_chars):
260
+ final_chunks.append(chunk[start:start + max_chars])
261
+
262
+ return final_chunks
@@ -0,0 +1,86 @@
1
+ """Prompt compiler for generating dynamic Chain-of-Thought prompts from schemas."""
2
+
3
+ import json
4
+ from typing import Any, Dict
5
+ from .schema import Schema, Field
6
+
7
+ def _generate_json_structure(fields: list[Field]) -> Dict[str, Any]:
8
+ """Generate a sample JSON structure representing the schema."""
9
+ structure = {}
10
+ for f in fields:
11
+ if f.type == "list" and f.sub_fields:
12
+ structure[f.name] = [_generate_json_structure(f.sub_fields)]
13
+ else:
14
+ structure[f.name] = f"<{f.type}> or null"
15
+ return structure
16
+
17
+ def compile_prompt(schema: Schema) -> str:
18
+ """Compile a deterministic CoT prompt from a Pydantic schema."""
19
+
20
+ # Generate reasoning steps
21
+ reasoning_steps = []
22
+ for f in schema.fields:
23
+ if f.type == "list" and f.sub_fields:
24
+ sub_names = ", ".join([sub.name for sub in f.sub_fields])
25
+ reasoning_steps.append(f"• What are the items for '{f.name}'? Identify {sub_names}.")
26
+ else:
27
+ reasoning_steps.append(f"• What is the '{f.name}'? {f.description}")
28
+
29
+ # Generate extraction guide
30
+ extraction_guide = []
31
+ idx = 1
32
+ for f in schema.fields:
33
+ if f.type == "list" and f.sub_fields:
34
+ sub_desc = ", ".join([f"{sub.name}: {sub.description}" for sub in f.sub_fields])
35
+ extraction_guide.append(f"{idx}. {f.name}: List of items containing ({sub_desc}).")
36
+ else:
37
+ extraction_guide.append(f"{idx}. {f.name}: {f.description}")
38
+ idx += 1
39
+
40
+ json_structure = _generate_json_structure(schema.fields)
41
+ json_str = json.dumps(json_structure, indent=2)
42
+
43
+ examples_text = ""
44
+ json_step_num = 3
45
+ if schema.examples:
46
+ examples_text = "\n═══════════════════════════════════════════════\nSTEP 3 — EXAMPLES\n═══════════════════════════════════════════════\n"
47
+ for i, (doc_snippet, expected_json) in enumerate(schema.examples, 1):
48
+ examples_text += f"Example {i}:\nDocument Snippet:\n{doc_snippet}\n\nExpected Output:\n```json\n{json.dumps(expected_json, indent=2)}\n```\n\n"
49
+ json_step_num = 4
50
+
51
+
52
+ prompt = f"""You are an expert document analyst.
53
+
54
+ Below is text extracted directly from a document (via layout-aware text parsing or OCR).
55
+ Analyze the document step by step and extract the requested fields for schema: {schema.name}.
56
+
57
+ ═══════════════════════════════════════════════
58
+ STEP 1 — DOCUMENT REASONING (think out loud)
59
+ ═══════════════════════════════════════════════
60
+ {chr(10).join(reasoning_steps)}
61
+
62
+ ═══════════════════════════════════════════════
63
+ STEP 2 — FIELD EXTRACTION GUIDE
64
+ ═══════════════════════════════════════════════
65
+ {chr(10).join(extraction_guide)}
66
+
67
+ {examples_text}═══════════════════════════════════════════════
68
+ STEP {json_step_num} — JSON OUTPUT
69
+ ═══════════════════════════════════════════════
70
+ Output ONLY a valid JSON object in this exact structure:
71
+
72
+ ```json
73
+ {json_str}
74
+ ```
75
+
76
+ RULES:
77
+ - For addresses or multiline text: NEVER leave null if text is visible in the document. Combine all lines into a single string.
78
+ - Numbers only for numeric fields. No symbols/commas.
79
+ - If a field is missing, set to null.
80
+
81
+ ══════════════════════
82
+ DOCUMENT TEXT:
83
+ ══════════════════════
84
+ {{document_text}}
85
+ """
86
+ return prompt
docextract/py.typed ADDED
File without changes
docextract/result.py ADDED
@@ -0,0 +1,42 @@
1
+ """Typed view of DocumentParser.extract()'s output.
2
+
3
+ extract() itself keeps returning a plain dict — that's what the CLI needs for
4
+ zero-friction json.dumps(), and changing it would break every existing caller and
5
+ test. This model is an opt-in convenience for callers who want attribute access
6
+ and validation instead of raw dict indexing:
7
+
8
+ result = parser.extract(document_bytes, schema)
9
+ typed = ExtractionResult.from_raw(result)
10
+ typed.fields["invoice_number"].value
11
+ """
12
+
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from pydantic import BaseModel
16
+
17
+
18
+ class FieldResult(BaseModel):
19
+ value: Any
20
+ confidence: str
21
+ flags: List[str]
22
+
23
+
24
+ class ExtractionMeta(BaseModel):
25
+ truncated: bool
26
+ truncation_reason: Optional[str] = None
27
+
28
+
29
+ class ExtractionResult(BaseModel):
30
+ meta: ExtractionMeta
31
+ fields: Dict[str, FieldResult]
32
+
33
+ @classmethod
34
+ def from_raw(cls, raw: Dict[str, Any]) -> "ExtractionResult":
35
+ """Build a typed result from the dict returned by DocumentParser.extract()."""
36
+ raw = dict(raw)
37
+ meta = raw.pop("_meta", None)
38
+ if meta is None:
39
+ raise ValueError(
40
+ "Missing '_meta' key — expected the dict returned by DocumentParser.extract()."
41
+ )
42
+ return cls(meta=ExtractionMeta(**meta), fields={k: FieldResult(**v) for k, v in raw.items()})
docextract/schema.py ADDED
@@ -0,0 +1,94 @@
1
+ """Schema definitions for document extraction."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import List, Optional, Tuple, Dict, Any, Union
6
+ from pydantic import BaseModel, field_validator
7
+
8
+
9
+ class Field(BaseModel):
10
+ """Defines a single field to extract from a document."""
11
+ name: str
12
+ description: str
13
+ type: str = "text" # "text", "number", "date", "currency", "list"
14
+ required: bool = False
15
+ pattern: Optional[str] = None # regex the extracted string value must fully match, e.g. HS/container codes
16
+ enum: Optional[List[str]] = None # allowed values, e.g. shipment status, incoterms
17
+ sub_fields: Optional[List['Field']] = None
18
+
19
+ @property
20
+ def is_numeric(self) -> bool:
21
+ """True for field types that should be grounded/merged by parsed numeric value
22
+ rather than exact string match (see grounding.check_substring's numeric= flag)."""
23
+ return self.type in ("number", "currency")
24
+
25
+ @property
26
+ def is_date(self) -> bool:
27
+ """True for date fields — grounded by parsed calendar date rather than exact
28
+ string match (see grounding.check_substring's date= flag), since a normalized
29
+ "2021-04-22" won't literally appear in a source document that says "22Apr2021"."""
30
+ return self.type == "date"
31
+
32
+
33
+ class Schema(BaseModel):
34
+ """Defines a complete document schema."""
35
+ name: str = "DocumentSchema"
36
+ fields: List[Field]
37
+ examples: Optional[List[Tuple[str, Dict[str, Any]]]] = None
38
+
39
+ @field_validator('fields')
40
+ @classmethod
41
+ def validate_field_names(cls, v):
42
+ if any(f.name == "_meta" for f in v):
43
+ raise ValueError(
44
+ "'_meta' is a reserved field name (used for truncation metadata in the "
45
+ "extraction result) and cannot be used as a schema field name."
46
+ )
47
+ return v
48
+
49
+ @field_validator('examples')
50
+ @classmethod
51
+ def validate_examples(cls, v):
52
+ if v is not None:
53
+ for ex in v:
54
+ if not isinstance(ex, tuple) or len(ex) != 2:
55
+ raise ValueError("Each example must be a tuple of (document_snippet, expected_json)")
56
+ if not isinstance(ex[0], str) or not isinstance(ex[1], dict):
57
+ raise ValueError("Each example must be a tuple of (str, dict)")
58
+ return v
59
+
60
+ @classmethod
61
+ def from_dict(cls, data: Dict[str, Any]) -> "Schema":
62
+ """Build a Schema from a plain dict (as loaded from JSON/YAML) — no Python code needed."""
63
+ return cls.model_validate(data)
64
+
65
+ @classmethod
66
+ def from_json(cls, path: Union[str, Path]) -> "Schema":
67
+ """Load a Schema from a .json file."""
68
+ with open(path, "r") as f:
69
+ return cls.from_dict(json.load(f))
70
+
71
+ @classmethod
72
+ def from_yaml(cls, path: Union[str, Path]) -> "Schema":
73
+ """Load a Schema from a .yaml/.yml file."""
74
+ import yaml # optional dependency; only needed for this path
75
+
76
+ with open(path, "r") as f:
77
+ return cls.from_dict(yaml.safe_load(f))
78
+
79
+ @classmethod
80
+ def from_file(cls, path: Union[str, Path]) -> "Schema":
81
+ """Load a Schema from a .json or .yaml/.yml file, dispatching on extension."""
82
+ path = Path(path)
83
+ suffix = path.suffix.lower()
84
+ if suffix in (".yaml", ".yml"):
85
+ return cls.from_yaml(path)
86
+ if suffix == ".json":
87
+ return cls.from_json(path)
88
+ raise ValueError(f"Unsupported schema file extension {suffix!r} (expected .json, .yaml, or .yml)")
89
+
90
+ def get_field(self, name: str) -> Optional[Field]:
91
+ for f in self.fields:
92
+ if f.name == name:
93
+ return f
94
+ return None
@@ -0,0 +1,68 @@
1
+ """Compile a plain-English description of what to extract into a Schema, using the LLM.
2
+
3
+ Lets a non-technical user describe fields in their own words instead of writing JSON:
4
+
5
+ "I want the bill of lading number (starts with BL-), the container number,
6
+ the shipment status which is one of in_transit, delivered, or customs_hold,
7
+ and the destination country. Bill of lading and destination are required."
8
+
9
+ The LLM proposes field names/types/constraints; the caller should review the
10
+ result (or the saved schema file) before relying on it for real extraction —
11
+ a hallucinated schema silently corrupts every later run, so this is meant as
12
+ an authoring aid with a human in the loop, not a fully implicit step.
13
+ """
14
+
15
+ from typing import Any, Dict
16
+
17
+ from .json_repair import parse_json_from_llm
18
+ from .llm_client import LLMClient
19
+ from .schema import Schema
20
+
21
+ SCHEMA_GEN_PROMPT = """You are a data schema designer. A user described, in plain language, what \
22
+ information they want extracted from a document. Convert their description into a JSON schema.
23
+
24
+ Output ONLY a valid JSON object with this exact shape:
25
+ {{
26
+ "name": "<short PascalCase schema name>",
27
+ "fields": [
28
+ {{
29
+ "name": "<snake_case field name>",
30
+ "description": "<clear description of what to extract, written for a document-extraction assistant to follow>",
31
+ "type": "text" | "number" | "date" | "currency" | "list",
32
+ "required": true | false,
33
+ "pattern": "<regex the value must fully match, or null if the user didn't mention a specific format>",
34
+ "enum": ["<allowed value>", "..."] or null,
35
+ "sub_fields": [ {{ "name": "...", "description": "..." }} ]
36
+ }}
37
+ ]
38
+ }}
39
+
40
+ Rules:
41
+ - Use "list" + "sub_fields" only for repeating/tabular data (e.g. line items, containers). Every other field omits "sub_fields" (null).
42
+ - Set "required": true only if the description implies the field is essential/mandatory.
43
+ - Set "pattern" only when the description mentions a specific code/ID format (e.g. "starts with BL-", "6 digits"). Otherwise null.
44
+ - Set "enum" only when the description lists a fixed set of allowed values. Otherwise null.
45
+ - Field names must be snake_case, no spaces, no punctuation.
46
+ - Do not invent fields the user didn't ask for.
47
+
48
+ User's description:
49
+ \"\"\"
50
+ {description}
51
+ \"\"\"
52
+
53
+ Output ONLY the JSON object, nothing else."""
54
+
55
+
56
+ def compile_schema_from_description(description: str, client: LLMClient) -> Schema:
57
+ """Turn a natural-language description into a Schema via one LLM call."""
58
+ prompt = SCHEMA_GEN_PROMPT.format(description=description)
59
+ raw_response = client.complete(prompt)
60
+ data: Dict[str, Any] = parse_json_from_llm(raw_response)
61
+
62
+ if not data or not data.get("fields"):
63
+ raise ValueError(
64
+ "Could not turn that description into a schema. Try listing the specific fields you want, "
65
+ "e.g. 'invoice number, total price, and a list of line items with product name and quantity'."
66
+ )
67
+
68
+ return Schema.from_dict(data)
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "Invoice",
3
+ "fields": [
4
+ {
5
+ "name": "invoice_number",
6
+ "description": "Invoice No / Bill No / Reference number.",
7
+ "required": true
8
+ },
9
+ {
10
+ "name": "invoice_date",
11
+ "description": "YYYY-MM-DD format.",
12
+ "type": "date"
13
+ },
14
+ {
15
+ "name": "exporter_name",
16
+ "description": "Full name of the seller/exporter/supplier issuing the invoice. Look for company name at the top left, letterhead, or footer signature block."
17
+ },
18
+ {
19
+ "name": "exporter_address",
20
+ "description": "Complete address of the exporter (combine all address lines with comma separators)."
21
+ },
22
+ {
23
+ "name": "importer_name",
24
+ "description": "Full name of the buyer/consignee/importer. Look for 'Consignee', 'Bill To', 'Buyer', 'Importer', 'Ship To'."
25
+ },
26
+ {
27
+ "name": "importer_address",
28
+ "description": "Complete address of the importer/consignee (combine all address lines)."
29
+ },
30
+ {
31
+ "name": "currency",
32
+ "description": "3-letter ISO code (USD, EUR, GBP, INR, etc.).",
33
+ "enum": ["USD", "EUR", "GBP", "INR", "JPY", "CNY"]
34
+ },
35
+ {
36
+ "name": "total_price",
37
+ "description": "Final grand total invoice amount due (numeric only).",
38
+ "type": "number",
39
+ "required": true
40
+ },
41
+ {
42
+ "name": "line_items",
43
+ "description": "Every product row.",
44
+ "type": "list",
45
+ "sub_fields": [
46
+ { "name": "product_name", "description": "Name of the product" },
47
+ { "name": "product_description", "description": "Description of the product" },
48
+ { "name": "hs_code", "description": "HSN/HS code" },
49
+ { "name": "unit_price", "description": "Price per unit" },
50
+ { "name": "quantity", "description": "Quantity" }
51
+ ]
52
+ }
53
+ ],
54
+ "examples": [
55
+ [
56
+ "GLOBAL EXPORTS INC.\n123 Market St, San Francisco, CA 94105, USA\n\nBILL TO:\nAcme Corp\n456 Industrial Way, Seattle, WA 98101\n\nInvoice: INV-9011\nDate: 04/15/2023\n\nItem\tQty\tPrice\tTotal\nWidget A\t10\t$5.00\t$50.00\nSuper Widget\t2\t$25.00\t$50.00\n\nGrand Total: USD 100.00",
57
+ {
58
+ "invoice_number": "INV-9011",
59
+ "invoice_date": "2023-04-15",
60
+ "exporter_name": "GLOBAL EXPORTS INC.",
61
+ "exporter_address": "123 Market St, San Francisco, CA 94105, USA",
62
+ "importer_name": "Acme Corp",
63
+ "importer_address": "456 Industrial Way, Seattle, WA 98101",
64
+ "currency": "USD",
65
+ "total_price": 100.0,
66
+ "line_items": [
67
+ { "product_name": "Widget A", "product_description": "Widget A", "hs_code": null, "unit_price": 5.0, "quantity": 10 },
68
+ { "product_name": "Super Widget", "product_description": "Super Widget", "hs_code": null, "unit_price": 25.0, "quantity": 2 }
69
+ ]
70
+ }
71
+ ]
72
+ ]
73
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "ShipmentManifest",
3
+ "fields": [
4
+ {
5
+ "name": "bill_of_lading",
6
+ "description": "Bill of Lading (B/L) number identifying the shipment.",
7
+ "required": true,
8
+ "pattern": "BL-\\d{4,}"
9
+ },
10
+ {
11
+ "name": "container_number",
12
+ "description": "Shipping container number, ISO 6346 format (4 letters + 7 digits).",
13
+ "pattern": "[A-Z]{4}\\d{7}"
14
+ },
15
+ {
16
+ "name": "hs_code",
17
+ "description": "Harmonized System code for the primary goods.",
18
+ "pattern": "\\d{6,10}"
19
+ },
20
+ {
21
+ "name": "shipment_status",
22
+ "description": "Current status of the shipment.",
23
+ "enum": ["in_transit", "delivered", "customs_hold", "delayed"]
24
+ },
25
+ {
26
+ "name": "origin_port",
27
+ "description": "Port or place of loading."
28
+ },
29
+ {
30
+ "name": "destination_country",
31
+ "description": "Country of final destination.",
32
+ "required": true
33
+ },
34
+ {
35
+ "name": "gross_weight_kg",
36
+ "description": "Total gross weight in kilograms (numeric only).",
37
+ "type": "number"
38
+ }
39
+ ]
40
+ }