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
954 lines
29 KiB
Python
954 lines
29 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.
|
|
|
|
"""Tests for the main package functions in __init__.py."""
|
|
|
|
import textwrap
|
|
from unittest import mock
|
|
import warnings
|
|
|
|
from absl.testing import absltest
|
|
from absl.testing import parameterized
|
|
|
|
from langextract import prompting
|
|
import langextract as lx
|
|
from langextract.core import base_model
|
|
from langextract.core import data
|
|
from langextract.core import format_handler as fh
|
|
from langextract.core import schema
|
|
from langextract.core import types
|
|
from langextract.providers import schemas
|
|
|
|
|
|
class InitTest(parameterized.TestCase):
|
|
"""Test cases for the main package functions."""
|
|
|
|
@mock.patch.object(
|
|
schemas.gemini.GeminiSchema, "from_examples", autospec=True
|
|
)
|
|
@mock.patch("langextract.extraction.factory.create_model")
|
|
def test_lang_extract_as_lx_extract(
|
|
self, mock_create_model, mock_gemini_schema
|
|
):
|
|
|
|
input_text = "Patient takes Aspirin 100mg every morning."
|
|
|
|
mock_model = mock.MagicMock()
|
|
mock_model.infer.return_value = [[
|
|
types.ScoredOutput(
|
|
output=textwrap.dedent("""\
|
|
```json
|
|
{
|
|
"extractions": [
|
|
{
|
|
"entity": "Aspirin",
|
|
"entity_attributes": {
|
|
"class": "medication"
|
|
}
|
|
},
|
|
{
|
|
"entity": "100mg",
|
|
"entity_attributes": {
|
|
"frequency": "every morning",
|
|
"class": "dosage"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```"""),
|
|
score=0.9,
|
|
)
|
|
]]
|
|
|
|
mock_model.requires_fence_output = True
|
|
mock_create_model.return_value = mock_model
|
|
|
|
mock_gemini_schema.return_value = None
|
|
|
|
expected_result = data.AnnotatedDocument(
|
|
document_id=None,
|
|
extractions=[
|
|
data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="Aspirin",
|
|
char_interval=data.CharInterval(start_pos=14, end_pos=21),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
extraction_index=1,
|
|
group_index=0,
|
|
description=None,
|
|
attributes={"class": "medication"},
|
|
),
|
|
data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="100mg",
|
|
char_interval=data.CharInterval(start_pos=22, end_pos=27),
|
|
alignment_status=data.AlignmentStatus.MATCH_EXACT,
|
|
extraction_index=2,
|
|
group_index=1,
|
|
description=None,
|
|
attributes={"frequency": "every morning", "class": "dosage"},
|
|
),
|
|
],
|
|
text="Patient takes Aspirin 100mg every morning.",
|
|
)
|
|
|
|
mock_description = textwrap.dedent("""\
|
|
Extract medication and dosage information in order of occurrence.
|
|
""")
|
|
|
|
mock_examples = [
|
|
lx.data.ExampleData(
|
|
text="Patient takes Tylenol 500mg daily.",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="Tylenol",
|
|
attributes={
|
|
"type": "analgesic",
|
|
"class": "medication",
|
|
},
|
|
),
|
|
],
|
|
)
|
|
]
|
|
mock_prompt_template = prompting.PromptTemplateStructured(
|
|
description=mock_description, examples=mock_examples
|
|
)
|
|
|
|
format_handler = fh.FormatHandler(
|
|
format_type=data.FormatType.JSON,
|
|
use_wrapper=True,
|
|
wrapper_key="extractions",
|
|
use_fences=True,
|
|
)
|
|
|
|
prompt_generator = prompting.QAPromptGenerator(
|
|
template=mock_prompt_template, format_handler=format_handler
|
|
)
|
|
|
|
actual_result = lx.extract(
|
|
text_or_documents=input_text,
|
|
prompt_description=mock_description,
|
|
examples=mock_examples,
|
|
api_key="some_api_key",
|
|
fence_output=True,
|
|
use_schema_constraints=False,
|
|
)
|
|
|
|
mock_gemini_schema.assert_not_called()
|
|
mock_create_model.assert_called_once()
|
|
mock_model.infer.assert_called_once_with(
|
|
batch_prompts=[prompt_generator.render(input_text)],
|
|
max_workers=10,
|
|
)
|
|
|
|
self.assertDataclassEqual(expected_result, actual_result)
|
|
|
|
@mock.patch("langextract.extraction.resolver.Resolver.align")
|
|
@mock.patch("langextract.extraction.factory.create_model")
|
|
def test_extract_resolver_params_alignment_passthrough(
|
|
self, mock_create_model, mock_align
|
|
):
|
|
mock_model = mock.MagicMock()
|
|
mock_model.infer.return_value = [
|
|
[types.ScoredOutput(output='{"extractions":[]}')]
|
|
]
|
|
mock_model.requires_fence_output = False
|
|
mock_create_model.return_value = mock_model
|
|
mock_align.return_value = []
|
|
|
|
mock_examples = [
|
|
lx.data.ExampleData(
|
|
text="Patient takes Tylenol 500mg daily.",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="Tylenol",
|
|
attributes={
|
|
"type": "analgesic",
|
|
"class": "medication",
|
|
},
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
lx.extract(
|
|
text_or_documents="test text",
|
|
prompt_description="desc",
|
|
examples=mock_examples,
|
|
api_key="test_key",
|
|
resolver_params={
|
|
"enable_fuzzy_alignment": False,
|
|
"fuzzy_alignment_threshold": 0.8,
|
|
"accept_match_lesser": False,
|
|
},
|
|
)
|
|
|
|
mock_align.assert_called()
|
|
_, kwargs = mock_align.call_args
|
|
self.assertFalse(kwargs.get("enable_fuzzy_alignment"))
|
|
self.assertEqual(kwargs.get("fuzzy_alignment_threshold"), 0.8)
|
|
self.assertFalse(kwargs.get("accept_match_lesser"))
|
|
|
|
@mock.patch("langextract.annotation.Annotator.annotate_text")
|
|
@mock.patch("langextract.extraction.factory.create_model")
|
|
def test_extract_resolver_params_suppress_parse_errors(
|
|
self, mock_create_model, mock_annotate
|
|
):
|
|
"""Test that suppress_parse_errors can be passed through resolver_params."""
|
|
mock_model = mock.MagicMock()
|
|
mock_model.requires_fence_output = False
|
|
mock_model.schema = None
|
|
mock_create_model.return_value = mock_model
|
|
|
|
mock_annotate.return_value = lx.data.AnnotatedDocument(
|
|
text="test", extractions=[]
|
|
)
|
|
|
|
mock_examples = [
|
|
lx.data.ExampleData(
|
|
text="Example text",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="example",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
# This should not raise a TypeError about unknown key
|
|
lx.extract(
|
|
text_or_documents="test text",
|
|
prompt_description="desc",
|
|
examples=mock_examples,
|
|
api_key="test_key",
|
|
resolver_params={
|
|
"suppress_parse_errors": True,
|
|
"enable_fuzzy_alignment": False,
|
|
},
|
|
)
|
|
|
|
mock_annotate.assert_called()
|
|
_, kwargs = mock_annotate.call_args
|
|
self.assertIn("suppress_parse_errors", kwargs)
|
|
self.assertTrue(kwargs.get("suppress_parse_errors"))
|
|
self.assertFalse(kwargs.get("enable_fuzzy_alignment"))
|
|
|
|
@parameterized.named_parameters(
|
|
dict(
|
|
testcase_name="default_true",
|
|
resolver_params=None,
|
|
expected=True,
|
|
),
|
|
dict(
|
|
testcase_name="caller_override_false",
|
|
resolver_params={"suppress_parse_errors": False},
|
|
expected=False,
|
|
),
|
|
)
|
|
@mock.patch("langextract.annotation.Annotator.annotate_text", autospec=True)
|
|
@mock.patch("langextract.extraction.factory.create_model", autospec=True)
|
|
def test_extract_suppress_parse_errors_routing(
|
|
self, mock_create_model, mock_annotate, resolver_params, expected
|
|
):
|
|
mock_model = mock.MagicMock()
|
|
mock_model.requires_fence_output = False
|
|
mock_model.schema = None
|
|
mock_create_model.return_value = mock_model
|
|
mock_annotate.return_value = lx.data.AnnotatedDocument(
|
|
text="test", extractions=[]
|
|
)
|
|
mock_examples = [
|
|
lx.data.ExampleData(
|
|
text="Example text",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="example",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
extract_kwargs = {
|
|
"text_or_documents": "test text",
|
|
"prompt_description": "desc",
|
|
"examples": mock_examples,
|
|
"api_key": "test_key",
|
|
}
|
|
if resolver_params is not None:
|
|
extract_kwargs["resolver_params"] = resolver_params
|
|
|
|
lx.extract(**extract_kwargs)
|
|
|
|
mock_annotate.assert_called()
|
|
_, kwargs = mock_annotate.call_args
|
|
self.assertEqual(kwargs.get("suppress_parse_errors"), expected)
|
|
|
|
@mock.patch("langextract.extraction.resolver.Resolver")
|
|
@mock.patch("langextract.extraction.factory.create_model")
|
|
def test_extract_resolver_params_none_handling(
|
|
self, mock_create_model, mock_resolver_class
|
|
):
|
|
mock_model = mock.MagicMock()
|
|
mock_model.infer.return_value = [
|
|
[types.ScoredOutput(output='{"extractions":[]}')]
|
|
]
|
|
mock_model.requires_fence_output = False
|
|
mock_create_model.return_value = mock_model
|
|
|
|
mock_resolver = mock.MagicMock()
|
|
mock_resolver_class.return_value = mock_resolver
|
|
|
|
mock_examples = [
|
|
lx.data.ExampleData(
|
|
text="Test text",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="test",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
with mock.patch(
|
|
"langextract.annotation.Annotator.annotate_text"
|
|
) as mock_annotate:
|
|
mock_annotate.return_value = lx.data.AnnotatedDocument(
|
|
text="test", extractions=[]
|
|
)
|
|
|
|
lx.extract(
|
|
text_or_documents="test text",
|
|
prompt_description="desc",
|
|
examples=mock_examples,
|
|
api_key="test_key",
|
|
resolver_params={
|
|
"enable_fuzzy_alignment": None,
|
|
"fuzzy_alignment_threshold": 0.8,
|
|
},
|
|
)
|
|
|
|
_, resolver_kwargs = mock_resolver_class.call_args
|
|
self.assertNotIn("enable_fuzzy_alignment", resolver_kwargs)
|
|
self.assertNotIn("fuzzy_alignment_threshold", resolver_kwargs)
|
|
self.assertIn("format_handler", resolver_kwargs)
|
|
|
|
_, annotate_kwargs = mock_annotate.call_args
|
|
self.assertNotIn("enable_fuzzy_alignment", annotate_kwargs)
|
|
self.assertEqual(annotate_kwargs["fuzzy_alignment_threshold"], 0.8)
|
|
|
|
@mock.patch("langextract.extraction.factory.create_model")
|
|
def test_extract_resolver_params_typo_error(self, mock_create_model):
|
|
mock_model = mock.MagicMock()
|
|
mock_model.requires_fence_output = False
|
|
mock_create_model.return_value = mock_model
|
|
|
|
mock_examples = [
|
|
lx.data.ExampleData(
|
|
text="Test",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="test",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
with self.assertRaisesRegex(TypeError, "Unknown key in resolver_params"):
|
|
lx.extract(
|
|
text_or_documents="test",
|
|
prompt_description="desc",
|
|
examples=mock_examples,
|
|
api_key="test_key",
|
|
resolver_params={
|
|
"fuzzy_alignment_treshold": ( # Typo: treshold instead of threshold
|
|
0.5
|
|
),
|
|
},
|
|
)
|
|
|
|
@mock.patch("langextract.annotation.Annotator.annotate_documents")
|
|
@mock.patch("langextract.extraction.factory.create_model")
|
|
def test_extract_resolver_params_docs_path_passthrough(
|
|
self, mock_create_model, mock_annotate_docs
|
|
):
|
|
mock_model = mock.MagicMock()
|
|
mock_model.infer.return_value = [
|
|
[types.ScoredOutput(output='{"extractions":[]}')]
|
|
]
|
|
mock_model.requires_fence_output = False
|
|
mock_create_model.return_value = mock_model
|
|
mock_annotate_docs.return_value = []
|
|
|
|
docs = [lx.data.Document(text="doc1")]
|
|
examples = [
|
|
lx.data.ExampleData(
|
|
text="Example text",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="example",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
lx.extract(
|
|
text_or_documents=docs,
|
|
prompt_description="desc",
|
|
examples=examples,
|
|
api_key="k",
|
|
resolver_params={
|
|
"enable_fuzzy_alignment": False,
|
|
"fuzzy_alignment_threshold": 0.9,
|
|
"accept_match_lesser": False,
|
|
},
|
|
)
|
|
|
|
_, kwargs = mock_annotate_docs.call_args
|
|
self.assertFalse(kwargs.get("enable_fuzzy_alignment"))
|
|
self.assertEqual(kwargs.get("fuzzy_alignment_threshold"), 0.9)
|
|
self.assertFalse(kwargs.get("accept_match_lesser"))
|
|
|
|
@mock.patch("langextract.annotation.Annotator.annotate_text")
|
|
@mock.patch("langextract.extraction.resolver.Resolver")
|
|
@mock.patch("langextract.extraction.factory.create_model")
|
|
def test_extract_resolver_params_none_threshold(
|
|
self, mock_create_model, mock_resolver_cls, mock_annotate
|
|
):
|
|
mock_model = mock.MagicMock()
|
|
mock_model.infer.return_value = [
|
|
[types.ScoredOutput(output='{"extractions":[]}')]
|
|
]
|
|
mock_model.requires_fence_output = False
|
|
mock_create_model.return_value = mock_model
|
|
mock_resolver_cls.return_value = mock.MagicMock()
|
|
mock_annotate.return_value = lx.data.AnnotatedDocument(
|
|
text="t", extractions=[]
|
|
)
|
|
|
|
lx.extract(
|
|
text_or_documents="t",
|
|
prompt_description="d",
|
|
examples=[
|
|
lx.data.ExampleData(
|
|
text="example",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="ex",
|
|
),
|
|
],
|
|
)
|
|
],
|
|
api_key="k",
|
|
resolver_params={"fuzzy_alignment_threshold": None},
|
|
)
|
|
|
|
_, resolver_kwargs = mock_resolver_cls.call_args
|
|
self.assertNotIn("fuzzy_alignment_threshold", resolver_kwargs)
|
|
|
|
_, annotate_kwargs = mock_annotate.call_args
|
|
self.assertNotIn("fuzzy_alignment_threshold", annotate_kwargs)
|
|
|
|
@mock.patch.object(
|
|
schemas.gemini.GeminiSchema, "from_examples", autospec=True
|
|
)
|
|
@mock.patch("langextract.extraction.factory.create_model")
|
|
def test_extract_custom_params_reach_inference(
|
|
self, mock_create_model, mock_gemini_schema
|
|
):
|
|
"""Sanity check that custom parameters reach the inference layer."""
|
|
input_text = "Test text"
|
|
|
|
mock_model = mock.MagicMock()
|
|
mock_model.infer.return_value = [[
|
|
types.ScoredOutput(
|
|
output='```json\n{"extractions": []}\n```',
|
|
score=0.9,
|
|
)
|
|
]]
|
|
|
|
mock_model.requires_fence_output = True
|
|
mock_create_model.return_value = mock_model
|
|
mock_gemini_schema.return_value = None
|
|
|
|
mock_examples = [
|
|
lx.data.ExampleData(
|
|
text="Example",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="test",
|
|
extraction_text="example",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
lx.extract(
|
|
text_or_documents=input_text,
|
|
prompt_description="Test extraction",
|
|
examples=mock_examples,
|
|
api_key="test_key",
|
|
max_workers=5,
|
|
fence_output=True,
|
|
use_schema_constraints=False,
|
|
)
|
|
|
|
mock_model.infer.assert_called_once()
|
|
_, kwargs = mock_model.infer.call_args
|
|
self.assertEqual(kwargs.get("max_workers"), 5)
|
|
|
|
@mock.patch("langextract.extraction.factory.create_model")
|
|
def test_extract_with_custom_tokenizer(self, mock_create_model):
|
|
"""Test that a custom tokenizer can be passed to extract()."""
|
|
input_text = "Test text"
|
|
mock_model = mock.MagicMock()
|
|
mock_model.infer.return_value = [[
|
|
types.ScoredOutput(
|
|
output='```json\n{"extractions": []}\n```',
|
|
score=0.9,
|
|
)
|
|
]]
|
|
mock_model.requires_fence_output = True
|
|
mock_create_model.return_value = mock_model
|
|
|
|
def mock_tokenize(text):
|
|
if text == "\u241F": # Delimiter
|
|
return lx.tokenizer.TokenizedText(
|
|
text=text,
|
|
tokens=[
|
|
lx.tokenizer.Token(
|
|
index=0,
|
|
token_type=lx.tokenizer.TokenType.PUNCTUATION,
|
|
char_interval=lx.tokenizer.CharInterval(0, 1),
|
|
)
|
|
],
|
|
)
|
|
# Return dummy tokens for other text to avoid "empty tokens" error in aligner
|
|
return lx.tokenizer.TokenizedText(
|
|
text=text,
|
|
tokens=[
|
|
lx.tokenizer.Token(
|
|
index=0,
|
|
token_type=lx.tokenizer.TokenType.WORD,
|
|
char_interval=lx.tokenizer.CharInterval(0, len(text)),
|
|
)
|
|
],
|
|
)
|
|
|
|
mock_tokenizer = mock.MagicMock()
|
|
mock_tokenizer.tokenize.side_effect = mock_tokenize
|
|
|
|
mock_examples = [
|
|
lx.data.ExampleData(
|
|
text="Example",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="test",
|
|
extraction_text="example",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
lx.extract(
|
|
text_or_documents=input_text,
|
|
prompt_description="Test extraction",
|
|
examples=mock_examples,
|
|
api_key="test_key",
|
|
tokenizer=mock_tokenizer,
|
|
)
|
|
|
|
mock_tokenizer.tokenize.assert_called_with(input_text)
|
|
|
|
def test_data_module_exports_via_compatibility_shim(self):
|
|
"""Verify data module exports are accessible via lx.data."""
|
|
expected_exports = [
|
|
"AlignmentStatus",
|
|
"CharInterval",
|
|
"Extraction",
|
|
"Document",
|
|
"AnnotatedDocument",
|
|
"ExampleData",
|
|
"FormatType",
|
|
]
|
|
|
|
for name in expected_exports:
|
|
with self.subTest(export=name):
|
|
self.assertTrue(
|
|
hasattr(lx.data, name),
|
|
f"lx.data.{name} not accessible via compatibility shim",
|
|
)
|
|
|
|
def test_tokenizer_module_exports_via_compatibility_shim(self):
|
|
"""Verify tokenizer module exports are accessible via lx.tokenizer."""
|
|
expected_exports = [
|
|
"BaseTokenizerError",
|
|
"InvalidTokenIntervalError",
|
|
"SentenceRangeError",
|
|
"CharInterval",
|
|
"TokenInterval",
|
|
"TokenType",
|
|
"Token",
|
|
"TokenizedText",
|
|
"tokenize",
|
|
"tokens_text",
|
|
"find_sentence_range",
|
|
]
|
|
|
|
for name in expected_exports:
|
|
with self.subTest(export=name):
|
|
self.assertTrue(
|
|
hasattr(lx.tokenizer, name),
|
|
f"lx.tokenizer.{name} not accessible via compatibility shim",
|
|
)
|
|
|
|
@parameterized.named_parameters(
|
|
dict(
|
|
testcase_name="show_progress_true_debug_false",
|
|
show_progress=True,
|
|
debug=False,
|
|
expected_progress_disabled=False,
|
|
),
|
|
dict(
|
|
testcase_name="show_progress_false_debug_false",
|
|
show_progress=False,
|
|
debug=False,
|
|
expected_progress_disabled=True,
|
|
),
|
|
dict(
|
|
testcase_name="show_progress_true_debug_true",
|
|
show_progress=True,
|
|
debug=True,
|
|
expected_progress_disabled=False,
|
|
),
|
|
dict(
|
|
testcase_name="show_progress_false_debug_true",
|
|
show_progress=False,
|
|
debug=True,
|
|
expected_progress_disabled=True,
|
|
),
|
|
)
|
|
@mock.patch("langextract.progress.create_extraction_progress_bar")
|
|
@mock.patch("langextract.extraction.factory.create_model")
|
|
def test_show_progress_controls_progress_bar(
|
|
self,
|
|
mock_create_model,
|
|
mock_progress,
|
|
show_progress,
|
|
debug,
|
|
expected_progress_disabled,
|
|
):
|
|
"""Test that show_progress parameter controls progress bar visibility."""
|
|
mock_model = mock.MagicMock()
|
|
mock_model.infer.return_value = [
|
|
[
|
|
types.ScoredOutput(
|
|
output='{"extractions": []}',
|
|
score=0.9,
|
|
)
|
|
]
|
|
]
|
|
mock_model.requires_fence_output = False
|
|
mock_create_model.return_value = mock_model
|
|
|
|
mock_progress.side_effect = lambda iterable, **kwargs: iter(iterable)
|
|
|
|
mock_examples = [
|
|
lx.data.ExampleData(
|
|
text="Example text",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="example",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
lx.extract(
|
|
text_or_documents="test text",
|
|
prompt_description="extract entities",
|
|
examples=mock_examples,
|
|
api_key="test_key",
|
|
show_progress=show_progress,
|
|
debug=debug,
|
|
)
|
|
|
|
mock_progress.assert_called()
|
|
call_args = mock_progress.call_args
|
|
self.assertEqual(
|
|
call_args.kwargs.get("disable", False), expected_progress_disabled
|
|
)
|
|
|
|
@mock.patch("langextract.factory.create_model")
|
|
def test_schema_validation_warning_issued(self, mock_create_model):
|
|
"""Test that schema validation warnings are properly issued."""
|
|
mock_model = mock.Mock(spec=base_model.BaseLanguageModel)
|
|
mock_model.requires_fence_output = True
|
|
mock_model.infer.return_value = [
|
|
[types.ScoredOutput(output='{"extractions": []}', score=1.0)]
|
|
]
|
|
|
|
mock_schema = mock.Mock(spec=schema.BaseSchema)
|
|
|
|
def validate_format_side_effect(format_handler):
|
|
warnings.warn("Test validation warning", UserWarning, stacklevel=3)
|
|
|
|
mock_schema.validate_format = mock.Mock(
|
|
side_effect=validate_format_side_effect
|
|
)
|
|
mock_model.schema = mock_schema
|
|
|
|
mock_create_model.return_value = mock_model
|
|
test_examples = [
|
|
lx.data.ExampleData(
|
|
text="test",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="test",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter("always")
|
|
|
|
result = lx.extract(
|
|
text_or_documents="Sample text",
|
|
prompt_description="Extract",
|
|
examples=test_examples,
|
|
model_id="test-model",
|
|
api_key="key",
|
|
use_schema_constraints=True,
|
|
)
|
|
warning_messages = [str(warning.message) for warning in w]
|
|
self.assertIn(
|
|
"Test validation warning",
|
|
" ".join(warning_messages),
|
|
"Schema validation warning should be issued",
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
|
|
def test_gemini_schema_deprecation_warning(self):
|
|
"""Test that passing gemini_schema triggers deprecation warning."""
|
|
mock_model = mock.MagicMock(spec=base_model.BaseLanguageModel)
|
|
mock_model.infer.return_value = iter(
|
|
[[mock.Mock(output='{"extractions": []}')]]
|
|
)
|
|
mock_model.requires_fence_output = True
|
|
mock_model.schema = None
|
|
|
|
self.enter_context(
|
|
mock.patch(
|
|
"langextract.factory.create_model",
|
|
return_value=mock_model,
|
|
)
|
|
)
|
|
|
|
self.enter_context(
|
|
mock.patch(
|
|
"langextract.annotation.Annotator.annotate_text",
|
|
return_value=data.AnnotatedDocument(text="test", extractions=[]),
|
|
)
|
|
)
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter("always")
|
|
|
|
_ = lx.extract(
|
|
text_or_documents="test",
|
|
prompt_description="Extract conditions",
|
|
examples=[
|
|
lx.data.ExampleData(
|
|
text="test",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="test",
|
|
),
|
|
],
|
|
)
|
|
],
|
|
model_id="gemini-3.5-flash",
|
|
api_key="test_key",
|
|
language_model_params={"gemini_schema": "deprecated"},
|
|
)
|
|
|
|
self.assertTrue(
|
|
any(
|
|
issubclass(warning.category, FutureWarning)
|
|
and "gemini_schema" in str(warning.message)
|
|
for warning in w
|
|
),
|
|
"Expected deprecation warning for gemini_schema",
|
|
)
|
|
|
|
|
|
class AnnotationSuppressParseErrorsTest(absltest.TestCase):
|
|
"""Tests for suppress_parse_errors isolation in the annotation layer."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self._mock_model = mock.MagicMock()
|
|
self._mock_model.requires_fence_output = False
|
|
self._mock_model.schema = None
|
|
examples = [
|
|
lx.data.ExampleData(
|
|
text="Example text",
|
|
extractions=[
|
|
lx.data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text="example",
|
|
),
|
|
],
|
|
)
|
|
]
|
|
handler = fh.FormatHandler(
|
|
format_type=data.FormatType.JSON, use_wrapper=True
|
|
)
|
|
self._annotator = lx.annotation.Annotator(
|
|
language_model=self._mock_model,
|
|
prompt_template=prompting.PromptTemplateStructured(
|
|
description="desc", examples=examples
|
|
),
|
|
format_handler=handler,
|
|
)
|
|
self._resolver = lx.resolver.Resolver(format_handler=handler)
|
|
|
|
def test_suppress_parse_errors_not_sent_to_infer(self):
|
|
"""Provider infer() must not receive suppress_parse_errors."""
|
|
self._mock_model.infer.return_value = [
|
|
[types.ScoredOutput(output='{"extractions": []}')]
|
|
]
|
|
|
|
_ = self._annotator.annotate_text(
|
|
text="test text",
|
|
resolver=self._resolver,
|
|
suppress_parse_errors=True,
|
|
)
|
|
|
|
self._mock_model.infer.assert_called()
|
|
_, infer_kwargs = self._mock_model.infer.call_args
|
|
self.assertNotIn("suppress_parse_errors", infer_kwargs)
|
|
|
|
def test_warning_excludes_raw_chunk_text(self):
|
|
"""Suppressed parse error logs must not contain raw document text."""
|
|
sensitive_text = "Patient has diabetes and hypertension"
|
|
self._mock_model.infer.return_value = [
|
|
[types.ScoredOutput(output="I cannot extract entities")]
|
|
]
|
|
|
|
with mock.patch("langextract.resolver.logging") as mock_logging:
|
|
_ = self._annotator.annotate_text(
|
|
text=sensitive_text,
|
|
resolver=self._resolver,
|
|
suppress_parse_errors=True,
|
|
)
|
|
|
|
for call in mock_logging.warning.call_args_list:
|
|
log_message = str(call)
|
|
self.assertNotIn(sensitive_text, log_message)
|
|
|
|
def test_mixed_chunks_valid_extractions_survive(self):
|
|
"""Valid extractions survive when other chunks fail to parse."""
|
|
self._mock_model.infer.return_value = [
|
|
[
|
|
types.ScoredOutput(
|
|
output='{"extractions": [{"entity": "diabetes"}]}'
|
|
)
|
|
],
|
|
[types.ScoredOutput(output="I don't see any entities.")],
|
|
]
|
|
|
|
result = self._annotator.annotate_text(
|
|
text="The patient has diabetes and takes insulin daily",
|
|
resolver=self._resolver,
|
|
max_char_buffer=25,
|
|
suppress_parse_errors=True,
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
self.assertGreater(len(result.extractions), 0)
|
|
self.assertEqual(result.extractions[0].extraction_class, "entity")
|
|
|
|
def test_mixed_chunks_raises_when_suppression_disabled(self):
|
|
"""Unparseable chunk raises when suppress_parse_errors=False."""
|
|
self._mock_model.infer.return_value = [
|
|
[types.ScoredOutput(output="No entities here.")],
|
|
]
|
|
|
|
with self.assertRaises(lx.resolver.ResolverParsingError):
|
|
self._annotator.annotate_text(
|
|
text="Test text",
|
|
resolver=self._resolver,
|
|
suppress_parse_errors=False,
|
|
)
|
|
|
|
|
|
class FetchUrlsOptInTest(absltest.TestCase):
|
|
"""URL fetching must be opt-in to keep the library SSRF-safe by default."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self._example = lx.data.ExampleData(
|
|
text="hi",
|
|
extractions=[
|
|
lx.data.Extraction(extraction_class="thing", extraction_text="hi")
|
|
],
|
|
)
|
|
|
|
def _extract(self, **overrides):
|
|
kwargs = dict(
|
|
text_or_documents="http://example.com/doc",
|
|
prompt_description="x",
|
|
examples=[self._example],
|
|
model_id="gemini-3.5-flash",
|
|
api_key="fake",
|
|
)
|
|
kwargs.update(overrides)
|
|
return lx.extract(**kwargs)
|
|
|
|
@mock.patch("langextract.extraction.factory.create_model", autospec=True)
|
|
@mock.patch("langextract.extraction.io.download_text_from_url", autospec=True)
|
|
def test_url_is_not_fetched_by_default(self, downloader, create_model):
|
|
sentinel = RuntimeError("short-circuit after URL decision")
|
|
create_model.side_effect = sentinel
|
|
with self.assertRaises(RuntimeError) as cm:
|
|
self._extract()
|
|
self.assertIs(cm.exception, sentinel)
|
|
downloader.assert_not_called()
|
|
|
|
@mock.patch("langextract.extraction.io.download_text_from_url", autospec=True)
|
|
def test_fetch_urls_true_invokes_downloader(self, downloader):
|
|
sentinel = RuntimeError("download invoked")
|
|
downloader.side_effect = sentinel
|
|
with self.assertRaises(RuntimeError) as cm:
|
|
self._extract(fetch_urls=True)
|
|
self.assertIs(cm.exception, sentinel)
|
|
downloader.assert_called_once_with("http://example.com/doc")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
absltest.main()
|