Files
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

1260 lines
40 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.
"""Live API integration tests that require real API keys.
These tests are skipped if API keys are not available in the environment.
They should run in CI after all other tests pass.
"""
import functools
import json
import os
import re
import textwrap
import time
from typing import Any
import unittest
from unittest import mock
import uuid
import dotenv
import google.auth
import google.auth.exceptions
import google.genai.errors
import pytest
from langextract import data
import langextract as lx
from langextract.core import tokenizer as tokenizer_lib
from langextract.providers import gemini_batch as gb
from langextract.providers import openai_batch
dotenv.load_dotenv(override=True)
DEFAULT_GEMINI_MODEL = "gemini-3.5-flash"
DEFAULT_OPENAI_MODEL = "gpt-4o"
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get(
"LANGEXTRACT_API_KEY"
)
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
RUN_OPENAI_BATCH_LIVE_TESTS = (
os.environ.get("LANGEXTRACT_RUN_OPENAI_BATCH_LIVE_TESTS") == "1"
)
VERTEX_PROJECT = os.environ.get("VERTEX_PROJECT") or os.environ.get(
"GOOGLE_CLOUD_PROJECT"
)
VERTEX_LOCATION = os.environ.get("VERTEX_LOCATION", "us-central1")
def has_vertex_ai_credentials():
"""Check if Vertex AI credentials are available."""
if not VERTEX_PROJECT:
return False
try:
credentials, _ = google.auth.default()
return credentials is not None
except (ImportError, google.auth.exceptions.DefaultCredentialsError):
return False
skip_if_no_gemini = pytest.mark.skipif(
not GEMINI_API_KEY,
reason=(
"Gemini API key not available (set GEMINI_API_KEY or"
" LANGEXTRACT_API_KEY)"
),
)
skip_if_no_openai = pytest.mark.skipif(
not OPENAI_API_KEY,
reason="OpenAI API key not available (set OPENAI_API_KEY)",
)
skip_if_openai_batch_live_disabled = pytest.mark.skipif(
not RUN_OPENAI_BATCH_LIVE_TESTS,
reason=(
"OpenAI Batch API live test not enabled "
"(set LANGEXTRACT_RUN_OPENAI_BATCH_LIVE_TESTS=1)"
),
)
skip_if_no_vertex = pytest.mark.skipif(
not has_vertex_ai_credentials(),
reason=(
"Vertex AI credentials not available (set GOOGLE_CLOUD_PROJECT and"
" configure gcloud auth)"
),
)
live_api = pytest.mark.live_api
GEMINI_MODEL_PARAMS = {
"temperature": 0.0,
"top_p": 0.0,
"max_output_tokens": 256,
}
OPENAI_MODEL_PARAMS = {
"temperature": 0.0,
}
# Extraction Classes
_CLASS_MEDICATION = "medication"
_CLASS_DOSAGE = "dosage"
_CLASS_ROUTE = "route"
_CLASS_FREQUENCY = "frequency"
_CLASS_DURATION = "duration"
_CLASS_CONDITION = "condition"
INITIAL_RETRY_DELAY = 1.0
MAX_RETRY_DELAY = 8.0
def retry_on_transient_errors(max_retries=3, backoff_factor=2.0):
"""Decorator to retry tests on transient API errors with exponential backoff.
Args:
max_retries (int): Maximum number of retry attempts
backoff_factor (float): Multiplier for exponential backoff (e.g., 2.0 = 1s, 2s, 4s)
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
delay = INITIAL_RETRY_DELAY
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except (
lx.exceptions.LangExtractError,
google.genai.errors.ClientError,
ConnectionError,
TimeoutError,
OSError,
RuntimeError,
) as e:
last_exception = e
if attempt < max_retries:
print(
f"\nRetryable error ({type(e).__name__}) on attempt"
f" {attempt + 1}/{max_retries + 1}: {e}"
)
time.sleep(delay)
delay = min(delay * backoff_factor, MAX_RETRY_DELAY)
continue
raise
raise last_exception
return wrapper
return decorator
@pytest.fixture(autouse=True)
def add_delay_between_tests():
"""Add a small delay between tests to avoid rate limiting."""
yield
time.sleep(0.5)
def get_basic_medication_examples():
"""Get example data for basic medication extraction."""
return [
lx.data.ExampleData(
text="Patient was given 250 mg IV Cefazolin TID for one week.",
extractions=[
lx.data.Extraction(
extraction_class=_CLASS_DOSAGE, extraction_text="250 mg"
),
lx.data.Extraction(
extraction_class=_CLASS_ROUTE, extraction_text="IV"
),
lx.data.Extraction(
extraction_class=_CLASS_MEDICATION,
extraction_text="Cefazolin",
),
lx.data.Extraction(
extraction_class=_CLASS_FREQUENCY,
extraction_text="TID", # TID = three times a day
),
lx.data.Extraction(
extraction_class=_CLASS_DURATION,
extraction_text="for one week",
),
],
)
]
def get_relationship_examples():
"""Get example data for medication relationship extraction."""
return [
lx.data.ExampleData(
text=(
"Patient takes Aspirin 100mg daily for heart health and"
" Simvastatin 20mg at bedtime."
),
extractions=[
# First medication group
lx.data.Extraction(
extraction_class=_CLASS_MEDICATION,
extraction_text="Aspirin",
attributes={"medication_group": "Aspirin"},
),
lx.data.Extraction(
extraction_class=_CLASS_DOSAGE,
extraction_text="100mg",
attributes={"medication_group": "Aspirin"},
),
lx.data.Extraction(
extraction_class=_CLASS_FREQUENCY,
extraction_text="daily",
attributes={"medication_group": "Aspirin"},
),
lx.data.Extraction(
extraction_class=_CLASS_CONDITION,
extraction_text="heart health",
attributes={"medication_group": "Aspirin"},
),
# Second medication group
lx.data.Extraction(
extraction_class=_CLASS_MEDICATION,
extraction_text="Simvastatin",
attributes={"medication_group": "Simvastatin"},
),
lx.data.Extraction(
extraction_class=_CLASS_DOSAGE,
extraction_text="20mg",
attributes={"medication_group": "Simvastatin"},
),
lx.data.Extraction(
extraction_class=_CLASS_FREQUENCY,
extraction_text="at bedtime",
attributes={"medication_group": "Simvastatin"},
),
],
)
]
def extract_by_class(result, extraction_class):
"""Helper to extract entities by class.
Returns a set of extraction texts for the given class.
"""
return {
e.extraction_text
for e in result.extractions
if e.extraction_class == extraction_class
}
def assert_extractions_contain(test_case, result, expected_classes):
"""Assert that result contains all expected extraction classes.
Uses unittest assertions for richer error messages.
"""
actual_classes = {e.extraction_class for e in result.extractions}
missing_classes = expected_classes - actual_classes
test_case.assertFalse(
missing_classes,
f"Missing expected classes: {missing_classes}. Found extractions:"
f" {[f'{e.extraction_class}:{e.extraction_text}' for e in result.extractions]}",
)
def assert_valid_char_intervals(test_case, result):
"""Assert that all extractions have valid char intervals and alignment status."""
for extraction in result.extractions:
test_case.assertIsNotNone(
extraction.char_interval,
f"Missing char_interval for extraction: {extraction.extraction_text}",
)
test_case.assertIsNotNone(
extraction.alignment_status,
"Missing alignment_status for extraction:"
f" {extraction.extraction_text}",
)
if isinstance(result, lx.data.AnnotatedDocument) and result.text:
text_length = len(result.text)
test_case.assertGreaterEqual(
extraction.char_interval.start_pos,
0,
f"Invalid start_pos for extraction: {extraction.extraction_text}",
)
test_case.assertLessEqual(
extraction.char_interval.end_pos,
text_length,
f"Invalid end_pos for extraction: {extraction.extraction_text}",
)
class TestLiveAPIGemini(unittest.TestCase):
"""Tests using real Gemini API."""
def _check_cached_result(self, result_json: dict[str, Any]) -> bool:
"""Check if cached result contains expected medication data.
Args:
result_json: The raw JSON dict from the cache file.
Expected format: {"text": "JSON_STRING_OF_RESULT"}
Returns:
True if the result contains valid medication extractions, False otherwise.
"""
try:
text_content = result_json.get("text")
if not isinstance(text_content, str):
return False
inner_json = json.loads(text_content)
if not isinstance(inner_json, dict):
return False
extractions_data = inner_json.get(data.EXTRACTIONS_KEY)
if not isinstance(extractions_data, list):
return False
extractions = []
for item in extractions_data:
if isinstance(item, dict):
clean_item = {k: v for k, v in item.items() if not k.startswith("_")}
extractions.append(data.Extraction(**clean_item))
doc = data.AnnotatedDocument(
text=inner_json.get("text"), extractions=extractions
)
if not doc.extractions:
return False
# Check for specific content
medication_texts = extract_by_class(doc, _CLASS_MEDICATION)
dosage_texts = extract_by_class(doc, _CLASS_DOSAGE)
has_lisinopril = any("Lisinopril" in t for t in medication_texts)
has_10mg = any("10mg" in t for t in dosage_texts)
return has_lisinopril and has_10mg
except (json.JSONDecodeError, TypeError, ValueError):
return False
def _verify_gcs_cache_content(self, bucket_name):
"""Verify that GCS cache contains expected structured results."""
cache = gb.GCSBatchCache(bucket_name, project=VERTEX_PROJECT)
found_content = False
# Use iter_items() to check cache content
items = list(cache.iter_items())
self.assertTrue(len(items) > 0, "No cache files found in GCS bucket")
for _, text in items:
try:
result_json = json.loads(text)
if self._check_cached_result(result_json):
found_content = True
break
except (json.JSONDecodeError, TypeError, ValueError):
continue
self.assertTrue(
found_content,
"Could not find expected structured result in GCS cache files",
)
@skip_if_no_gemini
@live_api
@retry_on_transient_errors(max_retries=2)
def test_medication_extraction(self):
"""Test medication extraction with entities in order."""
prompt = textwrap.dedent("""\
Extract medication information including medication name, dosage, route, frequency,
and duration in the order they appear in the text.""")
examples = get_basic_medication_examples()
input_text = "Patient took 400 mg PO Ibuprofen q4h for two days."
result = lx.extract(
text_or_documents=input_text,
prompt_description=prompt,
examples=examples,
model_id=DEFAULT_GEMINI_MODEL,
api_key=GEMINI_API_KEY,
language_model_params=GEMINI_MODEL_PARAMS,
)
assert result is not None
self.assertIsInstance(result, lx.data.AnnotatedDocument)
assert len(result.extractions) > 0
expected_classes = {
_CLASS_DOSAGE,
_CLASS_ROUTE,
_CLASS_MEDICATION,
_CLASS_FREQUENCY,
_CLASS_DURATION,
}
assert_extractions_contain(self, result, expected_classes)
assert_valid_char_intervals(self, result)
# Using regex for precise matching to avoid false positives
medication_texts = extract_by_class(result, _CLASS_MEDICATION)
self.assertTrue(
any(
re.search(r"\bIbuprofen\b", text, re.IGNORECASE)
for text in medication_texts
),
f"No Ibuprofen found in: {medication_texts}",
)
dosage_texts = extract_by_class(result, _CLASS_DOSAGE)
self.assertTrue(
any(
re.search(r"\b400\s*mg\b", text, re.IGNORECASE)
for text in dosage_texts
),
f"No 400mg dosage found in: {dosage_texts}",
)
route_texts = extract_by_class(result, _CLASS_ROUTE)
self.assertTrue(
any(
re.search(r"\b(PO|oral)\b", text, re.IGNORECASE)
for text in route_texts
),
f"No PO/oral route found in: {route_texts}",
)
@skip_if_no_gemini
@live_api
@retry_on_transient_errors(max_retries=2)
def test_multilingual_medication_extraction(self):
"""Test medication extraction with Japanese text."""
text = ( # "The patient takes 10 mg of medication daily."
"患者は毎日10mgの薬を服用します。"
)
prompt = "Extract medication information including dosage and frequency."
examples = [
lx.data.ExampleData(
text="The patient takes 20mg of aspirin twice daily.",
extractions=[
lx.data.Extraction(
extraction_class=_CLASS_MEDICATION,
extraction_text="aspirin",
attributes={
_CLASS_DOSAGE: "20mg",
_CLASS_FREQUENCY: "twice daily",
},
),
],
)
]
unicode_tokenizer = tokenizer_lib.UnicodeTokenizer()
result = lx.extract(
text_or_documents=text,
prompt_description=prompt,
examples=examples,
model_id=DEFAULT_GEMINI_MODEL,
api_key=GEMINI_API_KEY,
language_model_params=GEMINI_MODEL_PARAMS,
tokenizer=unicode_tokenizer,
)
assert result is not None
self.assertIsInstance(result, lx.data.AnnotatedDocument)
assert len(result.extractions) > 0
medication_extractions = [
e for e in result.extractions if e.extraction_class == _CLASS_MEDICATION
]
assert (
len(medication_extractions) > 0
), "No medication entities found in Japanese text"
assert_valid_char_intervals(self, result)
@skip_if_no_gemini
@live_api
@retry_on_transient_errors(max_retries=2)
def test_explicit_provider_gemini(self):
"""Test using explicit provider with Gemini."""
config = lx.factory.ModelConfig(
model_id=DEFAULT_GEMINI_MODEL,
provider="GeminiLanguageModel",
provider_kwargs={
"api_key": GEMINI_API_KEY,
"temperature": 0.0,
},
)
model = lx.factory.create_model(config)
self.assertEqual(model.__class__.__name__, "GeminiLanguageModel")
self.assertEqual(model.model_id, DEFAULT_GEMINI_MODEL)
config2 = lx.factory.ModelConfig(
model_id=DEFAULT_GEMINI_MODEL,
provider="gemini",
provider_kwargs={
"api_key": GEMINI_API_KEY,
},
)
model2 = lx.factory.create_model(config2)
self.assertEqual(model2.__class__.__name__, "GeminiLanguageModel")
@skip_if_no_gemini
@live_api
@retry_on_transient_errors(max_retries=2)
def test_medication_relationship_extraction(self):
"""Test relationship extraction for medications with Gemini."""
input_text = """
The patient was prescribed Lisinopril and Metformin last month.
He takes the Lisinopril 10mg daily for hypertension, but often misses
his Metformin 500mg dose which should be taken twice daily for diabetes.
"""
prompt = textwrap.dedent("""
Extract medications with their details, using attributes to group related information:
1. Extract entities in the order they appear in the text
2. Each entity must have a 'medication_group' attribute linking it to its medication
3. All details about a medication should share the same medication_group value
""")
examples = get_relationship_examples()
result = lx.extract(
text_or_documents=input_text,
prompt_description=prompt,
examples=examples,
model_id=DEFAULT_GEMINI_MODEL,
api_key=GEMINI_API_KEY,
language_model_params=GEMINI_MODEL_PARAMS,
)
assert result is not None
assert len(result.extractions) > 0
assert_valid_char_intervals(self, result)
medication_groups = {}
for extraction in result.extractions:
assert (
extraction.attributes is not None
), f"Missing attributes for {extraction.extraction_text}"
assert (
"medication_group" in extraction.attributes
), f"Missing medication_group for {extraction.extraction_text}"
group_name = extraction.attributes["medication_group"]
medication_groups.setdefault(group_name, []).append(extraction)
assert (
len(medication_groups) >= 2
), f"Expected at least 2 medications, found {len(medication_groups)}"
# Allow flexible matching for dosage field (could be "dosage" or "dose")
for med_name, extractions in medication_groups.items():
extraction_classes = {e.extraction_class for e in extractions}
# At minimum, each group should have the medication itself
assert (
_CLASS_MEDICATION in extraction_classes
), f"{med_name} group missing medication entity"
# Dosage is expected but might be formatted differently
assert any(
c in extraction_classes for c in [_CLASS_DOSAGE, "dose"]
), f"{med_name} group missing dosage"
@skip_if_no_vertex
@live_api
@pytest.mark.vertex_ai
@mock.patch.object(gb, "infer_batch", wraps=gb.infer_batch, autospec=True)
def test_batch_extraction_vertex_gcs(self, mock_infer_batch):
"""Test extraction using Vertex AI Batch API with GCS.
This test runs a real Vertex AI Batch job and will take time to complete.
It is skipped unless VERTEX_PROJECT is set.
We wrap `infer_batch` to verify that:
- Batch API is actually called (not falling back to real-time API)
- Schema dict is passed (non-None) to the batch function
"""
prompt = textwrap.dedent("""\
Extract medication information including medication name, dosage, route, frequency,
and duration in the order they appear in the text.""")
examples = get_basic_medication_examples()
documents = [
lx.data.Document(
document_id="vx_doc1",
text="Patient took 400 mg PO Ibuprofen q4h for two days.",
),
lx.data.Document(
document_id="vx_doc2",
text="Patient was given 250 mg IV Cefazolin TID for one week.",
),
lx.data.Document(
document_id="vx_doc3",
text="Administered 2 mg IV Morphine once for acute pain.",
),
lx.data.Document(
document_id="vx_doc4",
text="Prescribed 500 mg PO Amoxicillin BID for infection.",
),
lx.data.Document(
document_id="vx_doc5",
text="Given 10 mg IM Haloperidol PRN for agitation.",
),
]
expected_meds = [
"Ibuprofen",
"Cefazolin",
"Morphine",
"Amoxicillin",
"Haloperidol",
]
language_model_params = dict(GEMINI_MODEL_PARAMS)
language_model_params["vertexai"] = True
language_model_params["project"] = VERTEX_PROJECT
language_model_params["location"] = VERTEX_LOCATION
language_model_params["batch"] = {
"enabled": True,
"threshold": 2,
"poll_interval": 1, # Fast polling for test
"timeout": 900, # 15 minutes for actual batch job completion
}
batch_result = lx.extract(
text_or_documents=documents,
prompt_description=prompt,
examples=examples,
model_id=DEFAULT_GEMINI_MODEL,
language_model_params=language_model_params,
)
mock_infer_batch.assert_called_once()
call_args = mock_infer_batch.call_args
schema_dict_arg = call_args.kwargs.get("schema_dict")
self.assertIsNotNone(
schema_dict_arg,
"schema_dict should be passed to batch API (not None)",
)
self.assertIsInstance(batch_result, list)
self.assertEqual(
len(batch_result),
len(documents),
f"Expected {len(documents)} results from Vertex batch API",
)
for i, (res, med_name) in enumerate(zip(batch_result, expected_meds)):
self.assertIsInstance(
res,
lx.data.AnnotatedDocument,
f"Result {i} should be an AnnotatedDocument, got {type(res)}",
)
self.assertTrue(
res.extractions,
f"No extractions for document {i}",
)
for extraction in res.extractions:
self.assertIsInstance(
extraction,
lx.data.Extraction,
"Extraction item should be Extraction object, got"
f" {type(extraction)}",
)
meds = extract_by_class(res, _CLASS_MEDICATION)
self.assertTrue(
any(
re.search(rf"\b{re.escape(med_name)}\b", m, re.IGNORECASE)
for m in meds
),
f"Expected medication '{med_name}' not found in results: {meds}",
)
dosages = extract_by_class(res, _CLASS_DOSAGE)
self.assertTrue(
dosages,
f"No dosage extracted for medication '{med_name}'",
)
assert_valid_char_intervals(self, res)
@skip_if_no_vertex
@live_api
@pytest.mark.vertex_ai
def test_batch_caching_live(self):
"""Test batch caching with real Vertex AI Batch API.
Verifies that:
1. First run populates GCS cache
2. Second run uses cache (returns same results faster)
"""
prompt = "Extract the medication: Patient takes 10mg Lisinopril."
examples = get_basic_medication_examples()
# Use unique IDs to ensure cache isolation between test runs.
run_id = uuid.uuid4().hex[:8]
documents = [
lx.data.Document(
document_id=f"doc_{i}_{run_id}",
text=f"Patient takes 10mg Lisinopril {i} {run_id}.",
)
for i in range(2)
]
language_model_params = dict(GEMINI_MODEL_PARAMS)
language_model_params["vertexai"] = True
language_model_params["project"] = VERTEX_PROJECT
language_model_params["location"] = VERTEX_LOCATION
language_model_params["batch"] = {
"enabled": True,
"threshold": 2,
"poll_interval": 1,
"timeout": 900,
"enable_caching": True,
}
print("\nStarting first batch run (API)...")
start_time = time.time()
results1 = list(
lx.extract(
text_or_documents=documents,
prompt_description=prompt,
examples=examples,
model_id=DEFAULT_GEMINI_MODEL,
language_model_params=language_model_params,
)
)
duration1 = time.time() - start_time
print(f"First run took {duration1:.2f}s")
print("Starting second batch run (Cache)...")
start_time = time.time()
results2 = list(
lx.extract(
text_or_documents=documents,
prompt_description=prompt,
examples=examples,
model_id=DEFAULT_GEMINI_MODEL,
language_model_params=language_model_params,
)
)
duration2 = time.time() - start_time
print(f"Second run took {duration2:.2f}s")
self.assertEqual(len(results1), len(results2))
for r1, r2 in zip(results1, results2):
self.assertEqual(r1.text, r2.text)
self.assertEqual(len(r1.extractions), len(r2.extractions))
self.assertLess(duration2, 10.0, "Second run took too long for cache hit")
print("\nVerifying GCS cache content...")
bucket_name = gb._get_bucket_name(VERTEX_PROJECT, VERTEX_LOCATION)
print(f"Checking bucket: {bucket_name}")
self._verify_gcs_cache_content(bucket_name)
class TestCrossChunkContext(unittest.TestCase):
"""Tests for cross-chunk context feature with real API."""
@skip_if_no_gemini
@live_api
@retry_on_transient_errors(max_retries=3)
def test_context_window_extracts_from_both_chunks(self):
"""Verify context_window_chars enables extraction across chunk boundaries."""
input_text = (
"Dr. Sarah Chen is the lead researcher at the institute. "
"She published groundbreaking work on neural networks last year."
)
prompt = textwrap.dedent(
"""\
Extract all person names, roles, and achievements mentioned in the text.
Include both explicit names and information associated with pronouns."""
)
examples = [
lx.data.ExampleData(
text=(
"Professor James Miller leads the physics department. "
"He won the Nobel Prize in 2020."
),
extractions=[
lx.data.Extraction(
extraction_class="person",
extraction_text="Professor James Miller",
attributes={"role": "leads the physics department"},
),
lx.data.Extraction(
extraction_class="achievement",
extraction_text="won the Nobel Prize in 2020",
),
],
)
]
result = lx.extract(
text_or_documents=input_text,
prompt_description=prompt,
examples=examples,
model_id=DEFAULT_GEMINI_MODEL,
api_key=GEMINI_API_KEY,
language_model_params=GEMINI_MODEL_PARAMS,
max_char_buffer=60,
context_window_chars=50,
)
self.assertIsNotNone(result)
self.assertGreater(len(result.extractions), 0)
all_extraction_text = " ".join(
str(e.extraction_text) + " " + str(e.attributes)
for e in result.extractions
).lower()
has_chunk1_content = any(
term in all_extraction_text
for term in ("sarah", "chen", "researcher", "lead")
)
has_chunk2_content = any(
term in all_extraction_text
for term in ("published", "groundbreaking", "neural", "networks")
)
self.assertTrue(
has_chunk1_content,
f"Expected chunk 1 content (Sarah Chen). Got: {result.extractions}",
)
self.assertTrue(
has_chunk2_content,
f"Expected chunk 2 content (publication). Got: {result.extractions}",
)
class TestLiveAPIOpenAI(unittest.TestCase):
"""Tests using real OpenAI API."""
@skip_if_no_openai
@skip_if_openai_batch_live_disabled
@live_api
@retry_on_transient_errors(max_retries=1)
@mock.patch.object(
openai_batch, "infer_batch", wraps=openai_batch.infer_batch, autospec=True
)
def test_batch_extraction_uses_openai_batch_api(self, mock_infer_batch):
"""OpenAI batch mode runs a real Batch API extraction."""
prompt = textwrap.dedent("""\
Extract medication information including medication name, dosage, route,
frequency, and duration in the order they appear in the text.""")
examples = get_basic_medication_examples()
documents = [
lx.data.Document(
document_id="openai_batch_doc1",
text="Patient took 400 mg PO Ibuprofen q4h for two days.",
),
lx.data.Document(
document_id="openai_batch_doc2",
text="Administered 2 mg IV Morphine once for acute pain.",
),
]
expected_meds = ["Ibuprofen", "Morphine"]
language_model_params = {
**OPENAI_MODEL_PARAMS,
"max_output_tokens": 512,
"batch": {
"enabled": True,
"threshold": 1,
"poll_interval": 5,
"timeout": 900,
},
}
batch_result = lx.extract(
text_or_documents=documents,
prompt_description=prompt,
examples=examples,
model_id="gpt-4o-mini",
api_key=OPENAI_API_KEY,
use_schema_constraints=False,
language_model_params=language_model_params,
)
mock_infer_batch.assert_called()
call_args = mock_infer_batch.call_args
self.assertTrue(call_args.kwargs["cfg"].enabled)
self.assertEqual(call_args.kwargs["cfg"].threshold, 1)
self.assertIsInstance(batch_result, list)
self.assertEqual(len(batch_result), len(documents))
for result, expected_med in zip(batch_result, expected_meds):
self.assertIsInstance(result, lx.data.AnnotatedDocument)
medication_texts = extract_by_class(result, _CLASS_MEDICATION)
self.assertIn(expected_med, medication_texts)
assert_valid_char_intervals(self, result)
@skip_if_no_openai
@live_api
@retry_on_transient_errors(max_retries=2)
def test_medication_extraction(self):
"""Test medication extraction with OpenAI models."""
prompt = textwrap.dedent("""\
Extract medication information including medication name, dosage, route, frequency,
and duration in the order they appear in the text.""")
examples = get_basic_medication_examples()
input_text = "Patient took 400 mg PO Ibuprofen q4h for two days."
result = lx.extract(
text_or_documents=input_text,
prompt_description=prompt,
examples=examples,
model_id=DEFAULT_OPENAI_MODEL,
api_key=OPENAI_API_KEY,
use_schema_constraints=False,
language_model_params=OPENAI_MODEL_PARAMS,
)
assert result is not None
self.assertIsInstance(result, lx.data.AnnotatedDocument)
assert len(result.extractions) > 0
expected_classes = {
_CLASS_DOSAGE,
_CLASS_ROUTE,
_CLASS_MEDICATION,
_CLASS_FREQUENCY,
_CLASS_DURATION,
}
assert_extractions_contain(self, result, expected_classes)
assert_valid_char_intervals(self, result)
# Using regex for precise matching to avoid false positives
medication_texts = extract_by_class(result, _CLASS_MEDICATION)
self.assertTrue(
any(
re.search(r"\bIbuprofen\b", text, re.IGNORECASE)
for text in medication_texts
),
f"No Ibuprofen found in: {medication_texts}",
)
dosage_texts = extract_by_class(result, _CLASS_DOSAGE)
self.assertTrue(
any(
re.search(r"\b400\s*mg\b", text, re.IGNORECASE)
for text in dosage_texts
),
f"No 400mg dosage found in: {dosage_texts}",
)
route_texts = extract_by_class(result, _CLASS_ROUTE)
self.assertTrue(
any(
re.search(r"\b(PO|oral)\b", text, re.IGNORECASE)
for text in route_texts
),
f"No PO/oral route found in: {route_texts}",
)
@skip_if_no_openai
@live_api
@retry_on_transient_errors(max_retries=2)
def test_medication_extraction_with_schema_constraints(self):
"""Strict OpenAI outputs enforce the example-derived extraction shape."""
prompt = textwrap.dedent("""\
Extract conditions and medications in the order they appear in the text.
Use exact text for extractions. For condition attributes, include status
and symptoms as a list when symptoms are available.""")
examples = [
lx.data.ExampleData(
text="Patient has diabetes with fatigue and takes Metformin.",
extractions=[
lx.data.Extraction(
extraction_class=_CLASS_CONDITION,
extraction_text="diabetes",
attributes={
"status": "present",
"symptoms": ["fatigue"],
},
),
lx.data.Extraction(
extraction_class=_CLASS_MEDICATION,
extraction_text="Metformin",
attributes={"status": "current"},
),
],
)
]
input_text = (
"Patient has headache with fatigue and chills and took 400 mg PO "
"Ibuprofen."
)
result = lx.extract(
text_or_documents=input_text,
prompt_description=prompt,
examples=examples,
model_id="gpt-4o-mini",
api_key=OPENAI_API_KEY,
use_schema_constraints=True,
fence_output=False,
language_model_params={
**OPENAI_MODEL_PARAMS,
"max_output_tokens": 512,
},
)
self.assertIsInstance(result, lx.data.AnnotatedDocument)
self.assertGreater(len(result.extractions), 0)
allowed_classes = {_CLASS_CONDITION, _CLASS_MEDICATION}
extraction_classes = {
extraction.extraction_class for extraction in result.extractions
}
self.assertSetEqual(extraction_classes, allowed_classes)
allowed_attribute_keys = {
_CLASS_CONDITION: {"status", "symptoms"},
_CLASS_MEDICATION: {"status"},
}
for extraction in result.extractions:
if isinstance(extraction.attributes, dict):
self.assertLessEqual(
set(extraction.attributes),
allowed_attribute_keys[extraction.extraction_class],
)
condition_extractions = [
extraction
for extraction in result.extractions
if extraction.extraction_class == _CLASS_CONDITION
]
self.assertTrue(
any(
isinstance(extraction.attributes, dict)
and isinstance(extraction.attributes.get("symptoms"), list)
for extraction in condition_extractions
),
f"Expected list-valued symptoms attribute. Got: {result.extractions}",
)
assert_valid_char_intervals(self, result)
@skip_if_no_openai
@live_api
@retry_on_transient_errors(max_retries=2)
def test_explicit_provider_selection(self):
"""Test using explicit provider parameter for disambiguation."""
# Test with explicit model_id and provider
config = lx.factory.ModelConfig(
model_id=DEFAULT_OPENAI_MODEL,
provider="OpenAILanguageModel", # Explicit provider selection
provider_kwargs={
"api_key": OPENAI_API_KEY,
"temperature": 0.0,
},
)
model = lx.factory.create_model(config)
self.assertIsInstance(model, lx.providers.openai.OpenAILanguageModel)
self.assertEqual(model.model_id, DEFAULT_OPENAI_MODEL)
# Also test using provider without model_id (uses default)
config_default = lx.factory.ModelConfig(
provider="OpenAILanguageModel",
provider_kwargs={
"api_key": OPENAI_API_KEY,
},
)
model_default = lx.factory.create_model(config_default)
self.assertEqual(model_default.__class__.__name__, "OpenAILanguageModel")
# Should use the default model_id from the provider
self.assertEqual(model_default.model_id, "gpt-4o-mini")
@skip_if_no_openai
@live_api
@retry_on_transient_errors(max_retries=2)
def test_medication_relationship_extraction(self):
"""Test relationship extraction for medications with OpenAI."""
input_text = """
The patient was prescribed Lisinopril and Metformin last month.
He takes the Lisinopril 10mg daily for hypertension, but often misses
his Metformin 500mg dose which should be taken twice daily for diabetes.
"""
prompt = textwrap.dedent("""
Extract medications with their details, using attributes to group related information:
1. Extract entities in the order they appear in the text
2. Each entity must have a 'medication_group' attribute linking it to its medication
3. All details about a medication should share the same medication_group value
""")
examples = get_relationship_examples()
result = lx.extract(
text_or_documents=input_text,
prompt_description=prompt,
examples=examples,
model_id=DEFAULT_OPENAI_MODEL,
api_key=OPENAI_API_KEY,
use_schema_constraints=False,
language_model_params=OPENAI_MODEL_PARAMS,
)
assert result is not None
assert len(result.extractions) > 0
assert_valid_char_intervals(self, result)
medication_groups = {}
for extraction in result.extractions:
assert (
extraction.attributes is not None
), f"Missing attributes for {extraction.extraction_text}"
assert (
"medication_group" in extraction.attributes
), f"Missing medication_group for {extraction.extraction_text}"
group_name = extraction.attributes["medication_group"]
medication_groups.setdefault(group_name, []).append(extraction)
assert (
len(medication_groups) >= 2
), f"Expected at least 2 medications, found {len(medication_groups)}"
# Allow flexible matching for dosage field (could be "dosage" or "dose")
for med_name, extractions in medication_groups.items():
extraction_classes = {e.extraction_class for e in extractions}
# At minimum, each group should have the medication itself
assert (
_CLASS_MEDICATION in extraction_classes
), f"{med_name} group missing medication entity"
# Dosage is expected but might be formatted differently
assert any(
c in extraction_classes for c in [_CLASS_DOSAGE, "dose"]
), f"{med_name} group missing dosage"
@skip_if_no_openai
@live_api
@retry_on_transient_errors(max_retries=2)
def test_reasoning_effort_passthrough(self):
"""reasoning_effort is accepted by reasoning models."""
examples = get_basic_medication_examples()
input_text = "Patient took 400 mg PO Ibuprofen q4h for two days."
config = lx.factory.ModelConfig(
model_id="o4-mini",
provider="OpenAILanguageModel",
provider_kwargs={
"api_key": OPENAI_API_KEY,
"reasoning_effort": "low",
},
)
result = lx.extract(
text_or_documents=input_text,
prompt_description="Extract medications.",
examples=examples,
config=config,
use_schema_constraints=False,
)
assert result is not None
self.assertIsInstance(result, lx.data.AnnotatedDocument)
class TestLiveAPIOutputSchema(unittest.TestCase):
"""Live tests for user-provided output_schema support."""
_OUTPUT_SCHEMA = {
"type": "object",
"properties": {
"extractions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"condition": {"type": "string"},
"condition_attributes": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["active", "resolved"],
}
},
"required": ["status"],
"additionalProperties": False,
},
},
"required": ["condition", "condition_attributes"],
"additionalProperties": False,
},
}
},
"required": ["extractions"],
"additionalProperties": False,
}
_INPUT_TEXT = "Patient has active hypertension and a resolved infection."
def _assert_schema_constrained_extractions(self, result):
self.assertIsInstance(result, lx.data.AnnotatedDocument)
self.assertTrue(result.extractions)
for extraction in result.extractions:
self.assertEqual(extraction.extraction_class, "condition")
if extraction.attributes:
self.assertIn(
extraction.attributes.get("status"), ("active", "resolved")
)
@skip_if_no_gemini
@live_api
def test_gemini_extract_with_output_schema(self):
result = lx.extract(
text_or_documents=self._INPUT_TEXT,
prompt_description=(
"Extract medical conditions with their status attribute."
),
model_id=DEFAULT_GEMINI_MODEL,
api_key=GEMINI_API_KEY,
output_schema=self._OUTPUT_SCHEMA,
)
self._assert_schema_constrained_extractions(result)
@skip_if_no_openai
@live_api
def test_openai_extract_with_output_schema(self):
result = lx.extract(
text_or_documents=self._INPUT_TEXT,
prompt_description=(
"Extract medical conditions with their status attribute."
),
model_id=DEFAULT_OPENAI_MODEL,
api_key=OPENAI_API_KEY,
output_schema=self._OUTPUT_SCHEMA,
)
self._assert_schema_constrained_extractions(result)