22
33from __future__ import annotations
44
5- from collections .abc import Iterable , Mapping
5+ from collections .abc import Callable , Iterable , Mapping
66from dataclasses import dataclass
77from types import MappingProxyType
88from typing import Any
@@ -88,21 +88,60 @@ class _HeaderHit:
8888 language : str
8989
9090
91+ @dataclass (frozen = True )
92+ class _SectionCandidate :
93+ span : SectionSpan
94+ header_start : int
95+ header_end : int
96+ registration_order : int
97+
98+ @property
99+ def header_length (self ) -> int :
100+ return self .header_end - self .header_start
101+
102+
103+ _SectionSegmenter = Callable [[str , str | None ], tuple [SectionSpan , ...]]
104+
105+
91106def detect_sections (
92107 text : str ,
93108 * ,
94109 language : str | None = None ,
95110 include_unsectioned : bool = True ,
96111) -> tuple [SectionSpan , ...]:
97- """Segment *text* into canonical clinical section spans.
112+ """Run registered segmenters and assemble canonical section spans.
98113
99- Headers are matched at line starts using language-pack section lexicons.
100- Colon/full-width-colon headers, standalone headers, and underlined headers
101- are supported without whitespace assumptions around CJK or RTL scripts.
102- Returned ``label`` values are canonical section keys, so downstream section
103- priors can consume them directly .
114+ Candidate headers from the language-pack lexicon and focused section-family
115+ segmenters are merged deterministically. Overlapping header matches prefer
116+ the longest match, then the earliest section start, then registration order
117+ for an exact tie. Returned spans are sorted and, by default, ``unsectioned``
118+ spans fill any uncovered ranges so the result covers all of *text* .
104119 """
105120
121+ if not text :
122+ validate_sections (text , ())
123+ return ()
124+
125+ candidates = _section_candidates (text , language )
126+ result = _assemble_sections (
127+ text ,
128+ _resolve_overlapping_headers (candidates ),
129+ language = language ,
130+ include_unsectioned = include_unsectioned ,
131+ )
132+ if include_unsectioned :
133+ validate_sections (text , result )
134+ else :
135+ _validate_section_spans (text , result , require_coverage = False )
136+ return result
137+
138+
139+ def _segment_lexicon_sections (
140+ text : str ,
141+ language : str | None ,
142+ ) -> tuple [SectionSpan , ...]:
143+ """Return sections detected from the multilingual header lexicon."""
144+
106145 if not text :
107146 return ()
108147
@@ -113,29 +152,21 @@ def detect_sections(
113152 for hit in _line_header_hits (line , _next_line (lines , index ), language )
114153 )
115154 if not hits :
116- unsectioned_result = (
117- (
118- _section_dict (
119- label = UNSECTIONED_SECTION ,
120- start = 0 ,
121- end = len (text ),
122- language = language ,
123- ),
124- )
125- if include_unsectioned and text
126- else ()
127- )
128- _validate_section_spans (
129- text ,
130- unsectioned_result ,
131- require_coverage = include_unsectioned ,
155+ result = (
156+ _section_dict (
157+ label = UNSECTIONED_SECTION ,
158+ start = 0 ,
159+ end = len (text ),
160+ language = language ,
161+ ),
132162 )
133- return unsectioned_result
163+ validate_sections (text , result )
164+ return result
134165
135166 sections : list [SectionSpan ] = []
136167 cursor = 0
137168 for index , hit in enumerate (hits ):
138- if include_unsectioned and cursor < hit .start :
169+ if cursor < hit .start :
139170 sections .append (
140171 _section_dict (
141172 label = UNSECTIONED_SECTION ,
@@ -160,7 +191,7 @@ def detect_sections(
160191 )
161192 cursor = section_end
162193
163- if include_unsectioned and cursor < len (text ):
194+ if cursor < len (text ):
164195 sections .append (
165196 _section_dict (
166197 label = UNSECTIONED_SECTION ,
@@ -170,15 +201,212 @@ def detect_sections(
170201 )
171202 )
172203 result = tuple (section for section in sections if section ["start" ] < section ["end" ])
173- _validate_section_spans (
174- text ,
175- result ,
176- require_coverage = include_unsectioned ,
177- )
204+ validate_sections (text , result )
178205 return result
179206
180207
181- def validate_section_spans (
208+ def _segment_history_sections (
209+ text : str ,
210+ language : str | None ,
211+ ) -> tuple [SectionSpan , ...]:
212+ """Adapt the focused history-family segmenter to the registry contract."""
213+
214+ del language
215+ from .history import segment_history_family
216+
217+ return segment_history_family (text )
218+
219+
220+ _REGISTERED_SECTION_SEGMENTERS : tuple [_SectionSegmenter , ...] = (
221+ _segment_lexicon_sections ,
222+ _segment_history_sections ,
223+ )
224+
225+
226+ def _section_candidates (
227+ text : str ,
228+ language : str | None ,
229+ ) -> tuple [_SectionCandidate , ...]:
230+ candidates : list [_SectionCandidate ] = []
231+ registration_order = 0
232+ for segmenter in _REGISTERED_SECTION_SEGMENTERS :
233+ for index , raw_span in enumerate (segmenter (text , language )):
234+ if not isinstance (raw_span , Mapping ):
235+ raise ValueError (
236+ f"registered section segmenter span { index } must be a mapping"
237+ )
238+ label = raw_span .get ("label" )
239+ if not isinstance (label , str ) or not label .strip ():
240+ raise ValueError (
241+ f"registered section segmenter span { index } requires a label"
242+ )
243+ start = _section_offset (raw_span , "start" , index )
244+ end = _section_offset (raw_span , "end" , index )
245+ if start < 0 or start > len (text ) or end < 0 or end > len (text ):
246+ raise ValueError (
247+ f"registered section segmenter span { index } has invalid offsets"
248+ )
249+ if end <= start :
250+ raise ValueError (
251+ f"registered section segmenter span { index } has invalid offsets"
252+ )
253+ if label == UNSECTIONED_SECTION :
254+ continue
255+
256+ metadata = {
257+ key : value
258+ for key , value in raw_span .items ()
259+ if isinstance (key , str ) and key not in {"label" , "start" , "end" }
260+ }
261+ span = SectionSpan (label = label , start = start , end = end , ** metadata )
262+ header_start , header_end = _candidate_header_bounds (text , span )
263+ candidates .append (
264+ _SectionCandidate (
265+ span = span ,
266+ header_start = header_start ,
267+ header_end = header_end ,
268+ registration_order = registration_order ,
269+ )
270+ )
271+ registration_order += 1
272+ return tuple (candidates )
273+
274+
275+ def _candidate_header_bounds (
276+ text : str ,
277+ span : Mapping [str , Any ],
278+ ) -> tuple [int , int ]:
279+ start = int (span ["start" ])
280+ end = int (span ["end" ])
281+ raw_header_start = span .get ("header_start" )
282+ raw_header_end = span .get ("header_end" )
283+ if raw_header_start is not None or raw_header_end is not None :
284+ if (
285+ not isinstance (raw_header_start , int )
286+ or isinstance (raw_header_start , bool )
287+ or not isinstance (raw_header_end , int )
288+ or isinstance (raw_header_end , bool )
289+ or not start <= raw_header_start < raw_header_end <= end
290+ ):
291+ raise ValueError ("section candidate has invalid header offsets" )
292+ return raw_header_start , raw_header_end
293+
294+ line_end = text .find ("\n " , start , end )
295+ if line_end == - 1 :
296+ line_end = end
297+ content_end = line_end - int (line_end > start and text [line_end - 1 ] == "\r " )
298+ content , content_start = _strip_line_prefix (text [start :content_end ], start )
299+ delimiter_index = _first_delimiter_index (content )
300+ header = (
301+ content [:delimiter_index ].strip () if delimiter_index > 0 else content .strip ()
302+ )
303+ if not header :
304+ return start , start + 1
305+ header_start = content_start + content .find (header )
306+ return header_start , header_start + len (header )
307+
308+
309+ def _resolve_overlapping_headers (
310+ candidates : Iterable [_SectionCandidate ],
311+ ) -> tuple [_SectionCandidate , ...]:
312+ selected : list [_SectionCandidate ] = []
313+ precedence = sorted (
314+ candidates ,
315+ key = lambda candidate : (
316+ - candidate .header_length ,
317+ candidate .span .start ,
318+ candidate .registration_order ,
319+ ),
320+ )
321+ for candidate in precedence :
322+ if any (_candidate_headers_overlap (candidate , other ) for other in selected ):
323+ continue
324+ selected .append (candidate )
325+ return tuple (
326+ sorted (
327+ selected ,
328+ key = lambda candidate : (
329+ candidate .span .start ,
330+ candidate .registration_order ,
331+ ),
332+ )
333+ )
334+
335+
336+ def _candidate_headers_overlap (
337+ left : _SectionCandidate ,
338+ right : _SectionCandidate ,
339+ ) -> bool :
340+ return left .span .start == right .span .start or (
341+ left .header_start < right .header_end and right .header_start < left .header_end
342+ )
343+
344+
345+ def _assemble_sections (
346+ text : str ,
347+ candidates : tuple [_SectionCandidate , ...],
348+ * ,
349+ language : str | None ,
350+ include_unsectioned : bool ,
351+ ) -> tuple [SectionSpan , ...]:
352+ if not candidates :
353+ if not include_unsectioned :
354+ return ()
355+ return (
356+ _section_dict (
357+ label = UNSECTIONED_SECTION ,
358+ start = 0 ,
359+ end = len (text ),
360+ language = language ,
361+ ),
362+ )
363+
364+ sections : list [SectionSpan ] = []
365+ cursor = 0
366+ for index , candidate in enumerate (candidates ):
367+ start = candidate .span .start
368+ if include_unsectioned and cursor < start :
369+ sections .append (
370+ _section_dict (
371+ label = UNSECTIONED_SECTION ,
372+ start = cursor ,
373+ end = start ,
374+ language = language ,
375+ )
376+ )
377+ next_start = (
378+ candidates [index + 1 ].span .start
379+ if index + 1 < len (candidates )
380+ else len (text )
381+ )
382+ end = min (candidate .span .end , next_start )
383+ metadata = {
384+ key : value
385+ for key , value in candidate .span .items ()
386+ if key not in {"label" , "start" , "end" }
387+ }
388+ sections .append (
389+ SectionSpan (
390+ label = candidate .span .label ,
391+ start = start ,
392+ end = end ,
393+ ** metadata ,
394+ )
395+ )
396+ cursor = end
397+ if include_unsectioned and cursor < len (text ):
398+ sections .append (
399+ _section_dict (
400+ label = UNSECTIONED_SECTION ,
401+ start = cursor ,
402+ end = len (text ),
403+ language = language ,
404+ )
405+ )
406+ return tuple (sections )
407+
408+
409+ def validate_sections (
182410 text : str ,
183411 spans : Iterable [Mapping [str , Any ]],
184412) -> None :
@@ -196,6 +424,15 @@ def validate_section_spans(
196424 _validate_section_spans (text , spans , require_coverage = True )
197425
198426
427+ def validate_section_spans (
428+ text : str ,
429+ spans : Iterable [Mapping [str , Any ]],
430+ ) -> None :
431+ """Backward-compatible alias for :func:`validate_sections`."""
432+
433+ validate_sections (text , spans )
434+
435+
199436def list_section_label (section : str | Mapping [str , Any ]) -> str | None :
200437 """Return the canonical list-bearing label for a label or coded section.
201438
@@ -345,7 +582,7 @@ def _validate_section_spans(
345582 raise ValueError (f"section span { index } requires a non-empty label" )
346583 start = _section_offset (span , "start" , index )
347584 end = _section_offset (span , "end" , index )
348- if start < 0 or end > len (text ):
585+ if start < 0 or start > len ( text ) or end < 0 or end > len (text ):
349586 raise ValueError (f"section span { index } is outside document bounds" )
350587 if end <= start :
351588 raise ValueError (f"section span { index } must have positive length" )
@@ -357,7 +594,7 @@ def _validate_section_spans(
357594 raise ValueError (
358595 f"section spans overlap at offsets { start } to { previous_end } "
359596 )
360- elif start > previous_end :
597+ elif require_coverage and start > previous_end :
361598 raise ValueError (
362599 f"section spans leave a gap from { previous_end } to { start } "
363600 )
0 commit comments