Files
google--langextract/tests/extract_precedence_test.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:37:14 +08:00

603 lines
21 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 parameter precedence in extract()."""
from unittest import mock
from absl.testing import absltest
from absl.testing import parameterized
from langextract import factory
import langextract as lx
from langextract.core import data
from langextract.providers import openai
class ExtractParameterPrecedenceTest(absltest.TestCase):
"""Tests ensuring correct precedence among extract() parameters."""
def setUp(self):
super().setUp()
self.examples = [
data.ExampleData(
text="example",
extractions=[
data.Extraction(
extraction_class="entity",
extraction_text="example",
)
],
)
]
self.description = "description"
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_model_overrides_all_other_parameters(
self, mock_create_model, mock_annotator_cls
):
"""Test that model parameter overrides all other model-related parameters."""
provided_model = mock.MagicMock()
mock_annotator = mock_annotator_cls.return_value
mock_annotator.annotate_text.return_value = "ok"
config = factory.ModelConfig(model_id="config-id")
result = lx.extract(
text_or_documents="text",
prompt_description=self.description,
examples=self.examples,
model=provided_model,
config=config,
model_id="ignored-model",
api_key="ignored-key",
language_model_type=openai.OpenAILanguageModel,
use_schema_constraints=False,
)
mock_create_model.assert_not_called()
_, kwargs = mock_annotator_cls.call_args
self.assertIs(kwargs["language_model"], provided_model)
self.assertEqual(result, "ok")
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_config_overrides_model_id_and_language_model_type(
self, mock_create_model, mock_annotator_cls
):
"""Test that config parameter overrides model_id and language_model_type."""
config = factory.ModelConfig(
model_id="config-model", provider_kwargs={"api_key": "config-key"}
)
mock_model = mock.MagicMock()
mock_model.requires_fence_output = True
mock_create_model.return_value = mock_model
mock_annotator = mock_annotator_cls.return_value
mock_annotator.annotate_text.return_value = "ok"
with mock.patch(
"langextract.extraction.factory.ModelConfig"
) as mock_model_config:
result = lx.extract(
text_or_documents="text",
prompt_description=self.description,
examples=self.examples,
config=config,
model_id="other-model",
api_key="other-key",
language_model_type=openai.OpenAILanguageModel,
use_schema_constraints=False,
)
mock_model_config.assert_not_called()
mock_create_model.assert_called_once()
called_config = mock_create_model.call_args[1]["config"]
self.assertEqual(called_config.model_id, "config-model")
self.assertEqual(called_config.provider_kwargs, {"api_key": "config-key"})
_, kwargs = mock_annotator_cls.call_args
self.assertIs(kwargs["language_model"], mock_model)
self.assertEqual(result, "ok")
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_model_id_and_base_kwargs_override_language_model_type(
self, mock_create_model, mock_annotator_cls
):
"""Test that model_id and other kwargs are used when no model or config."""
mock_model = mock.MagicMock()
mock_model.requires_fence_output = True
mock_create_model.return_value = mock_model
mock_annotator_cls.return_value.annotate_text.return_value = "ok"
mock_config = mock.MagicMock()
with mock.patch(
"langextract.extraction.factory.ModelConfig", return_value=mock_config
) as mock_model_config:
with self.assertWarns(FutureWarning):
result = lx.extract(
text_or_documents="text",
prompt_description=self.description,
examples=self.examples,
model_id="model-123",
api_key="api-key",
temperature=0.9,
model_url="http://model",
language_model_type=openai.OpenAILanguageModel,
use_schema_constraints=False,
)
mock_model_config.assert_called_once()
_, kwargs = mock_model_config.call_args
self.assertEqual(kwargs["model_id"], "model-123")
provider_kwargs = kwargs["provider_kwargs"]
self.assertEqual(provider_kwargs["api_key"], "api-key")
self.assertEqual(provider_kwargs["temperature"], 0.9)
self.assertEqual(provider_kwargs["model_url"], "http://model")
self.assertEqual(provider_kwargs["base_url"], "http://model")
mock_create_model.assert_called_once()
self.assertEqual(result, "ok")
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_language_model_type_only_emits_warning_and_works(
self, mock_create_model, mock_annotator_cls
):
"""Test that language_model_type emits deprecation warning but still works."""
mock_model = mock.MagicMock()
mock_model.requires_fence_output = True
mock_create_model.return_value = mock_model
mock_annotator_cls.return_value.annotate_text.return_value = "ok"
mock_config = mock.MagicMock()
with mock.patch(
"langextract.extraction.factory.ModelConfig", return_value=mock_config
) as mock_model_config:
with self.assertWarns(FutureWarning):
result = lx.extract(
text_or_documents="text",
prompt_description=self.description,
examples=self.examples,
language_model_type=openai.OpenAILanguageModel,
use_schema_constraints=False,
)
mock_model_config.assert_called_once()
_, kwargs = mock_model_config.call_args
self.assertEqual(kwargs["model_id"], "gemini-3.5-flash")
mock_create_model.assert_called_once()
self.assertEqual(result, "ok")
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_language_model_params_forward_retry_knobs(
self, mock_create_model, mock_annotator_cls
):
"""Test that provider-specific retry knobs flow through language_model_params."""
mock_model = mock.MagicMock()
mock_model.requires_fence_output = True
mock_create_model.return_value = mock_model
mock_annotator_cls.return_value.annotate_text.return_value = "ok"
mock_config = mock.MagicMock()
with mock.patch(
"langextract.extraction.factory.ModelConfig", return_value=mock_config
) as mock_model_config:
result = lx.extract(
text_or_documents="text",
prompt_description=self.description,
examples=self.examples,
model_id="gemini-3.5-flash",
api_key="api-key",
language_model_params={
"max_retries": 5,
"retry_delay": 0.25,
"max_retry_delay": 4.0,
},
use_schema_constraints=False,
)
mock_model_config.assert_called_once()
_, kwargs = mock_model_config.call_args
provider_kwargs = kwargs["provider_kwargs"]
self.assertEqual(provider_kwargs["api_key"], "api-key")
self.assertEqual(provider_kwargs["max_retries"], 5)
self.assertEqual(provider_kwargs["retry_delay"], 0.25)
self.assertEqual(provider_kwargs["max_retry_delay"], 4.0)
mock_create_model.assert_called_once()
self.assertEqual(result, "ok")
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_use_schema_constraints_warns_with_config(
self, mock_create_model, mock_annotator_cls
):
"""Test that use_schema_constraints emits warning when used with config."""
config = factory.ModelConfig(
model_id="gemini-3.5-flash", provider_kwargs={"api_key": "test-key"}
)
mock_model = mock.MagicMock()
mock_model.requires_fence_output = True
mock_create_model.return_value = mock_model
mock_annotator = mock_annotator_cls.return_value
mock_annotator.annotate_text.return_value = "ok"
with self.assertWarns(UserWarning) as cm:
result = lx.extract(
text_or_documents="text",
prompt_description=self.description,
examples=self.examples,
config=config,
use_schema_constraints=True,
)
self.assertIn("schema constraints", str(cm.warning))
self.assertIn("applied", str(cm.warning))
mock_create_model.assert_called_once()
called_config = mock_create_model.call_args[1]["config"]
self.assertEqual(called_config.model_id, "gemini-3.5-flash")
self.assertEqual(result, "ok")
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_use_schema_constraints_warns_with_model(
self, mock_create_model, mock_annotator_cls
):
"""Test that use_schema_constraints emits warning when used with model."""
provided_model = mock.MagicMock()
mock_annotator = mock_annotator_cls.return_value
mock_annotator.annotate_text.return_value = "ok"
with self.assertWarns(UserWarning) as cm:
result = lx.extract(
text_or_documents="text",
prompt_description=self.description,
examples=self.examples,
model=provided_model,
use_schema_constraints=True,
)
self.assertIn("use_schema_constraints", str(cm.warning))
self.assertIn("ignored", str(cm.warning))
mock_create_model.assert_not_called()
self.assertEqual(result, "ok")
class ExtractAdditionalContextTest(parameterized.TestCase):
"""Tests for additional_context propagation in the document path of extract()."""
def setUp(self):
super().setUp()
self.examples = [
data.ExampleData(
text="example",
extractions=[
data.Extraction(
extraction_class="entity",
extraction_text="example",
)
],
)
]
self.description = "description"
def _setup_mocks(self, mock_create_model, mock_annotator_cls):
"""Wire the patched create_model and Annotator for a single test."""
mock_model = mock.MagicMock()
mock_model.requires_fence_output = False
mock_create_model.return_value = mock_model
mock_annotator = mock_annotator_cls.return_value
mock_annotator.annotate_documents.return_value = iter([])
return mock_model, mock_annotator
@parameterized.named_parameters(
dict(
testcase_name="global_applied_when_doc_lacks_own",
per_doc_ctxs=[None, None],
global_ctx="Important disambiguation rule: treat X as a brand name.",
expected=[
"Important disambiguation rule: treat X as a brand name.",
"Important disambiguation rule: treat X as a brand name.",
],
),
dict(
testcase_name="per_doc_takes_precedence_over_global",
per_doc_ctxs=["Document-specific context.", None],
global_ctx="Global context.",
expected=["Document-specific context.", "Global context."],
),
dict(
testcase_name="empty_string_per_doc_takes_precedence_over_global",
per_doc_ctxs=[""],
global_ctx="Global context.",
expected=[""],
),
dict(
testcase_name="empty_string_global_treated_as_non_none",
per_doc_ctxs=[None],
global_ctx="",
expected=[""],
),
)
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_additional_context_propagated_to_passed_documents(
self,
mock_create_model,
mock_annotator_cls,
per_doc_ctxs,
global_ctx,
expected,
):
mock_model, mock_annotator = self._setup_mocks(
mock_create_model, mock_annotator_cls
)
docs = [
data.Document(text=f"doc {i}", additional_context=ctx)
for i, ctx in enumerate(per_doc_ctxs)
]
lx.extract(
text_or_documents=docs,
prompt_description=self.description,
examples=self.examples,
model=mock_model,
additional_context=global_ctx,
use_schema_constraints=False,
)
passed_docs = list(
mock_annotator.annotate_documents.call_args.kwargs["documents"]
)
actual = [doc.additional_context for doc in passed_docs]
self.assertEqual(actual, expected)
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_no_additional_context_leaves_documents_unchanged(
self, mock_create_model, mock_annotator_cls
):
"""When additional_context is None, documents are passed through as-is."""
mock_model, mock_annotator = self._setup_mocks(
mock_create_model, mock_annotator_cls
)
docs = [
data.Document(text="doc one"),
data.Document(text="doc two"),
]
original_docs = list(docs)
lx.extract(
text_or_documents=docs,
prompt_description=self.description,
examples=self.examples,
model=mock_model,
additional_context=None,
use_schema_constraints=False,
)
_, kwargs = mock_annotator.annotate_documents.call_args
passed_docs = list(kwargs["documents"])
self.assertLen(passed_docs, 2)
for passed, original in zip(passed_docs, original_docs):
self.assertIs(passed, original)
self.assertIsNone(passed.additional_context)
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_document_ids_preserved_when_applying_global_context(
self, mock_create_model, mock_annotator_cls
):
"""Document IDs are not lost when global additional_context is applied."""
mock_model, mock_annotator = self._setup_mocks(
mock_create_model, mock_annotator_cls
)
docs = [
data.Document(text="doc one", document_id="custom-id-1"),
data.Document(text="doc two", document_id="custom-id-2"),
]
lx.extract(
text_or_documents=docs,
prompt_description=self.description,
examples=self.examples,
model=mock_model,
additional_context="context",
use_schema_constraints=False,
)
_, kwargs = mock_annotator.annotate_documents.call_args
passed_docs = list(kwargs["documents"])
self.assertLen(passed_docs, 2)
self.assertEqual(passed_docs[0].document_id, "custom-id-1")
self.assertEqual(passed_docs[1].document_id, "custom-id-2")
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_auto_generated_document_ids_preserved_with_global_context(
self, mock_create_model, mock_annotator_cls
):
"""Generated IDs still correlate caller Documents with results."""
mock_model, mock_annotator = self._setup_mocks(
mock_create_model, mock_annotator_cls
)
docs = [
data.Document(text="doc one"),
data.Document(text="doc two"),
]
lx.extract(
text_or_documents=docs,
prompt_description=self.description,
examples=self.examples,
model=mock_model,
additional_context="context",
use_schema_constraints=False,
)
_, kwargs = mock_annotator.annotate_documents.call_args
passed_docs = list(kwargs["documents"])
self.assertLen(passed_docs, 2)
self.assertEqual(passed_docs[0].document_id, docs[0].document_id)
self.assertEqual(passed_docs[1].document_id, docs[1].document_id)
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_generator_input_works_with_additional_context(
self, mock_create_model, mock_annotator_cls
):
"""Generator inputs are fully consumed when additional_context is applied."""
mock_model, mock_annotator = self._setup_mocks(
mock_create_model, mock_annotator_cls
)
def doc_generator():
yield data.Document(text="gen doc one")
yield data.Document(text="gen doc two")
lx.extract(
text_or_documents=doc_generator(),
prompt_description=self.description,
examples=self.examples,
model=mock_model,
additional_context="global context",
use_schema_constraints=False,
)
_, kwargs = mock_annotator.annotate_documents.call_args
passed_docs = list(kwargs["documents"])
self.assertLen(passed_docs, 2)
for doc in passed_docs:
self.assertEqual(doc.additional_context, "global context")
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_caller_documents_keep_context_and_token_cache(
self, mock_create_model, mock_annotator_cls
):
"""Global context copies do not alter caller context or tokenization."""
mock_model, mock_annotator = self._setup_mocks(
mock_create_model, mock_annotator_cls
)
docs = [
data.Document(text="doc one"),
data.Document(text="doc two"),
]
lx.extract(
text_or_documents=docs,
prompt_description=self.description,
examples=self.examples,
model=mock_model,
additional_context="injected context",
use_schema_constraints=False,
)
# Wrapping is lazy; force consumption so the copy path actually runs.
list(mock_annotator.annotate_documents.call_args.kwargs["documents"])
for original in docs:
self.assertIsNone(original.additional_context)
self.assertIsNone(original._tokenized_text)
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_pre_tokenized_text_preserved_when_applying_global_context(
self, mock_create_model, mock_annotator_cls
):
"""A Document's pre-tokenized cache survives the global-context copy.
Re-tokenizing on every copy would silently waste work for callers who
already paid for tokenization upstream.
"""
mock_model, mock_annotator = self._setup_mocks(
mock_create_model, mock_annotator_cls
)
doc = data.Document(text="patient has diabetes")
pre_tokenized = doc.tokenized_text # triggers tokenization
lx.extract(
text_or_documents=[doc],
prompt_description=self.description,
examples=self.examples,
model=mock_model,
additional_context="global ctx",
use_schema_constraints=False,
)
_, kwargs = mock_annotator.annotate_documents.call_args
passed_docs = list(kwargs["documents"])
self.assertLen(passed_docs, 1)
self.assertIs(passed_docs[0].tokenized_text, pre_tokenized)
self.assertEqual(passed_docs[0].additional_context, "global ctx")
@mock.patch("langextract.annotation.Annotator")
@mock.patch("langextract.extraction.factory.create_model")
def test_string_and_document_paths_apply_additional_context_identically(
self, mock_create_model, mock_annotator_cls
):
"""String and Document inputs deliver the same additional_context.
Locks parity between lx.extract(text=..., additional_context=X) and
lx.extract([Document(text=...)], additional_context=X). The drift
between these two paths is exactly what produced #445.
"""
text = "patient has diabetes"
ctx = "Disambiguation rule: treat conditions as present unless stated."
mock_model, mock_annotator = self._setup_mocks(
mock_create_model, mock_annotator_cls
)
mock_annotator.annotate_text.return_value = mock.MagicMock()
lx.extract(
text_or_documents=text,
prompt_description=self.description,
examples=self.examples,
model=mock_model,
additional_context=ctx,
use_schema_constraints=False,
)
string_kwargs = mock_annotator.annotate_text.call_args.kwargs
lx.extract(
text_or_documents=[data.Document(text=text)],
prompt_description=self.description,
examples=self.examples,
model=mock_model,
additional_context=ctx,
use_schema_constraints=False,
)
doc_kwargs = mock_annotator.annotate_documents.call_args.kwargs
self.assertEqual(string_kwargs["additional_context"], ctx)
passed_docs = list(doc_kwargs["documents"])
self.assertLen(passed_docs, 1)
self.assertEqual(passed_docs[0].additional_context, ctx)
self.assertEqual(
passed_docs[0].additional_context,
string_kwargs["additional_context"],
)
if __name__ == "__main__":
absltest.main()