-
-
Notifications
You must be signed in to change notification settings - Fork 189
Expand file tree
/
Copy pathoc_text_parser.py
More file actions
261 lines (226 loc) · 9.81 KB
/
Copy pathoc_text_parser.py
File metadata and controls
261 lines (226 loc) · 9.81 KB
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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
import logging
from dataclasses import dataclass, field
from typing import Any, Optional, cast
from django.core.files.storage import default_storage
from opencontractserver.annotations.models import SPAN_LABEL
from opencontractserver.documents.models import Document
from opencontractserver.pipeline.base.file_types import FileTypeEnum
from opencontractserver.pipeline.base.parser import BaseParser
from opencontractserver.pipeline.base.settings_schema import (
PipelineSetting,
SettingType,
)
from opencontractserver.pipeline.parsers.text_chunkers import (
SENTENCE_CHUNK_LABEL,
BaseTextChunker,
ChunkerSpec,
TextChunk,
get_chunker,
)
from opencontractserver.types.dicts import (
AnnotationLabelPythonType,
OpenContractDocExport,
OpenContractsAnnotationPythonType,
TextSpanData,
)
from opencontractserver.types.enums import LabelType
logger = logging.getLogger(__name__)
# Default chunking recipe: sentence-only, matching the historical behaviour
# of TxtParser prior to the pluggable-chunker migration.
DEFAULT_CHUNKERS: list[ChunkerSpec] = [{"name": "sentence"}]
class TxtParser(BaseParser):
"""
Parser that processes plain text documents into structural annotations.
Chunking granularity is pluggable: the parser iterates one or more
``BaseTextChunker`` strategies (see ``text_chunkers``) and stores each
chunk as a ``SPAN_LABEL`` structural annotation. The default is a
single sentence-level chunker so existing corpora are unchanged, but
operators can stack strategies (e.g. sentence + paragraph) to index
multiple retrieval granularities at once.
"""
title = "Text Parser"
description = (
"Parses plain text documents and splits them into configurable "
"structural chunks (sentences, paragraphs, sliding windows)."
)
author = "OpenContracts"
# SentenceChunker additionally requires the optional ``spacy`` package,
# but that dependency is only loaded when the sentence strategy is
# actually used; leaving the base parser's dependency list empty keeps
# non-sentence pipelines (paragraph / sliding-window) dependency-free.
dependencies = []
supported_file_types = [FileTypeEnum.TXT]
@dataclass
class Settings:
"""Configuration schema for :class:`TxtParser`."""
# ``chunkers`` is a list of ``ChunkerSpec`` entries. Each entry is
# either a bare name ("sentence") or a mapping with ``name`` plus
# strategy-specific keyword arguments:
# {"name": "sliding_window", "window_size": 1200, "overlap": 200}
# Using a list (rather than a single value) lets a single parse run
# index the document at multiple retrieval granularities at once.
chunkers: list[ChunkerSpec] = field(
default_factory=lambda: list(DEFAULT_CHUNKERS),
metadata={
"pipeline_setting": PipelineSetting(
setting_type=SettingType.OPTIONAL,
description=(
"Ordered list of chunking strategies applied to the "
"document text. Each entry is either a chunker name "
"(e.g. 'sentence') or a mapping with 'name' plus "
"strategy kwargs (e.g. {'name': 'sliding_window', "
"'window_size': 1200, 'overlap': 200}). Defaults to "
"['sentence']."
),
)
},
)
def __init__(self, **kwargs) -> None:
"""Initialise the parser. Chunker instantiation is deferred to parse time."""
super().__init__(**kwargs)
def _resolve_chunkers(
self, override: Optional[list[ChunkerSpec]] = None
) -> list[BaseTextChunker]:
"""
Resolve the active list of chunker instances for a parse call.
Resolution order: explicit ``override`` kwarg → parser settings →
:data:`DEFAULT_CHUNKERS`. An empty list from either source also
falls back to the default, so a misconfiguration never produces a
document with zero structural annotations. Note that ``override=[]``
means "caller explicitly asked for no chunkers" but we still emit
DEFAULT_CHUNKERS (with a warning) because a zero-chunker parse is
never useful downstream.
"""
if override is not None:
specs = override
elif self.settings is not None and self.settings.chunkers:
specs = self.settings.chunkers
else:
specs = DEFAULT_CHUNKERS
if not specs:
logger.warning(
"TxtParser received an empty chunker list; "
"falling back to DEFAULT_CHUNKERS."
)
specs = DEFAULT_CHUNKERS
return [get_chunker(spec) for spec in specs]
def _parse_document_impl(
self, user_id: int, doc_id: int, **all_kwargs
) -> Optional[OpenContractDocExport]:
"""
Parse a text document into structural annotations using the
configured chunking strategies.
Call-time ``chunkers=[...]`` in ``all_kwargs`` overrides the
per-component settings for this invocation only — useful for
benchmark sweeps that want to compare strategies without touching
``PipelineSettings``.
"""
logger.info(
f"TxtParser - Parsing doc {doc_id} for user {user_id} "
f"with effective kwargs: {all_kwargs}"
)
document = Document.objects.get(pk=doc_id)
if not document.txt_extract_file.name:
logger.error(f"No txt file found for document {doc_id}")
return None
txt_path = document.txt_extract_file.name
with default_storage.open(txt_path, mode="r") as txt_file:
text_content = txt_file.read()
return self.parse_text(
text_content,
title=document.title or "",
description=document.description or "",
doc_id=doc_id,
**all_kwargs,
)
def parse_text(
self,
text_content: str,
*,
title: str = "",
description: str = "",
doc_id: int = 0,
**all_kwargs,
) -> OpenContractDocExport:
"""Parse decoded text without storage or database access; preserve offsets."""
chunker_override = all_kwargs.get("chunkers")
if chunker_override is not None and not isinstance(chunker_override, list):
raise TypeError(
"chunkers kwarg must be a list of ChunkerSpec (dict/string) "
f"entries, got {type(chunker_override).__name__}"
)
chunkers = self._resolve_chunkers(chunker_override)
logger.info(
f"TxtParser - doc {doc_id}: applying {len(chunkers)} chunker(s): "
f"{[type(c).__name__ for c in chunkers]}"
)
# Base envelope shared across strategies. Labels and annotations
# are filled in as we iterate the chunkers.
open_contracts_data: OpenContractDocExport = {
"title": title or "",
"content": text_content,
"description": description or "",
"pawls_file_content": [], # No PAWLS data for plain text
"page_count": 1, # Single page
"doc_labels": [],
"labelled_text": [],
}
text_labels: dict[str, AnnotationLabelPythonType] = {}
labelled_text: list[OpenContractsAnnotationPythonType] = []
for chunker in chunkers:
chunks_produced = 0
for chunk in chunker.chunk(text_content):
label_name = chunk.label or SENTENCE_CHUNK_LABEL
if label_name not in text_labels:
text_labels[label_name] = _make_label(label_name)
annotation = _annotation_for_chunk(chunk, label_name)
annotation["id"] = f"txt-{len(labelled_text)}"
labelled_text.append(annotation)
chunks_produced += 1
logger.debug(
f"TxtParser - doc {doc_id}: {type(chunker).__name__} "
f"produced {chunks_produced} chunk(s)"
)
# ``text_labels`` is not part of the OpenContractDocExport TypedDict
# surface but downstream pipelines tolerate (and historically read)
# the key off the per-doc payload, so we keep the assignment via a
# widened cast rather than dropping it.
cast(dict[str, Any], open_contracts_data)["text_labels"] = text_labels
open_contracts_data["labelled_text"] = labelled_text
return open_contracts_data
def _make_label(label_name: str) -> AnnotationLabelPythonType:
"""Build the ``AnnotationLabelPythonType`` stub used for a chunker's label.
The caller fills in ``id`` post-import; we cast through ``dict`` so the
placeholder ``None`` doesn't violate the strict type surface here.
"""
label: dict[str, Any] = {
"id": None, # ID will be assigned when saved to the database
"color": "grey",
"description": f"Structural {label_name.lower()} chunk",
"icon": "expand",
"text": label_name,
"label_type": LabelType(SPAN_LABEL),
"parent_id": None,
}
return cast(AnnotationLabelPythonType, label)
def _annotation_for_chunk(
chunk: TextChunk, label_name: str
) -> OpenContractsAnnotationPythonType:
"""Convert a :class:`TextChunk` into an ``OpenContractsAnnotationPythonType``."""
span: TextSpanData = {
"start": chunk.start,
"end": chunk.end,
"text": chunk.text,
}
annotation: OpenContractsAnnotationPythonType = {
"id": None,
"annotationLabel": label_name,
"rawText": chunk.text,
"page": 1,
"annotation_json": span,
"parent_id": None,
"annotation_type": "SPAN_LABEL",
"structural": True,
"content_modalities": ["TEXT"],
}
return annotation