Skip to content

Commit dec8f51

Browse files
Merge pull request #2212 from maziyarpanahi/feature/om-814-detect-sections-orchestrator
Add validated clinical section detection orchestration
2 parents f93b44e + d36e5ae commit dec8f51

3 files changed

Lines changed: 516 additions & 34 deletions

File tree

openmed/clinical/sections/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
parse_section_lists,
1515
section_label_from_loinc,
1616
validate_section_spans,
17+
validate_sections,
1718
)
1819
from .doctype import (
1920
DEFAULT_DOCUMENT_TYPE_SIGNATURES_RESOURCE,
@@ -41,5 +42,6 @@
4142
"parse_section_lists",
4243
"segment_history_family",
4344
"section_label_from_loinc",
45+
"validate_sections",
4446
"validate_section_spans",
4547
]

openmed/clinical/sections/detect.py

Lines changed: 271 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Iterable, Mapping
5+
from collections.abc import Callable, Iterable, Mapping
66
from dataclasses import dataclass
77
from types import MappingProxyType
88
from 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+
91106
def 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+
199436
def 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

Comments
 (0)