File size: 8,155 Bytes
5840d20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15028da
 
 
 
5840d20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2cb1336
 
 
 
 
 
 
 
 
 
 
 
 
5840d20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
"""Data shapes handed between pipeline stages β€” the parsing/extraction seam.

Extraction consumes `ParsedDocument`, never a file path and never the parser's
API. That is what keeps the parser swappable: a Tesseract or Azure Document
Intelligence path emits the same artifact, and the extraction half never learns
which one ran.

Design note β€” these fields were derived from real MinerU output, not designed on
paper. Verified against actual `content_list.json` output and cross-checked
against MinerU's source for both the `pipeline` and `vlm` backends.

Two things worth knowing before reviewing:

1. `bbox` and `text_level` are emitted by **both** backends β€” verified in
   MinerU's source, where `pipeline` and `vlm` run identical logic:

       elif para_type == BlockType.TITLE:
           title_level = get_title_level(para_block)
           if title_level != 0:
               para_content['text_level'] = title_level

   So heading structure is read from the document when MinerU detects titles,
   and only derived from numbering patterns as a fallback.

2. Heading availability is **document-dependent, not backend-dependent**. A
   document with explicit numbered headings (e.g. the BUMA standard: `2.` /
   `2.1.` / `2.1.1.`) yields a clean hierarchy. A document of mid-chapter pages
   with no headings yields none β€” which is why `section_no`, `heading` and
   `heading_path` stay Optional rather than required.

⚠️ `Chunk.text` must stay VERBATIM from the source document. The extraction-side
guardrail locates LLM-quoted spans literally inside this text; if the text is
ever cleaned up (lines rejoined, whitespace normalised), the lookup fails and
the field is silently set to null instead of raising. The failure looks like a
bad LLM, but the cause would be here.
"""

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, Field

ChunkKind = Literal["text", "table", "chart", "equation"]

SCHEMA_VERSION = "0.2.0"


class Chunk(BaseModel):
    """One unit of a parsed document, ready for the extraction stage."""

    chunk_id: str
    doc_id: str
    kind: ChunkKind

    # Content. VERBATIM β€” never reflowed or normalised. See note above.
    #
    # Does NOT include the section's heading line; that lives in `heading`.
    # Consumers that need both compose them β€” the extraction half already builds
    # its span-check haystack as `heading + "\n" + text` and treats a heading
    # that names the term as a mention at offset 0.
    text: str

    # Location in the source document.
    #
    # Named `page_idx` deliberately: these are 0-BASED, exactly as MinerU reports
    # them, with no conversion anywhere in the pipeline. Page numbers eventually
    # reach a human review queue, and an off-by-one there is invisible until an
    # expert opens the wrong page. Converting to 1-based is the UI's job, done
    # once at display time β€” never here, so the artifact always matches the raw
    # MinerU output kept alongside it.
    page_idx: int
    page_idxs: list[int] = Field(default_factory=list)

    # Structural context. All Optional β€” many documents carry no headings.
    section_no: str | None = None  # e.g. "2.1.3", when the document is numbered

    # This chunk's own section title, VERBATIM as the document writes it β€”
    # including wording a reader may be tempted to "fix". The BUMA standard says
    # "Physical of Availability (PA)", not "Physical Availability"; that exact
    # string must reach the extraction model, or it gets silently normalised and
    # the discrepancy never reaches the expert.
    heading: str | None = None

    # Running headers of every page this chunk spans, in page order. A list
    # rather than a single value: a chunk crossing a chapter boundary would
    # otherwise silently keep only the first page's chapter.
    chapters: list[str] = Field(default_factory=list)

    # Breadcrumb of enclosing headings, outermost first, built from MinerU's
    # `text_level` hierarchy. Example from the BUMA standard:
    #   ["2. PENJELASAN PARAMETER", "2.1. Production Parameter", "2.1.1. Production"]
    heading_path: list[str] = Field(default_factory=list)

    # Flags for cheap filtering downstream
    has_formula: bool = False
    is_tabular: bool = False

    # Trace back to the source: item indices in MinerU's content_list.json
    source_items: list[int] = Field(default_factory=list)

    # Position on the page of the first source item, as MinerU reports it.
    # Carried through for a curation UI that highlights where on the page a
    # definition came from. Nothing in the pipeline reasons about it β€” ordering
    # uses item sequence, never coordinates.
    bbox: list[int] | None = None

    # Non-text attachments (formula images, table/chart crops)
    images: list[str] = Field(default_factory=list)

    # Source markup, kept verbatim beside the rendered prose in `text`.
    #
    # `text` carries a readable rendering because the term filter is an NER
    # model reading prose: MinerU writes formulas character-spaced
    # ("P u r c h a s i n g ~ c o s t s") and tables as HTML, and neither
    # produces a single mention. Measured on the same document and gold set,
    # only the parse differing: raw markup in `text` scored recall 0.7561 against
    # 0.8537 for plain text; rendering it back recovered 0.8293.
    #
    # The markup is not discarded β€” the formula branch needs exactly this form.
    latex: list[str] = Field(default_factory=list)
    table_html: str | None = None


class ParsedDocument(BaseModel):
    """The artifact itself β€” one parsed document, self-describing.

    The chunk list alone is not enough to hand across the seam: an artifact that
    travels to the extraction half must carry its own provenance. Without
    `parser_name` / `parser_version` / `parser_backend`, a MinerU upgrade and a
    prompt change are indistinguishable when extraction results shift.

    `content_hash` is the hash of the SOURCE FILE, so re-parsing the same
    document is detectable and a changed document forces a new artifact version.
    """

    doc_id: str
    chunks: list[Chunk]

    # Identity of the source
    source_path: str
    content_hash: str                    # sha256 of the source file
    n_pages: int

    # Which parser produced this, and how
    parser_name: str = "mineru"
    parser_version: str | None = None    # e.g. "3.4.4"
    parser_backend: str | None = None    # "pipeline" | "vlm" | "hybrid", as MinerU recorded it
    parser_config: str | None = None     # fingerprint of the settings that affect output

    # Version of THIS ARTIFACT for this document β€” bumped when the document is
    # re-parsed (new source content, new parser version, or changed settings).
    # Distinct from `schema_version`, which versions the contract itself.
    version: int = 1

    schema_version: str = SCHEMA_VERSION
    created_at: str | None = None

    # Where the untouched MinerU output for this document lives
    raw_output_dir: str | None = None


# --- Seam for the downstream stages (declared, not yet used) ---
# Written here so the handoff shape is visible from the start. The extraction
# half owns the final form of these two β€” see `KNOWLEDGE_PIPELINE_TODO.md` Β§5.


class Mention(BaseModel):
    """One occurrence of a term inside a chunk."""

    term: str
    chunk_id: str
    page_idx: int


class TermRecord(BaseModel):
    """Extracted knowledge for one term (one cluster of mentions)."""

    term: str
    full_name: str | None = None

    # What the document literally says, before any normalisation β€” e.g.
    # "Physical of Availability (PA)" where `full_name` may read "Physical
    # Availability". Span-checked against `Chunk.text` like every other field,
    # so the discrepancy surfaces to the expert instead of being quietly fixed.
    source_wording: str | None = None

    definition: str | None = None
    formula_latex: str | None = None
    subdomain_tags: list[str] = Field(default_factory=list)
    mention_count: int = 0
    provenance: dict = Field(default_factory=dict)
    extraction_status: str = "ok"