google--langextract
76d991c447
Auto Update PR / update-prs (push) Has been cancelled
CI / format-check (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / live-api-tests (push) Has been cancelled
CI / plugin-integration-test (push) Has been cancelled
CI / ollama-integration-test (push) Has been cancelled
CI / test-fork-pr (push) Has been cancelled
2629 行
90 KiB
Python
2629 行
90 KiB
Python
# Copyright 2025 Google LLC.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import textwrap
|
|
from typing import Sequence
|
|
from unittest import mock
|
|
|
|
from absl.testing import absltest
|
|
from absl.testing import parameterized
|
|
|
|
from langextract import chunking
|
|
from langextract import resolver as resolver_lib
|
|
from langextract.core import data
|
|
from langextract.core import tokenizer
|
|
|
|
|
|
def assert_char_interval_match_source(
|
|
test_case: absltest.TestCase,
|
|
source_text: str,
|
|
extractions: Sequence[data.Extraction],
|
|
):
|
|
"""Asserts that the char_interval of matched extractions matches the source text.
|
|
|
|
Args:
|
|
test_case: The TestCase instance.
|
|
source_text: The original source text.
|
|
extractions: A sequence of extractions to check.
|
|
"""
|
|
for extraction in extractions:
|
|
if extraction.alignment_status == data.AlignmentStatus.MATCH_EXACT:
|
|
assert (
|
|
extraction.char_interval is not None
|
|
), "char_interval should not be None for AlignmentStatus.MATCH_EXACT"
|
|
|
|
char_int = extraction.char_interval
|
|
start = char_int.start_pos
|
|
end = char_int.end_pos
|
|
test_case.assertIsNotNone(start, "start_pos should not be None")
|
|
test_case.assertIsNotNone(end, "end_pos should not be None")
|
|
extracted = source_text[start:end]
|
|
test_case.assertEqual(
|
|
extracted.lower(),
|
|
extraction.extraction_text.lower(),
|
|
f"Extraction '{extraction.extraction_text}' does not match extracted"
|
|
f" '{extracted}' using char_interval {char_int}",
|
|
)
|
|
|
|
|
|
class ParserTest(parameterized.TestCase):
|
|
|
|
@parameterized.named_parameters(
|
|
dict(
|
|
testcase_name="json_invalid_input",
|
|
resolver=resolver_lib.Resolver(
|
|
format_type=data.FormatType.JSON,
|
|
fence_output=True,
|
|
strict_fences=True,
|
|
),
|
|
input_text="invalid input",
|
|
expected_exception=resolver_lib.ResolverParsingError,
|
|
expected_regex=".*fence markers.*",
|
|
),
|
|
dict(
|
|
testcase_name="json_missing_markers",
|
|
resolver=resolver_lib.Resolver(
|
|
format_type=data.FormatType.JSON,
|
|
fence_output=True,
|
|
strict_fences=True,
|
|
),
|
|
input_text='[{"key": "value"}]',
|
|
expected_exception=resolver_lib.ResolverParsingError,
|
|
expected_regex=".*fence markers.*",
|
|
),
|
|
dict(
|
|
testcase_name="json_empty_string",
|
|
resolver=resolver_lib.Resolver(
|
|
format_type=data.FormatType.JSON,
|
|
fence_output=True,
|
|
),
|
|
input_text="",
|
|
expected_exception=ValueError,
|
|
expected_regex=".*must be a non-empty string.*",
|
|
),
|
|
dict(
|
|
testcase_name="json_partial_markers",
|
|
resolver=resolver_lib.Resolver(
|
|
format_type=data.FormatType.JSON,
|
|
fence_output=True,
|
|
strict_fences=True,
|
|
),
|
|
input_text='```json\n{"key": "value"',
|
|
expected_exception=resolver_lib.ResolverParsingError,
|
|
expected_regex=".*fence markers.*",
|
|
),
|
|
dict(
|
|
testcase_name="yaml_invalid_input",
|
|
resolver=resolver_lib.Resolver(
|
|
format_type=data.FormatType.YAML,
|
|
fence_output=True,
|
|
strict_fences=True,
|
|
),
|
|
input_text="invalid input",
|
|
expected_exception=resolver_lib.ResolverParsingError,
|
|
expected_regex=".*fence markers.*",
|
|
),
|
|
dict(
|
|
testcase_name="yaml_missing_markers",
|
|
resolver=resolver_lib.Resolver(
|
|
format_type=data.FormatType.YAML,
|
|
fence_output=True,
|
|
strict_fences=True,
|
|
),
|
|
input_text='[{"key": "value"}]',
|
|
expected_exception=resolver_lib.ResolverParsingError,
|
|
expected_regex=".*fence markers.*",
|
|
),
|
|
dict(
|
|
testcase_name="yaml_empty_content",
|
|
resolver=resolver_lib.Resolver(
|
|
format_type=data.FormatType.YAML,
|
|
fence_output=True,
|
|
),
|
|
input_text="```yaml\n```",
|
|
expected_exception=resolver_lib.ResolverParsingError,
|
|
expected_regex=(
|
|
".*Content must be a mapping with an"
|
|
f" '{data.EXTRACTIONS_KEY}' key.*"
|
|
),
|
|
),
|
|
)
|
|
def test_parser_error_cases(
|
|
self, resolver, input_text, expected_exception, expected_regex
|
|
):
|
|
with self.assertRaisesRegex(expected_exception, expected_regex):
|
|
resolver.string_to_extraction_data(input_text)
|
|
|
|
|
|
class ExtractOrderedEntitiesTest(parameterized.TestCase):
|
|
|
|
@parameterized.named_parameters(
|
|
dict(
|
|
testcase_name="valid_input",
|
|
test_input=[
|
|
{
|
|
"medication": "Naprosyn",
|
|
"medication_index": 4,
|
|
"frequency": "as needed",
|
|
"frequency_index": 5,
|
|
"reason": "pain",
|
|
"reason_index": 8,
|
|
},
|
|
{
|
|
"medication": "prednisone",
|
|
"medication_index": 5,
|
|
"frequency": "daily",
|
|
"frequency_index": 1,
|
|
},
|
|
],
|
|
expected_output=[
|
|
data.Extraction(
|
|
extraction_class="frequency",
|
|
extraction_text="daily",
|
|
extraction_index=1,
|
|
group_index=1,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
extraction_index=4,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="frequency",
|
|
extraction_text="as needed",
|
|
extraction_index=5,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
extraction_index=5,
|
|
group_index=1,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="reason",
|
|
extraction_text="pain",
|
|
extraction_index=8,
|
|
group_index=0,
|
|
),
|
|
],
|
|
),
|
|
dict(
|
|
testcase_name="empty_input",
|
|
test_input=[],
|
|
expected_output=[],
|
|
),
|
|
dict(
|
|
testcase_name="mixed_index_order",
|
|
test_input=[
|
|
{
|
|
"medication": "Ibuprofen",
|
|
"medication_index": 2,
|
|
"dosage": "400mg",
|
|
"dosage_index": 1,
|
|
},
|
|
{
|
|
"medication": "Acetaminophen",
|
|
"medication_index": 1,
|
|
"duration": "7 days",
|
|
"duration_index": 2,
|
|
},
|
|
],
|
|
expected_output=[
|
|
data.Extraction(
|
|
extraction_class="dosage",
|
|
extraction_text="400mg",
|
|
extraction_index=1,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Acetaminophen",
|
|
extraction_index=1,
|
|
group_index=1,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Ibuprofen",
|
|
extraction_index=2,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="duration",
|
|
extraction_text="7 days",
|
|
extraction_index=2,
|
|
group_index=1,
|
|
),
|
|
],
|
|
),
|
|
dict(
|
|
testcase_name="missing_index_key",
|
|
test_input=[{
|
|
"medication": "Aspirin",
|
|
"dosage": "325mg",
|
|
"dosage_index": 1,
|
|
}],
|
|
expected_output=[
|
|
data.Extraction(
|
|
extraction_class="dosage",
|
|
extraction_text="325mg",
|
|
extraction_index=1,
|
|
group_index=0,
|
|
),
|
|
],
|
|
),
|
|
dict(
|
|
testcase_name="all_indices_missing",
|
|
test_input=[
|
|
{"medication": "Aspirin", "dosage": "325mg"},
|
|
{"medication": "Ibuprofen", "dosage": "400mg"},
|
|
],
|
|
expected_output=[],
|
|
),
|
|
dict(
|
|
testcase_name="single_element_dictionaries",
|
|
test_input=[
|
|
{"medication": "Aspirin", "medication_index": 1},
|
|
{"medication": "Ibuprofen", "medication_index": 2},
|
|
],
|
|
expected_output=[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Aspirin",
|
|
extraction_index=1,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Ibuprofen",
|
|
extraction_index=2,
|
|
group_index=1,
|
|
),
|
|
],
|
|
),
|
|
dict(
|
|
testcase_name="duplicate_indices_unchanged",
|
|
test_input=[{
|
|
"medication": "Aspirin",
|
|
"medication_index": 1,
|
|
"dosage": "325mg",
|
|
"dosage_index": 1,
|
|
"form": "tablet",
|
|
"form_index": 1,
|
|
}],
|
|
expected_output=[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Aspirin",
|
|
extraction_index=1,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="dosage",
|
|
extraction_text="325mg",
|
|
extraction_index=1,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="form",
|
|
extraction_text="tablet",
|
|
extraction_index=1,
|
|
group_index=0,
|
|
),
|
|
],
|
|
),
|
|
dict(
|
|
testcase_name="negative_indices",
|
|
test_input=[{
|
|
"medication": "Aspirin",
|
|
"medication_index": -1,
|
|
"dosage": "325mg",
|
|
"dosage_index": -2,
|
|
}],
|
|
expected_output=[
|
|
data.Extraction(
|
|
extraction_class="dosage",
|
|
extraction_text="325mg",
|
|
extraction_index=-2,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Aspirin",
|
|
extraction_index=-1,
|
|
group_index=0,
|
|
),
|
|
],
|
|
),
|
|
dict(
|
|
testcase_name="index_without_data_key_ignored",
|
|
test_input=[{
|
|
"medication_index": 1,
|
|
"dosage": "325mg",
|
|
"dosage_index": 2,
|
|
}],
|
|
expected_output=[
|
|
data.Extraction(
|
|
extraction_class="dosage",
|
|
extraction_text="325mg",
|
|
extraction_index=2,
|
|
group_index=0,
|
|
),
|
|
],
|
|
),
|
|
dict(
|
|
testcase_name="no_index_suffix",
|
|
resolver=resolver_lib.Resolver(
|
|
extraction_index_suffix=None,
|
|
format_type=data.FormatType.JSON,
|
|
),
|
|
test_input=[
|
|
{"medication": "Aspirin"},
|
|
{"medication": "Ibuprofen"},
|
|
{"dosage": "325mg"},
|
|
{"dosage": "400mg"},
|
|
],
|
|
expected_output=[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Aspirin",
|
|
extraction_index=1,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Ibuprofen",
|
|
extraction_index=2,
|
|
group_index=1,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="dosage",
|
|
extraction_text="325mg",
|
|
extraction_index=3,
|
|
group_index=2,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="dosage",
|
|
extraction_text="400mg",
|
|
extraction_index=4,
|
|
group_index=3,
|
|
),
|
|
],
|
|
),
|
|
dict(
|
|
testcase_name="attributes_suffix",
|
|
resolver=resolver_lib.Resolver(
|
|
extraction_index_suffix=None,
|
|
format_type=data.FormatType.JSON,
|
|
),
|
|
test_input=[
|
|
{
|
|
"patient": "Jane Doe",
|
|
"patient_attributes": {
|
|
"PERSON": "True",
|
|
"IDENTIFIABLE": "True",
|
|
},
|
|
},
|
|
{
|
|
"medication": "Lisinopril",
|
|
"medication_attributes": {
|
|
"THERAPEUTIC": "True",
|
|
"CLINICAL": "True",
|
|
},
|
|
},
|
|
],
|
|
expected_output=[
|
|
data.Extraction(
|
|
extraction_class="patient",
|
|
extraction_text="Jane Doe",
|
|
extraction_index=1,
|
|
group_index=0,
|
|
attributes={
|
|
"PERSON": "True",
|
|
"IDENTIFIABLE": "True",
|
|
},
|
|
),
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Lisinopril",
|
|
extraction_index=2,
|
|
group_index=1,
|
|
attributes={
|
|
"THERAPEUTIC": "True",
|
|
"CLINICAL": "True",
|
|
},
|
|
),
|
|
],
|
|
),
|
|
dict(
|
|
testcase_name="indices_and_attributes",
|
|
test_input=[
|
|
{
|
|
"patient": "John Doe",
|
|
"patient_index": 2,
|
|
"patient_attributes": {
|
|
"IDENTIFIABLE": "True",
|
|
},
|
|
"condition": "hypertension",
|
|
"condition_index": 1,
|
|
"condition_attributes": {
|
|
"CHRONIC_CONDITION": "True",
|
|
"REQUIRES_MANAGEMENT": "True",
|
|
},
|
|
},
|
|
{
|
|
"medication": "Lisinopril",
|
|
"medication_index": 3,
|
|
"medication_attributes": {
|
|
"ANTIHYPERTENSIVE_MEDICATION": "True",
|
|
"DAILY_USE": "True",
|
|
},
|
|
"dosage": "10mg",
|
|
"dosage_index": 4,
|
|
"dosage_attributes": {
|
|
"STANDARD_DAILY_DOSE": "True",
|
|
},
|
|
},
|
|
],
|
|
expected_output=[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="hypertension",
|
|
extraction_index=1,
|
|
group_index=0,
|
|
attributes={
|
|
"CHRONIC_CONDITION": "True",
|
|
"REQUIRES_MANAGEMENT": "True",
|
|
},
|
|
),
|
|
data.Extraction(
|
|
extraction_class="patient",
|
|
extraction_text="John Doe",
|
|
extraction_index=2,
|
|
group_index=0,
|
|
attributes={
|
|
"IDENTIFIABLE": "True",
|
|
},
|
|
),
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Lisinopril",
|
|
extraction_index=3,
|
|
group_index=1,
|
|
attributes={
|
|
"ANTIHYPERTENSIVE_MEDICATION": "True",
|
|
"DAILY_USE": "True",
|
|
},
|
|
),
|
|
data.Extraction(
|
|
extraction_class="dosage",
|
|
extraction_text="10mg",
|
|
extraction_index=4,
|
|
group_index=1,
|
|
attributes={
|
|
"STANDARD_DAILY_DOSE": "True",
|
|
},
|
|
),
|
|
],
|
|
),
|
|
)
|
|
def test_extract_ordered_extractions_success(
|
|
self,
|
|
test_input,
|
|
resolver=None,
|
|
expected_output=None,
|
|
):
|
|
if resolver is None:
|
|
resolver = resolver_lib.Resolver(
|
|
extraction_index_suffix=resolver_lib.DEFAULT_INDEX_SUFFIX
|
|
)
|
|
actual_output = resolver.extract_ordered_extractions(test_input)
|
|
self.assertEqual(actual_output, expected_output)
|
|
|
|
@parameterized.named_parameters(
|
|
dict(
|
|
testcase_name="non_integer_indices",
|
|
resolver=resolver_lib.Resolver(
|
|
format_type=data.FormatType.JSON,
|
|
extraction_index_suffix=resolver_lib.DEFAULT_INDEX_SUFFIX,
|
|
),
|
|
test_input=[{
|
|
"medication": "Aspirin",
|
|
"medication_index": "first",
|
|
"dosage": "325mg",
|
|
"dosage_index": "second",
|
|
}],
|
|
expected_exception=ValueError,
|
|
expected_regex=".*must be an integer.*",
|
|
),
|
|
dict(
|
|
testcase_name="float_indices",
|
|
resolver=resolver_lib.Resolver(
|
|
format_type=data.FormatType.JSON,
|
|
extraction_index_suffix=resolver_lib.DEFAULT_INDEX_SUFFIX,
|
|
),
|
|
test_input=[{"medication": "Aspirin", "medication_index": 1.0}],
|
|
expected_exception=ValueError,
|
|
expected_regex=".*must be an integer.*",
|
|
),
|
|
)
|
|
def test_extract_ordered_extractions_exceptions(
|
|
self, resolver, test_input, expected_exception, expected_regex
|
|
):
|
|
with self.assertRaisesRegex(expected_exception, expected_regex):
|
|
resolver.extract_ordered_extractions(test_input)
|
|
|
|
|
|
class AlignEntitiesTest(parameterized.TestCase):
|
|
_SOURCE_TEXT_TWO_MEDS = (
|
|
"Patient is prescribed Naprosyn and prednisone for treatment."
|
|
)
|
|
_SOURCE_TEXT_THREE_CONDITIONS_AND_MEDS = (
|
|
"Patient with arthritis, fever, and inflammation is prescribed"
|
|
" Naprosyn, prednisone, and ibuprofen."
|
|
)
|
|
_SOURCE_TEXT_MULTI_WORD_EXTRACTIONS = (
|
|
"Pt was prescribed Naprosyn as needed for pain and prednisone for"
|
|
" one month."
|
|
)
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.aligner = resolver_lib.WordAligner()
|
|
self.maxDiff = 10000
|
|
|
|
@parameterized.named_parameters(
|
|
(
|
|
"basic_alignment",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
)
|
|
],
|
|
],
|
|
_SOURCE_TEXT_TWO_MEDS,
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=4
|
|
),
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=30),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=5, end_index=6
|
|
),
|
|
char_interval=data.CharInterval(start_pos=35, end_pos=45),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"shuffled_order_of_last_two_extractions",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition", extraction_text="arthritis"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition", extraction_text="fever"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="inflammation",
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="ibuprofen"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
)
|
|
],
|
|
],
|
|
_SOURCE_TEXT_THREE_CONDITIONS_AND_MEDS,
|
|
# Indexes Aligned with Tokens
|
|
# --------------------------------------------------------------------
|
|
# Index | 0 1 2 3 4 5 6
|
|
# Token | Patient with arthritis , fever , and
|
|
# --------------------------------------------------------------------
|
|
# Index | 7 8 9
|
|
# Token | inflammation is prescribed
|
|
# --------------------------------------------------------------------
|
|
# Index | 10 11 12 13 14 15
|
|
# Token | Naprosyn , prednisone , and ibuprofen
|
|
# --------------------------------------------------------------------
|
|
# Index | 16
|
|
# Token | .
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="arthritis",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=2, end_index=3
|
|
),
|
|
char_interval=data.CharInterval(start_pos=13, end_pos=22),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="fever",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=4, end_index=5
|
|
),
|
|
char_interval=data.CharInterval(start_pos=24, end_pos=29),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="inflammation",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=7, end_index=8
|
|
),
|
|
char_interval=data.CharInterval(start_pos=35, end_pos=47),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=10, end_index=11
|
|
),
|
|
char_interval=data.CharInterval(start_pos=62, end_pos=70),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="ibuprofen",
|
|
token_interval=None,
|
|
char_interval=None,
|
|
alignment_status=None,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=12, end_index=13
|
|
),
|
|
char_interval=data.CharInterval(start_pos=72, end_pos=82),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"extraction_not_found",
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="aspirin"
|
|
)
|
|
]],
|
|
_SOURCE_TEXT_TWO_MEDS,
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="aspirin",
|
|
char_interval=None,
|
|
)
|
|
]],
|
|
),
|
|
(
|
|
"multiple_word_extraction_partially_matched",
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="high blood pressure",
|
|
)
|
|
]],
|
|
"Patient is prescribed high glucose.",
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="high blood pressure",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=4
|
|
),
|
|
alignment_status=data.AlignmentStatus.MATCH_LESSER,
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=26),
|
|
)
|
|
]],
|
|
),
|
|
(
|
|
"optimize_multiword_extractions_at_back",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn and prednisone",
|
|
)
|
|
],
|
|
],
|
|
_SOURCE_TEXT_TWO_MEDS,
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
token_interval=None,
|
|
char_interval=None,
|
|
alignment_status=None,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn and prednisone",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=6
|
|
),
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=45),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"optimize_multiword_extractions_at_front",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn and prednisone",
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
],
|
|
],
|
|
_SOURCE_TEXT_TWO_MEDS,
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn and prednisone",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=6
|
|
),
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=45),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
char_interval=None,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_en_dash_unicode_handling",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="word", extraction_text="Separated"
|
|
)
|
|
],
|
|
[data.Extraction(extraction_class="word", extraction_text="by")],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="word", extraction_text="en–dashes"
|
|
)
|
|
],
|
|
],
|
|
"Separated–by–en–dashes.",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="word",
|
|
extraction_text="Separated",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=0, end_index=1
|
|
),
|
|
char_interval=data.CharInterval(start_pos=0, end_pos=9),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="word",
|
|
extraction_text="by",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=2, end_index=3
|
|
),
|
|
char_interval=data.CharInterval(start_pos=10, end_pos=12),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="word",
|
|
extraction_text="en–dashes",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=4, end_index=7
|
|
),
|
|
char_interval=data.CharInterval(start_pos=13, end_pos=22),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"empty_source_text",
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
]],
|
|
"",
|
|
ValueError,
|
|
),
|
|
(
|
|
"special_characters_in_extractions",
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Napro-syn"
|
|
)
|
|
]],
|
|
"Patient is prescribed Napro-syn.",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Napro-syn",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=6
|
|
),
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=31),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_extraction_with_substring_of_another_not_matched",
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Napro"
|
|
)
|
|
]],
|
|
_SOURCE_TEXT_TWO_MEDS,
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Napro",
|
|
char_interval=None,
|
|
)
|
|
]],
|
|
),
|
|
(
|
|
"test_empty_extractions_list",
|
|
[],
|
|
_SOURCE_TEXT_TWO_MEDS,
|
|
[],
|
|
),
|
|
(
|
|
"test_extractions_with_similar_words",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Napro"
|
|
)
|
|
],
|
|
],
|
|
_SOURCE_TEXT_TWO_MEDS,
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=4
|
|
),
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=30),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Napro",
|
|
char_interval=None,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_source_text_with_repeated_extractions",
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
]],
|
|
"Patient is prescribed Naprosyn and Naprosyn.",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=4
|
|
),
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=30),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_interleaved_extractions",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition", extraction_text="arthritis"
|
|
)
|
|
],
|
|
],
|
|
"Patient with arthritis is prescribed Naprosyn.",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
char_interval=None,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="arthritis",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=2, end_index=3
|
|
),
|
|
char_interval=data.CharInterval(start_pos=13, end_pos=22),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"overlapping_extractions_different_types",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="Naprosyn allergy",
|
|
)
|
|
],
|
|
],
|
|
_SOURCE_TEXT_TWO_MEDS,
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=4
|
|
),
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=30),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="Naprosyn allergy",
|
|
char_interval=None,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_overlapping_text_extractions_with_overlapping_source",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition", extraction_text="high blood"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="blood pressure",
|
|
)
|
|
],
|
|
],
|
|
"Patient has high blood pressure.",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="high blood",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=2, end_index=4
|
|
),
|
|
char_interval=data.CharInterval(start_pos=12, end_pos=22),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="blood pressure",
|
|
char_interval=None,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_multiple_instances_same_extraction",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
)
|
|
],
|
|
],
|
|
"Naprosyn, prednisone, and again Naprosyn are prescribed.",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=0, end_index=1
|
|
),
|
|
char_interval=data.CharInterval(start_pos=0, end_pos=8),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=2, end_index=3
|
|
),
|
|
char_interval=data.CharInterval(start_pos=10, end_pos=20),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_longer_extraction_spanning_multiple_words",
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="rheumatoid arthritis",
|
|
)
|
|
]],
|
|
"Patient diagnosed with rheumatoid arthritis.",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="rheumatoid arthritis",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=5
|
|
),
|
|
char_interval=data.CharInterval(start_pos=23, end_pos=43),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_case_insensitivity",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="naprosyn"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="PREDNISONE",
|
|
)
|
|
],
|
|
],
|
|
_SOURCE_TEXT_TWO_MEDS.lower(),
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="naprosyn",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=4
|
|
),
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=30),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="PREDNISONE",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=5, end_index=6
|
|
),
|
|
char_interval=data.CharInterval(start_pos=35, end_pos=45),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"numerical_extractions",
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Ibuprofen 600mg",
|
|
)
|
|
]],
|
|
"Patient was given Ibuprofen 600mg twice daily.",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Ibuprofen 600mg",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=6
|
|
),
|
|
char_interval=data.CharInterval(start_pos=18, end_pos=33),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_extractions_spanning_across_sentence_boundaries",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Ibuprofen"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="instruction",
|
|
extraction_text="take with food",
|
|
)
|
|
],
|
|
],
|
|
"Take Ibuprofen. Always take with food.",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Ibuprofen",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=1, end_index=2
|
|
),
|
|
char_interval=data.CharInterval(start_pos=5, end_pos=14),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="instruction",
|
|
extraction_text="take with food",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=4, end_index=7
|
|
),
|
|
char_interval=data.CharInterval(start_pos=23, end_pos=37),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_multiple_multiword_extractions_multi_group",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="Naprosyn"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="frequency", extraction_text="as needed"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="reason", extraction_text="pain"
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="duration",
|
|
extraction_text="for one month",
|
|
)
|
|
],
|
|
],
|
|
_SOURCE_TEXT_MULTI_WORD_EXTRACTIONS,
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=4
|
|
),
|
|
char_interval=data.CharInterval(start_pos=18, end_pos=26),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="frequency",
|
|
extraction_text="as needed",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=4, end_index=6
|
|
),
|
|
char_interval=data.CharInterval(start_pos=27, end_pos=36),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="reason",
|
|
extraction_text="pain",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=7, end_index=8
|
|
),
|
|
char_interval=data.CharInterval(start_pos=41, end_pos=45),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=9, end_index=10
|
|
),
|
|
char_interval=data.CharInterval(start_pos=50, end_pos=60),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="duration",
|
|
extraction_text="for one month",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=10, end_index=13
|
|
),
|
|
char_interval=data.CharInterval(start_pos=61, end_pos=74),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"extraction_with_tokenizing_pipe_delimiter",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Napro | syn",
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
)
|
|
],
|
|
],
|
|
"Patient is prescribed Napro | syn and prednisone.",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Napro | syn",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=6
|
|
),
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=33),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=7, end_index=8
|
|
),
|
|
char_interval=data.CharInterval(start_pos=38, end_pos=48),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
)
|
|
],
|
|
],
|
|
),
|
|
(
|
|
"test_only_matching_end_does_not_align",
|
|
[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="some_class",
|
|
extraction_text="only matched end",
|
|
)
|
|
],
|
|
],
|
|
"end",
|
|
[[
|
|
data.Extraction(
|
|
extraction_class="some_class",
|
|
extraction_text="only matched end",
|
|
char_interval=None,
|
|
alignment_status=None,
|
|
)
|
|
]],
|
|
),
|
|
dict(
|
|
testcase_name="fuzzy_alignment_success",
|
|
# "heart problem" (singular, absent verbatim) exercises the
|
|
# fuzzy path via stemmed tokens; the longer extraction gets a
|
|
# lesser match at the 75% threshold.
|
|
extractions=[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="heart problem",
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="severe heart problems complications",
|
|
)
|
|
],
|
|
],
|
|
source_text="Patient has severe heart problems today.",
|
|
expected_output=[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="heart problem",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=5
|
|
),
|
|
char_interval=data.CharInterval(start_pos=19, end_pos=33),
|
|
alignment_status=data.AlignmentStatus.MATCH_FUZZY,
|
|
)
|
|
],
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="severe heart problems complications",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=2, end_index=5
|
|
),
|
|
char_interval=data.CharInterval(start_pos=12, end_pos=33),
|
|
alignment_status=data.AlignmentStatus.MATCH_LESSER,
|
|
)
|
|
],
|
|
],
|
|
enable_fuzzy_alignment=True,
|
|
),
|
|
dict(
|
|
testcase_name="fuzzy_alignment_below_threshold",
|
|
# Tests fuzzy alignment failure when overlap ratio < _FUZZY_ALIGNMENT_MIN_THRESHOLD (75%).
|
|
# No tokens overlap between "completely different medicine" and "Patient takes aspirin daily."
|
|
extractions=[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="completely different medicine",
|
|
)
|
|
],
|
|
],
|
|
source_text="Patient takes aspirin daily.",
|
|
expected_output=[[
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="completely different medicine",
|
|
char_interval=None,
|
|
alignment_status=None,
|
|
)
|
|
]],
|
|
enable_fuzzy_alignment=True,
|
|
),
|
|
dict(
|
|
testcase_name="accept_match_lesser_disabled",
|
|
# Tests accept_match_lesser=False with fuzzy fallback.
|
|
extractions=[
|
|
[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="patient heart problems today",
|
|
)
|
|
],
|
|
],
|
|
source_text="Patient has heart problems today.",
|
|
expected_output=[[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="patient heart problems today",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=0, end_index=5
|
|
),
|
|
char_interval=data.CharInterval(start_pos=0, end_pos=32),
|
|
alignment_status=data.AlignmentStatus.MATCH_FUZZY,
|
|
)
|
|
]],
|
|
enable_fuzzy_alignment=True,
|
|
accept_match_lesser=False,
|
|
),
|
|
dict(
|
|
testcase_name="fuzzy_alignment_subset_window",
|
|
# Extraction is a subset of a longer source clause; ensures extra tokens do not penalise score.
|
|
extractions=[[
|
|
data.Extraction(
|
|
extraction_class="tendon",
|
|
extraction_text="The iliopsoas tendon is intact",
|
|
)
|
|
]],
|
|
source_text=(
|
|
"The iliopsoas and proximal hamstring tendons are intact."
|
|
),
|
|
expected_output=[[
|
|
data.Extraction(
|
|
extraction_class="tendon",
|
|
extraction_text="The iliopsoas tendon is intact",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=0, end_index=8
|
|
),
|
|
char_interval=data.CharInterval(start_pos=0, end_pos=55),
|
|
alignment_status=data.AlignmentStatus.MATCH_FUZZY,
|
|
)
|
|
]],
|
|
enable_fuzzy_alignment=True,
|
|
accept_match_lesser=False,
|
|
),
|
|
dict(
|
|
testcase_name="fuzzy_alignment_with_reordered_words",
|
|
# Tests fuzzy alignment's ability to handle reordered words in the extraction.
|
|
extractions=[[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="problems heart", # Reordered words
|
|
char_interval=data.CharInterval(start_pos=12, end_pos=33),
|
|
alignment_status=data.AlignmentStatus.MATCH_FUZZY,
|
|
)
|
|
]],
|
|
source_text="Patient has severe heart problems today.",
|
|
expected_output=[[
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="problems heart",
|
|
# The best matching window in the source is "severe heart problems"
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=2, end_index=5
|
|
),
|
|
char_interval=data.CharInterval(start_pos=12, end_pos=33),
|
|
alignment_status=data.AlignmentStatus.MATCH_FUZZY,
|
|
)
|
|
]],
|
|
enable_fuzzy_alignment=True,
|
|
),
|
|
dict(
|
|
testcase_name="fuzzy_alignment_fails_low_ratio",
|
|
# An extraction that partially overlaps but is below the fuzzy threshold should not be aligned.
|
|
extractions=[[
|
|
data.Extraction(
|
|
extraction_class="symptom",
|
|
extraction_text="headache and fever",
|
|
)
|
|
]],
|
|
source_text="Patient reports back pain and a fever.",
|
|
expected_output=[[
|
|
data.Extraction(
|
|
extraction_class="symptom",
|
|
extraction_text="headache and fever",
|
|
char_interval=None,
|
|
alignment_status=None,
|
|
)
|
|
]],
|
|
enable_fuzzy_alignment=True,
|
|
),
|
|
dict(
|
|
testcase_name="fuzzy_alignment_partial_overlap_success",
|
|
extractions=[[
|
|
data.Extraction(
|
|
extraction_class="finding",
|
|
extraction_text="mild degenerative disc disease",
|
|
)
|
|
]],
|
|
source_text=(
|
|
"Findings consistent with degenerative disc disease at L5-S1."
|
|
),
|
|
expected_output=[[
|
|
data.Extraction(
|
|
extraction_class="finding",
|
|
extraction_text="mild degenerative disc disease",
|
|
token_interval=tokenizer.TokenInterval(
|
|
start_index=3, end_index=6
|
|
),
|
|
char_interval=data.CharInterval(start_pos=25, end_pos=50),
|
|
alignment_status=data.AlignmentStatus.MATCH_FUZZY,
|
|
)
|
|
]],
|
|
enable_fuzzy_alignment=True,
|
|
),
|
|
)
|
|
def test_extraction_alignment(
|
|
self,
|
|
extractions: Sequence[Sequence[data.Extraction]],
|
|
source_text: str,
|
|
expected_output: Sequence[Sequence[data.Extraction]] | ValueError,
|
|
enable_fuzzy_alignment: bool = False,
|
|
accept_match_lesser: bool = True,
|
|
):
|
|
if expected_output is ValueError:
|
|
with self.assertRaises(ValueError):
|
|
self.aligner.align_extractions(
|
|
extractions, source_text, enable_fuzzy_alignment=False
|
|
)
|
|
else:
|
|
aligned_extraction_groups = self.aligner.align_extractions(
|
|
extractions,
|
|
source_text,
|
|
enable_fuzzy_alignment=enable_fuzzy_alignment,
|
|
accept_match_lesser=accept_match_lesser,
|
|
)
|
|
flattened_extractions = []
|
|
for group in aligned_extraction_groups:
|
|
flattened_extractions.extend(group)
|
|
assert_char_interval_match_source(
|
|
self, source_text, flattened_extractions
|
|
)
|
|
self.assertEqual(aligned_extraction_groups, expected_output)
|
|
|
|
|
|
class ResolverTest(parameterized.TestCase):
|
|
_TWO_MEDICATIONS_JSON_UNDELIMITED = textwrap.dedent(f"""\
|
|
{{
|
|
"{data.EXTRACTIONS_KEY}": [
|
|
{{
|
|
"medication": "Naprosyn",
|
|
"medication_index": 4,
|
|
"frequency": "as needed",
|
|
"frequency_index": 5,
|
|
"reason": "pain",
|
|
"reason_index": 8
|
|
}},
|
|
{{
|
|
"medication": "prednisone",
|
|
"medication_index": 9,
|
|
"duration": "for one month",
|
|
"duration_index": 10
|
|
}}
|
|
]
|
|
}}""")
|
|
|
|
_TWO_MEDICATIONS_YAML_UNDELIMITED = textwrap.dedent(f"""\
|
|
{data.EXTRACTIONS_KEY}:
|
|
- medication: "Naprosyn"
|
|
medication_index: 4
|
|
frequency: "as needed"
|
|
frequency_index: 5
|
|
reason: "pain"
|
|
reason_index: 8
|
|
|
|
- medication: "prednisone"
|
|
medication_index: 9
|
|
duration: "for one month"
|
|
duration_index: 10
|
|
""")
|
|
|
|
_EXPECTED_TWO_MEDICATIONS_ANNOTATED = [
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="Naprosyn",
|
|
extraction_index=4,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="frequency",
|
|
extraction_text="as needed",
|
|
extraction_index=5,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="reason",
|
|
extraction_text="pain",
|
|
extraction_index=8,
|
|
group_index=0,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="prednisone",
|
|
extraction_index=9,
|
|
group_index=1,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="duration",
|
|
extraction_text="for one month",
|
|
extraction_index=10,
|
|
group_index=1,
|
|
),
|
|
]
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.default_resolver = resolver_lib.Resolver(
|
|
format_type=data.FormatType.JSON,
|
|
extraction_index_suffix=resolver_lib.DEFAULT_INDEX_SUFFIX,
|
|
)
|
|
|
|
@parameterized.named_parameters(
|
|
dict(
|
|
testcase_name="json_with_fence",
|
|
resolver=resolver_lib.Resolver(
|
|
fence_output=True,
|
|
format_type=data.FormatType.JSON,
|
|
extraction_index_suffix=resolver_lib.DEFAULT_INDEX_SUFFIX,
|
|
),
|
|
input_text=textwrap.dedent(f"""\
|
|
```json
|
|
{{
|
|
"{data.EXTRACTIONS_KEY}": [
|
|
{{
|
|
"medication": "Naprosyn",
|
|
"medication_index": 4,
|
|
"frequency": "as needed",
|
|
"frequency_index": 5,
|
|
"reason": "pain",
|
|
"reason_index": 8
|
|
}},
|
|
{{
|
|
"medication": "prednisone",
|
|
"medication_index": 9,
|
|
"duration": "for one month",
|
|
"duration_index": 10
|
|
}}
|
|
]
|
|
}}
|
|
```"""),
|
|
expected_output=_EXPECTED_TWO_MEDICATIONS_ANNOTATED,
|
|
),
|
|
dict(
|
|
testcase_name="yaml_with_fence",
|
|
resolver=resolver_lib.Resolver(
|
|
fence_output=True,
|
|
format_type=data.FormatType.YAML,
|
|
extraction_index_suffix=resolver_lib.DEFAULT_INDEX_SUFFIX,
|
|
),
|
|
input_text=textwrap.dedent(f"""\
|
|
```yaml
|
|
{data.EXTRACTIONS_KEY}:
|
|
- medication: "Naprosyn"
|
|
medication_index: 4
|
|
frequency: "as needed"
|
|
frequency_index: 5
|
|
reason: "pain"
|
|
reason_index: 8
|
|
|
|
- medication: "prednisone"
|
|
medication_index: 9
|
|
duration: "for one month"
|
|
duration_index: 10
|
|
```"""),
|
|
expected_output=_EXPECTED_TWO_MEDICATIONS_ANNOTATED,
|
|
),
|
|
dict(
|
|
testcase_name="json_no_fence",
|
|
resolver=resolver_lib.Resolver(
|
|
fence_output=False,
|
|
format_type=data.FormatType.JSON,
|
|
extraction_index_suffix=resolver_lib.DEFAULT_INDEX_SUFFIX,
|
|
),
|
|
input_text=_TWO_MEDICATIONS_JSON_UNDELIMITED,
|
|
expected_output=_EXPECTED_TWO_MEDICATIONS_ANNOTATED,
|
|
),
|
|
dict(
|
|
testcase_name="yaml_no_fence",
|
|
resolver=resolver_lib.Resolver(
|
|
fence_output=False,
|
|
format_type=data.FormatType.YAML,
|
|
extraction_index_suffix=resolver_lib.DEFAULT_INDEX_SUFFIX,
|
|
),
|
|
input_text=_TWO_MEDICATIONS_YAML_UNDELIMITED,
|
|
expected_output=_EXPECTED_TWO_MEDICATIONS_ANNOTATED,
|
|
),
|
|
)
|
|
def test_resolve_valid_inputs(self, resolver, input_text, expected_output):
|
|
actual_extractions = resolver.resolve(input_text)
|
|
self.assertCountEqual(expected_output, actual_extractions)
|
|
assert_char_interval_match_source(self, input_text, actual_extractions)
|
|
|
|
def test_handle_integer_extraction(self):
|
|
test_input = textwrap.dedent(f"""\
|
|
```json
|
|
{{
|
|
"{data.EXTRACTIONS_KEY}": [
|
|
{{
|
|
"year": 2006,
|
|
"year_index": 6
|
|
}}
|
|
]
|
|
}}
|
|
```""")
|
|
expected_extractions = [
|
|
data.Extraction(
|
|
extraction_class="year",
|
|
extraction_text="2006",
|
|
extraction_index=6,
|
|
group_index=0,
|
|
)
|
|
]
|
|
|
|
actual_extractions = self.default_resolver.resolve(test_input)
|
|
self.assertEqual(expected_extractions, list(actual_extractions))
|
|
|
|
def test_resolve_empty_yaml(self):
|
|
test_input = "```json\n```"
|
|
actual = self.default_resolver.resolve(
|
|
test_input, suppress_parse_errors=True
|
|
)
|
|
self.assertEmpty(actual)
|
|
|
|
def test_resolve_empty_yaml_without_suppress_parse_errors(self):
|
|
test_input = "```json\n```"
|
|
with self.assertRaises(resolver_lib.ResolverParsingError):
|
|
self.default_resolver.resolve(test_input, suppress_parse_errors=False)
|
|
|
|
@parameterized.named_parameters(
|
|
dict(
|
|
testcase_name="non_dict_attributes",
|
|
test_input=(
|
|
'```json\n{"extractions":'
|
|
' [{"entity": "test", "entity_index": 1,'
|
|
' "entity_attributes": "bad"}]}\n```'
|
|
),
|
|
),
|
|
dict(
|
|
testcase_name="malformed_key_trailing_colon",
|
|
test_input=(
|
|
'```json\n{"extractions":'
|
|
' [{"emotion": "joy", "emotion_index": 1,'
|
|
' "emotion_attributes:": {"intensity": "high"}}]}\n```'
|
|
),
|
|
),
|
|
)
|
|
def test_resolve_schema_error_suppressed(self, test_input):
|
|
"""Schema errors are suppressed with warning-only logging."""
|
|
with mock.patch("langextract.resolver.logging") as mock_log:
|
|
actual = self.default_resolver.resolve(
|
|
test_input, suppress_parse_errors=True
|
|
)
|
|
self.assertEmpty(actual)
|
|
mock_log.warning.assert_called()
|
|
log_msg = mock_log.warning.call_args[0][0]
|
|
self.assertIn("schema error", log_msg)
|
|
mock_log.error.assert_not_called()
|
|
|
|
def test_resolve_schema_error_raises_without_suppression(self):
|
|
"""Malformed attributes raise ResolverParsingError when not suppressed."""
|
|
test_input = (
|
|
'```json\n{"extractions":'
|
|
' [{"entity": "test", "entity_index": 1,'
|
|
' "entity_attributes": "bad"}]}\n```'
|
|
)
|
|
with self.assertRaises(resolver_lib.ResolverParsingError):
|
|
self.default_resolver.resolve(test_input, suppress_parse_errors=False)
|
|
|
|
def test_align_with_valid_chunk(self):
|
|
text = "This is a sample text with some extractions."
|
|
tokenized_text = tokenizer.tokenize(text)
|
|
|
|
chunk = tokenizer.TokenInterval(start_index=0, end_index=8)
|
|
annotated_extractions = [
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="sample"
|
|
),
|
|
data.Extraction(
|
|
extraction_class="condition", extraction_text="extractions"
|
|
),
|
|
]
|
|
expected_extractions = [
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="sample",
|
|
token_interval=tokenizer.TokenInterval(start_index=3, end_index=4),
|
|
char_interval=data.CharInterval(start_pos=10, end_pos=16),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="extractions",
|
|
token_interval=tokenizer.TokenInterval(start_index=7, end_index=8),
|
|
char_interval=data.CharInterval(start_pos=32, end_pos=43),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
),
|
|
]
|
|
|
|
chunk_text = chunking.get_token_interval_text(tokenized_text, chunk)
|
|
token_offset = chunk.start_index
|
|
aligned_extractions = list(
|
|
self.default_resolver.align(
|
|
extractions=annotated_extractions,
|
|
source_text=chunk_text,
|
|
token_offset=token_offset,
|
|
char_offset=0,
|
|
enable_fuzzy_alignment=False,
|
|
)
|
|
)
|
|
|
|
self.assertEqual(len(aligned_extractions), len(expected_extractions))
|
|
for expected, actual in zip(expected_extractions, aligned_extractions):
|
|
self.assertDataclassEqual(expected, actual)
|
|
assert_char_interval_match_source(self, text, aligned_extractions)
|
|
|
|
def test_align_with_chunk_starting_in_middle(self):
|
|
text = "This is a sample text with some extractions."
|
|
tokenized_text = tokenizer.tokenize(text)
|
|
|
|
chunk = tokenizer.TokenInterval(start_index=3, end_index=8)
|
|
annotated_extractions = [
|
|
data.Extraction(
|
|
extraction_class="medication", extraction_text="sample"
|
|
),
|
|
data.Extraction(
|
|
extraction_class="condition", extraction_text="extractions"
|
|
),
|
|
]
|
|
expected_extractions = [
|
|
data.Extraction(
|
|
extraction_class="medication",
|
|
extraction_text="sample",
|
|
token_interval=tokenizer.TokenInterval(start_index=3, end_index=4),
|
|
char_interval=data.CharInterval(start_pos=10, end_pos=16),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
),
|
|
data.Extraction(
|
|
extraction_class="condition",
|
|
extraction_text="extractions",
|
|
token_interval=tokenizer.TokenInterval(start_index=7, end_index=8),
|
|
char_interval=data.CharInterval(start_pos=32, end_pos=43),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
),
|
|
]
|
|
|
|
chunk_text = chunking.get_token_interval_text(tokenized_text, chunk)
|
|
token_offset = chunk.start_index
|
|
# Compute global char offset from the token at chunk.start_index.
|
|
char_offset = tokenized_text.tokens[
|
|
chunk.start_index
|
|
].char_interval.start_pos
|
|
aligned_extractions = list(
|
|
self.default_resolver.align(
|
|
extractions=annotated_extractions,
|
|
source_text=chunk_text,
|
|
token_offset=token_offset,
|
|
char_offset=char_offset,
|
|
enable_fuzzy_alignment=False,
|
|
)
|
|
)
|
|
|
|
self.assertEqual(len(aligned_extractions), len(expected_extractions))
|
|
for expected, actual in zip(expected_extractions, aligned_extractions):
|
|
self.assertDataclassEqual(expected, actual)
|
|
|
|
assert_char_interval_match_source(self, text, aligned_extractions)
|
|
|
|
def test_align_with_no_extractions_in_chunk(self):
|
|
tokenized_text = tokenizer.tokenize("No extractions here.")
|
|
|
|
# Define a chunk that includes the entire text.
|
|
chunk = tokenizer.TokenInterval()
|
|
chunk.start_index = 0
|
|
chunk.end_index = 3
|
|
annotated_extractions = []
|
|
|
|
chunk_text = chunking.get_token_interval_text(tokenized_text, chunk)
|
|
token_offset = chunk.start_index
|
|
aligned_extractions = list(
|
|
self.default_resolver.align(
|
|
extractions=annotated_extractions,
|
|
source_text=chunk_text,
|
|
token_offset=token_offset,
|
|
char_offset=0,
|
|
enable_fuzzy_alignment=False,
|
|
)
|
|
)
|
|
|
|
self.assertEmpty(aligned_extractions)
|
|
|
|
def test_align_successful(self):
|
|
tokenized_text = tokenizer.TokenizedText(
|
|
text="zero one two",
|
|
tokens=[
|
|
tokenizer.Token(
|
|
token_type=tokenizer.TokenType.WORD,
|
|
char_interval=tokenizer.CharInterval(start_pos=0, end_pos=4),
|
|
index=0,
|
|
),
|
|
tokenizer.Token(
|
|
token_type=tokenizer.TokenType.WORD,
|
|
char_interval=tokenizer.CharInterval(start_pos=5, end_pos=8),
|
|
index=1,
|
|
),
|
|
tokenizer.Token(
|
|
token_type=tokenizer.TokenType.WORD,
|
|
char_interval=tokenizer.CharInterval(start_pos=9, end_pos=12),
|
|
index=2,
|
|
),
|
|
],
|
|
)
|
|
|
|
# Define a chunk that includes the entire text.
|
|
chunk = tokenizer.TokenInterval(start_index=0, end_index=3)
|
|
annotated_extractions = [
|
|
data.Extraction(extraction_class="foo", extraction_text="zero"),
|
|
data.Extraction(extraction_class="foo", extraction_text="one"),
|
|
]
|
|
|
|
chunk_text = chunking.get_token_interval_text(tokenized_text, chunk)
|
|
token_offset = chunk.start_index
|
|
aligned_extractions = list(
|
|
self.default_resolver.align(
|
|
extractions=annotated_extractions,
|
|
source_text=chunk_text,
|
|
token_offset=token_offset,
|
|
char_offset=0,
|
|
enable_fuzzy_alignment=False,
|
|
)
|
|
)
|
|
|
|
self.assertLen(aligned_extractions, 2)
|
|
assert_char_interval_match_source(
|
|
self, tokenized_text.text, aligned_extractions
|
|
)
|
|
|
|
def test_align_with_discontinuous_tokenized_text(self):
|
|
tokenized_text = tokenizer.TokenizedText(
|
|
text="zero one five",
|
|
tokens=[
|
|
tokenizer.Token(
|
|
token_type=tokenizer.TokenType.WORD,
|
|
char_interval=tokenizer.CharInterval(start_pos=0, end_pos=4),
|
|
index=0,
|
|
),
|
|
tokenizer.Token(
|
|
token_type=tokenizer.TokenType.WORD,
|
|
char_interval=tokenizer.CharInterval(start_pos=5, end_pos=8),
|
|
index=1,
|
|
),
|
|
tokenizer.Token(
|
|
token_type=tokenizer.TokenType.WORD,
|
|
char_interval=tokenizer.CharInterval(start_pos=9, end_pos=14),
|
|
index=5,
|
|
),
|
|
],
|
|
)
|
|
|
|
# Define a chunk that includes too many tokens.
|
|
chunk = tokenizer.TokenInterval(start_index=0, end_index=6)
|
|
annotated_extractions = [
|
|
data.Extraction(extraction_class="foo", extraction_text="zero"),
|
|
data.Extraction(extraction_class="foo", extraction_text="one"),
|
|
]
|
|
|
|
with self.assertRaises(tokenizer.InvalidTokenIntervalError):
|
|
chunk_text = chunking.get_token_interval_text(tokenized_text, chunk)
|
|
token_offset = chunk.start_index
|
|
list(
|
|
self.default_resolver.align(
|
|
annotated_extractions,
|
|
chunk_text,
|
|
token_offset,
|
|
enable_fuzzy_alignment=False,
|
|
)
|
|
)
|
|
|
|
def test_align_with_discontinuous_tokenized_text_but_right_chunk(self):
|
|
tokenized_text = tokenizer.TokenizedText(
|
|
text="zero one five",
|
|
tokens=[
|
|
tokenizer.Token(
|
|
token_type=tokenizer.TokenType.WORD,
|
|
char_interval=tokenizer.CharInterval(start_pos=0, end_pos=4),
|
|
index=0,
|
|
),
|
|
tokenizer.Token(
|
|
token_type=tokenizer.TokenType.WORD,
|
|
char_interval=tokenizer.CharInterval(start_pos=5, end_pos=8),
|
|
index=1,
|
|
),
|
|
tokenizer.Token(
|
|
token_type=tokenizer.TokenType.WORD,
|
|
char_interval=tokenizer.CharInterval(start_pos=9, end_pos=14),
|
|
index=5,
|
|
),
|
|
],
|
|
)
|
|
|
|
# Define a correct chunk.
|
|
chunk = tokenizer.TokenInterval(start_index=0, end_index=3)
|
|
annotated_extractions = [
|
|
data.Extraction(extraction_class="foo", extraction_text="zero"),
|
|
data.Extraction(extraction_class="foo", extraction_text="one"),
|
|
]
|
|
|
|
chunk_text = chunking.get_token_interval_text(tokenized_text, chunk)
|
|
token_offset = chunk.start_index
|
|
aligned_extractions = list(
|
|
self.default_resolver.align(
|
|
extractions=annotated_extractions,
|
|
source_text=chunk_text,
|
|
token_offset=token_offset,
|
|
char_offset=0,
|
|
enable_fuzzy_alignment=False,
|
|
)
|
|
)
|
|
self.assertLen(aligned_extractions, 2)
|
|
assert_char_interval_match_source(
|
|
self, tokenized_text.text, aligned_extractions
|
|
)
|
|
|
|
def test_align_with_empty_annotated_extractions(self):
|
|
"""Test align method with empty annotated_extractions sequence."""
|
|
tokenized_text = tokenizer.tokenize("No extractions here.")
|
|
|
|
# Define a chunk that includes the entire text.
|
|
chunk = tokenizer.TokenInterval()
|
|
chunk.start_index = 0
|
|
chunk.end_index = 3
|
|
annotated_extractions = [] # Empty sequence representing no extractions
|
|
|
|
chunk_text = chunking.get_token_interval_text(tokenized_text, chunk)
|
|
token_offset = chunk.start_index
|
|
aligned_extractions = list(
|
|
self.default_resolver.align(
|
|
extractions=annotated_extractions,
|
|
source_text=chunk_text,
|
|
token_offset=token_offset,
|
|
char_offset=0,
|
|
enable_fuzzy_alignment=False,
|
|
)
|
|
)
|
|
|
|
self.assertEmpty(aligned_extractions)
|
|
|
|
|
|
class FenceFallbackTest(parameterized.TestCase):
|
|
"""Tests for fence marker fallback behavior."""
|
|
|
|
@parameterized.named_parameters(
|
|
dict(
|
|
testcase_name="with_valid_fences",
|
|
test_input=textwrap.dedent("""\
|
|
```json
|
|
{
|
|
"extractions": [
|
|
{"person": "Marie Curie", "person_attributes": {"field": "physics"}}
|
|
]
|
|
}
|
|
```"""),
|
|
fence_output=True,
|
|
strict_fences=False,
|
|
expected_key="person",
|
|
expected_value="Marie Curie",
|
|
),
|
|
dict(
|
|
testcase_name="fallback_no_fences",
|
|
test_input=textwrap.dedent("""\
|
|
{
|
|
"extractions": [
|
|
{"person": "Albert Einstein", "person_attributes": {"field": "physics"}}
|
|
]
|
|
}"""),
|
|
fence_output=True,
|
|
strict_fences=False,
|
|
expected_key="person",
|
|
expected_value="Albert Einstein",
|
|
),
|
|
dict(
|
|
testcase_name="no_fence_expectation",
|
|
test_input=textwrap.dedent("""\
|
|
{
|
|
"extractions": [
|
|
{"drug": "Aspirin", "drug_attributes": {"dosage": "100mg"}}
|
|
]
|
|
}"""),
|
|
fence_output=False,
|
|
strict_fences=False,
|
|
expected_key="drug",
|
|
expected_value="Aspirin",
|
|
),
|
|
)
|
|
def test_parsing_scenarios(
|
|
self,
|
|
test_input,
|
|
fence_output,
|
|
strict_fences,
|
|
expected_key,
|
|
expected_value,
|
|
):
|
|
resolver = resolver_lib.Resolver(
|
|
fence_output=fence_output,
|
|
format_type=data.FormatType.JSON,
|
|
strict_fences=strict_fences,
|
|
)
|
|
result = resolver.string_to_extraction_data(test_input)
|
|
self.assertLen(result, 1)
|
|
self.assertIn(expected_key, result[0])
|
|
self.assertEqual(result[0][expected_key], expected_value)
|
|
|
|
def test_fallback_preserves_content_integrity(self):
|
|
test_input = textwrap.dedent("""\
|
|
{
|
|
"extractions": [
|
|
{
|
|
"medication": "Ibuprofen",
|
|
"medication_attributes": {
|
|
"dosage": "200mg",
|
|
"frequency": "twice daily"
|
|
}
|
|
},
|
|
{
|
|
"condition": "headache",
|
|
"condition_attributes": {
|
|
"severity": "mild"
|
|
}
|
|
}
|
|
]
|
|
}""")
|
|
resolver = resolver_lib.Resolver(
|
|
fence_output=True,
|
|
format_type=data.FormatType.JSON,
|
|
strict_fences=False,
|
|
)
|
|
result = resolver.string_to_extraction_data(test_input)
|
|
self.assertLen(result, 2, "Should preserve all extractions during fallback")
|
|
|
|
self.assertEqual(
|
|
result[0]["medication"],
|
|
"Ibuprofen",
|
|
"First extraction should have correct medication",
|
|
)
|
|
self.assertEqual(
|
|
result[0]["medication_attributes"]["dosage"],
|
|
"200mg",
|
|
"Should preserve nested attributes in fallback",
|
|
)
|
|
|
|
self.assertEqual(
|
|
result[1]["condition"],
|
|
"headache",
|
|
"Second extraction should have correct condition",
|
|
)
|
|
self.assertEqual(
|
|
result[1]["condition_attributes"]["severity"],
|
|
"mild",
|
|
"Should preserve all nested attributes",
|
|
)
|
|
|
|
def test_malformed_json_still_raises_error(self):
|
|
test_input = textwrap.dedent("""\
|
|
{
|
|
"extractions": [
|
|
{"person": "Missing closing brace"
|
|
]""")
|
|
resolver = resolver_lib.Resolver(
|
|
fence_output=True,
|
|
format_type=data.FormatType.JSON,
|
|
strict_fences=False,
|
|
)
|
|
with self.assertRaises(resolver_lib.ResolverParsingError):
|
|
resolver.string_to_extraction_data(test_input)
|
|
|
|
def test_strict_fences_raises_on_missing_markers(self):
|
|
strict_resolver = resolver_lib.Resolver(
|
|
fence_output=True,
|
|
format_type=data.FormatType.JSON,
|
|
strict_fences=True,
|
|
)
|
|
test_input = textwrap.dedent("""\
|
|
{"extractions": [{"person": "Test"}]}""")
|
|
|
|
with self.assertRaisesRegex(
|
|
resolver_lib.ResolverParsingError, ".*fence markers.*"
|
|
):
|
|
strict_resolver.string_to_extraction_data(test_input)
|
|
|
|
def test_default_allows_fallback(self):
|
|
default_resolver = resolver_lib.Resolver(
|
|
fence_output=True,
|
|
format_type=data.FormatType.JSON,
|
|
)
|
|
test_input = textwrap.dedent("""\
|
|
{"extractions": [{"person": "Default Test"}]}""")
|
|
|
|
result = default_resolver.string_to_extraction_data(test_input)
|
|
self.assertLen(result, 1)
|
|
self.assertEqual(result[0]["person"], "Default Test")
|
|
|
|
def test_rejects_multiple_fenced_blocks(self):
|
|
test_input = textwrap.dedent("""\
|
|
preamble
|
|
```json
|
|
{"extractions": [{"item": "first"}]}
|
|
```
|
|
Some explanation text
|
|
```json
|
|
{"extractions": [{"item": "second"}]}
|
|
```""")
|
|
resolver = resolver_lib.Resolver(
|
|
fence_output=True,
|
|
format_type=data.FormatType.JSON,
|
|
strict_fences=False,
|
|
)
|
|
with self.assertRaisesRegex(
|
|
resolver_lib.ResolverParsingError, "Multiple fenced blocks found"
|
|
):
|
|
resolver.string_to_extraction_data(test_input)
|
|
|
|
|
|
class FlexibleSchemaTest(parameterized.TestCase):
|
|
"""Tests for flexible schema formats without extractions key."""
|
|
|
|
def test_direct_list_format(self):
|
|
test_input = textwrap.dedent("""\
|
|
[
|
|
{"person": "Marie Curie", "field": "physics"},
|
|
{"person": "Albert Einstein", "field": "relativity"}
|
|
]""")
|
|
resolver = resolver_lib.Resolver(
|
|
fence_output=False,
|
|
format_type=data.FormatType.JSON,
|
|
require_extractions_key=False,
|
|
)
|
|
result = resolver.string_to_extraction_data(test_input)
|
|
self.assertLen(result, 2)
|
|
self.assertEqual(result[0]["person"], "Marie Curie")
|
|
self.assertEqual(result[1]["person"], "Albert Einstein")
|
|
|
|
def test_single_dict_as_extraction(self):
|
|
test_input = '{"person": "Isaac Newton", "field": "gravity"}'
|
|
resolver = resolver_lib.Resolver(
|
|
fence_output=False,
|
|
format_type=data.FormatType.JSON,
|
|
require_extractions_key=False,
|
|
)
|
|
result = resolver.string_to_extraction_data(test_input)
|
|
self.assertLen(result, 1)
|
|
self.assertEqual(result[0]["person"], "Isaac Newton")
|
|
self.assertEqual(result[0]["field"], "gravity")
|
|
|
|
def test_traditional_format_still_works(self):
|
|
test_input = textwrap.dedent("""\
|
|
{
|
|
"extractions": [
|
|
{"person": "Charles Darwin", "field": "evolution"}
|
|
]
|
|
}""")
|
|
resolver = resolver_lib.Resolver(
|
|
fence_output=False,
|
|
format_type=data.FormatType.JSON,
|
|
require_extractions_key=False,
|
|
)
|
|
result = resolver.string_to_extraction_data(test_input)
|
|
self.assertLen(result, 1)
|
|
self.assertEqual(result[0]["person"], "Charles Darwin")
|
|
|
|
def test_lenient_mode_accepts_list(self):
|
|
# Some models return [...] instead of {"extractions": [...]}
|
|
test_input = '[{"person": "Test"}]'
|
|
resolver = resolver_lib.Resolver(
|
|
fence_output=False,
|
|
format_type=data.FormatType.JSON,
|
|
require_extractions_key=True,
|
|
)
|
|
result = resolver.string_to_extraction_data(test_input)
|
|
self.assertLen(result, 1)
|
|
self.assertEqual(result[0]["person"], "Test")
|
|
|
|
def test_flexible_with_attributes(self):
|
|
test_input = textwrap.dedent("""\
|
|
[
|
|
{
|
|
"medication": "Aspirin",
|
|
"medication_attributes": {"dosage": "100mg", "frequency": "daily"}
|
|
},
|
|
{
|
|
"medication": "Ibuprofen",
|
|
"medication_attributes": {"dosage": "200mg"}
|
|
}
|
|
]""")
|
|
resolver = resolver_lib.Resolver(
|
|
fence_output=False,
|
|
format_type=data.FormatType.JSON,
|
|
require_extractions_key=False,
|
|
)
|
|
result = resolver.string_to_extraction_data(test_input)
|
|
self.assertLen(result, 2)
|
|
self.assertEqual(result[0]["medication"], "Aspirin")
|
|
self.assertEqual(result[0]["medication_attributes"]["dosage"], "100mg")
|
|
self.assertEqual(result[1]["medication"], "Ibuprofen")
|
|
|
|
|
|
def _entity(text: str) -> data.Extraction:
|
|
return data.Extraction(extraction_class="entity", extraction_text=text)
|
|
|
|
|
|
class MonotonicExactAlignmentTest(parameterized.TestCase):
|
|
"""Exact-phase DP: repeated mentions align to successive occurrences."""
|
|
|
|
_CLINICAL_NOTE = textwrap.dedent("""\
|
|
CLINICAL NOTE
|
|
Patient: John Smith DOB: 03/14/1962
|
|
Chief Complaint: Follow-up of hypertension and type 2 diabetes.
|
|
History: The patient reports good adherence to lisinopril 20 mg
|
|
daily. Blood pressure remains elevated at 148/92 today. Home logs
|
|
show fasting values of 130-150 mg/dL on metformin 1000 mg twice
|
|
daily. He continues atorvastatin 40 mg nightly for hyperlipidemia.
|
|
Assessment and Plan:
|
|
1. Hypertension, uncontrolled. Increase lisinopril to 40 mg daily.
|
|
2. Type 2 diabetes, above goal. Start empagliflozin 10 mg daily.
|
|
3. Hyperlipidemia, stable. Continue atorvastatin.
|
|
""")
|
|
|
|
_LONG_SEED_SOURCE = (
|
|
"alpha beta gamma delta was noted. omega followed. Later, alpha"
|
|
" beta gamma delta recurred."
|
|
)
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.aligner = resolver_lib.WordAligner()
|
|
|
|
def _align(self, texts, source, **kwargs):
|
|
groups = self.aligner.align_extractions(
|
|
[[_entity(t)] for t in texts], source, **kwargs
|
|
)
|
|
return [e for group in groups for e in group]
|
|
|
|
def test_repeated_mention_aligns_to_successive_occurrences(self):
|
|
source = "Take aspirin in the morning. Take aspirin at night."
|
|
first_start = source.index("aspirin")
|
|
second_start = source.index("aspirin", first_start + 1)
|
|
|
|
first, second = self._align(["aspirin", "aspirin"], source)
|
|
|
|
self.assertIs(first.alignment_status, data.AlignmentStatus.MATCH_EXACT)
|
|
self.assertIs(second.alignment_status, data.AlignmentStatus.MATCH_EXACT)
|
|
self.assertEqual(first.char_interval.start_pos, first_start)
|
|
self.assertEqual(second.char_interval.start_pos, second_start)
|
|
|
|
def test_clinical_note_duplicates_align_to_assessment_section(self):
|
|
# Model output order tracks document order, so a fully monotonic
|
|
# assignment exists; the greedy difflib exact phase still pins the
|
|
# duplicated mentions to their first (History section) occurrences.
|
|
source = self._CLINICAL_NOTE
|
|
texts = [
|
|
"lisinopril",
|
|
"148/92",
|
|
"metformin",
|
|
"atorvastatin",
|
|
"Hypertension",
|
|
"lisinopril",
|
|
"Type 2 diabetes",
|
|
"empagliflozin",
|
|
"Hyperlipidemia",
|
|
]
|
|
expected_starts = [
|
|
source.index("lisinopril 20 mg"),
|
|
source.index("148/92"),
|
|
source.index("metformin"),
|
|
source.index("atorvastatin 40 mg"),
|
|
source.index("Hypertension, uncontrolled"),
|
|
source.index("lisinopril to 40 mg"),
|
|
source.index("Type 2 diabetes, above"),
|
|
source.index("empagliflozin"),
|
|
source.index("Hyperlipidemia, stable"),
|
|
]
|
|
|
|
aligned = self._align(texts, source)
|
|
|
|
self.assertLen(aligned, len(texts))
|
|
for extraction, start in zip(aligned, expected_starts, strict=True):
|
|
with self.subTest(text=extraction.extraction_text, start=start):
|
|
self.assertIs(
|
|
extraction.alignment_status, data.AlignmentStatus.MATCH_EXACT
|
|
)
|
|
self.assertEqual(extraction.char_interval.start_pos, start)
|
|
|
|
def test_long_mention_seed_does_not_break_earlier_extractions(self):
|
|
source = self._LONG_SEED_SOURCE
|
|
|
|
omega, long_mention = self._align(
|
|
["omega", "alpha beta gamma delta"], source
|
|
)
|
|
|
|
self.assertIs(omega.alignment_status, data.AlignmentStatus.MATCH_EXACT)
|
|
self.assertIs(
|
|
long_mention.alignment_status, data.AlignmentStatus.MATCH_EXACT
|
|
)
|
|
self.assertEqual(omega.char_interval.start_pos, source.index("omega"))
|
|
self.assertEqual(
|
|
long_mention.char_interval.start_pos,
|
|
source.index("alpha beta gamma delta recurred"),
|
|
)
|
|
|
|
def test_difflib_escape_hatch_preserves_legacy_behavior(self):
|
|
source = self._LONG_SEED_SOURCE
|
|
|
|
omega, long_mention = self._align(
|
|
["omega", "alpha beta gamma delta"],
|
|
source,
|
|
exact_alignment_algorithm="difflib",
|
|
)
|
|
|
|
self.assertIs(
|
|
long_mention.alignment_status, data.AlignmentStatus.MATCH_EXACT
|
|
)
|
|
self.assertEqual(
|
|
long_mention.char_interval.start_pos, source.index("alpha")
|
|
)
|
|
self.assertIs(omega.alignment_status, data.AlignmentStatus.MATCH_FUZZY)
|
|
|
|
def test_duplicate_extractions_with_single_occurrence_share_span(self):
|
|
source = "Only aspirin appears here."
|
|
|
|
first, second = self._align(["aspirin", "aspirin"], source)
|
|
|
|
self.assertIs(first.alignment_status, data.AlignmentStatus.MATCH_EXACT)
|
|
self.assertIs(second.alignment_status, data.AlignmentStatus.MATCH_FUZZY)
|
|
self.assertEqual(
|
|
second.char_interval.start_pos, first.char_interval.start_pos
|
|
)
|
|
self.assertEqual(second.char_interval.end_pos, first.char_interval.end_pos)
|
|
|
|
def test_out_of_order_extractions_fall_back_gracefully(self):
|
|
source = "aspirin was given before ibuprofen was considered."
|
|
|
|
aligned = self._align(["ibuprofen", "aspirin"], source)
|
|
|
|
for extraction in aligned:
|
|
with self.subTest(text=extraction.extraction_text):
|
|
self.assertIsNotNone(extraction.alignment_status)
|
|
ci = extraction.char_interval
|
|
self.assertEqual(
|
|
source[ci.start_pos : ci.end_pos].lower(),
|
|
extraction.extraction_text.lower(),
|
|
)
|
|
|
|
def test_exact_span_upgraded_over_competing_lesser_extraction(self):
|
|
# "heart problems" is present verbatim; under the legacy difflib
|
|
# phase the longer extraction consumed the region and the verbatim
|
|
# span was demoted to MATCH_FUZZY.
|
|
source = "Patient has severe heart problems today."
|
|
|
|
short, longer = self._align(
|
|
["heart problems", "severe heart problems complications"], source
|
|
)
|
|
|
|
self.assertIs(short.alignment_status, data.AlignmentStatus.MATCH_EXACT)
|
|
self.assertEqual(short.char_interval.start_pos, source.index("heart"))
|
|
self.assertIs(longer.alignment_status, data.AlignmentStatus.MATCH_LESSER)
|
|
self.assertEqual(longer.char_interval.start_pos, source.index("severe"))
|
|
|
|
def test_invalid_exact_alignment_algorithm_raises(self):
|
|
with self.assertRaisesRegex(
|
|
ValueError, "Invalid exact_alignment_algorithm"
|
|
):
|
|
self._align(
|
|
["aspirin"],
|
|
"aspirin taken daily.",
|
|
exact_alignment_algorithm="dP",
|
|
)
|
|
|
|
def test_offsets_applied_to_dp_matches(self):
|
|
source = "aspirin taken daily."
|
|
|
|
(extraction,) = self._align(
|
|
["aspirin"], source, token_offset=7, char_offset=100
|
|
)
|
|
|
|
self.assertIs(extraction.alignment_status, data.AlignmentStatus.MATCH_EXACT)
|
|
self.assertEqual(extraction.token_interval.start_index, 7)
|
|
self.assertEqual(extraction.char_interval.start_pos, 100)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
absltest.main()
|