From 0076c7c7287a3e5699a52f735a0f770cb04e28c9 Mon Sep 17 00:00:00 2001 From: Mariia Borodii Date: Thu, 27 Aug 2026 17:30:22 +0300 Subject: [PATCH] Revert "Add web search tool to extract.ai and enrich its schema guidance (#1142)" This reverts commit 185a58825f6d3d6b7dc437b8f01e3ebac83d4867. --- .../extract_ai_recipe_examples.ipynb | 104 +------ docs/extract_ai_configuration.md | 48 +--- tests/recipes/wrangles/test_extract.py | 64 ----- tests/recipes/wrangles/test_main.py | 65 ----- .../samples/extract ai judge example.wrgl.yml | 4 +- tests/test_ai_definition.py | 68 +---- tests/test_openai_extract_ai.py | 257 +---------------- wrangles/ai_definition.py | 47 +--- wrangles/extract.py | 85 +----- wrangles/openai_responses.py | 134 +-------- wrangles/recipe_wrangles/extract.py | 266 ++++-------------- 11 files changed, 112 insertions(+), 1030 deletions(-) diff --git a/docs/examples/extract_ai/extract_ai_recipe_examples.ipynb b/docs/examples/extract_ai/extract_ai_recipe_examples.ipynb index 5f5be9de3..9a4a01b56 100644 --- a/docs/examples/extract_ai/extract_ai_recipe_examples.ipynb +++ b/docs/examples/extract_ai/extract_ai_recipe_examples.ipynb @@ -1925,10 +1925,7 @@ "\n", "`examples` on a field nudge the model toward a particular output style or\n", "unit format, without being included in the strict JSON schema sent to\n", - "OpenAI (they're folded into the instructions instead). The existing scalar\n", - "or list form remains supported. A field example can also be a paired object\n", - "with `input` and `output`, plus optional `name` and `notes`; that pair guides\n", - "only the field containing it." + "OpenAI (they're folded into the instructions instead)." ] }, { @@ -2022,54 +2019,6 @@ "result" ] }, - { - "cell_type": "markdown", - "id": "b18c5f72", - "metadata": {}, - "source": [ - "### Whole-record examples: `name`, `notes`, `input`, and `output`\n", - "\n", - "Top-level `record_examples` teach a complete input-to-output mapping. Keep the\n", - "source under `input`, the desired record under `output`, and optionally add\n", - "a descriptive `name` and explanatory `notes`. Sparse example outputs are\n", - "completed with `null` for\n", - "fields you omit. These are different from `examples` nested under one output\n", - "field, which guide only that field." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9f3a10de", - "metadata": {}, - "outputs": [], - "source": [ - "recipe_record_examples = '''\n", - "wrangles:\n", - " - extract.ai:\n", - " api_key: ${OPENAI_API_KEY}\n", - " output:\n", - " best_match:\n", - " type: string\n", - " description: Best catalog match for the input item\n", - " certainty:\n", - " type: string\n", - " enum: [high, medium, low]\n", - " record_examples:\n", - " - name: exact manufacturer part match\n", - " notes: Use high certainty only when both manufacturer and part number match.\n", - " input:\n", - " description: ACME valve V-100\n", - " output:\n", - " best_match: ACME V-100 valve\n", - " certainty: high\n", - "'''\n", - "\n", - "df = pd.DataFrame({\"description\": [\"ACME valve model V-100\"]})\n", - "result = recipe.run(recipe_record_examples, variables=variables, dataframe=df)\n", - "result" - ] - }, { "cell_type": "markdown", "id": "e6dc6eb6", @@ -2938,56 +2887,12 @@ "server.shutdown()" ] }, - { - "cell_type": "markdown", - "id": "57f0ca93", - "metadata": {}, - "source": [ - "## 14. Web search and source provenance\n", - "\n", - "Set `web_search: true` to make the native OpenAI Responses web-search tool\n", - "available to the model. The model decides whether a row needs a search.\n", - "Every processed row receives a separate `web_search_sources` column containing\n", - "a deduplicated list of `{title, url}` objects taken from Responses metadata.\n", - "The list is empty when no search/source was used. The reserved column name is\n", - "automatic; do not also define it under `output`.\n", - "\n", - "Web results are cached with the extracted value. Use `cache: false` when the\n", - "recipe must retrieve fresh web information. Web search requires the default\n", - "`responses` protocol and is not available with legacy `chat_completions`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d8641ec7", - "metadata": {}, - "outputs": [], - "source": [ - "recipe_web_search = '''\n", - "wrangles:\n", - " - extract.ai:\n", - " input: company\n", - " api_key: ${OPENAI_API_KEY}\n", - " web_search: true\n", - " cache: false\n", - " output:\n", - " latest_news_title:\n", - " type: string\n", - " description: Title of the company's most recent official news post\n", - "'''\n", - "\n", - "df = pd.DataFrame({\"company\": [\"OpenAI\"]})\n", - "result = recipe.run(recipe_web_search, variables=variables, dataframe=df)\n", - "result[[\"latest_news_title\", \"web_search_sources\"]]" - ] - }, { "cell_type": "markdown", "id": "0f2c87d0", "metadata": {}, "source": [ - "## 15. Quick reference\n", + "## 14. Quick reference\n", "\n", "| What you want | Recipe snippet |\n", "|---|---|\n", @@ -2995,16 +2900,13 @@ "| Multiple fields | Add more keys under `output` |\n", "| Quick shorthand | `output: ` (uses input column name) |\n", "| Constrained values | Add `enum: [...]` to a field |\n", - "| Overall guidance | `instructions: ...` applies guidance to every input row |\n", "| Numeric / list result | `type: integer` / `type: array` |\n", "| Style hints | Add `examples: [...]` to a field |\n", - "| Whole-record examples | `record_examples: [{name: ..., notes: ..., input: ..., output: ...}]` |\n", - "| Web search + sources | `web_search: true` adds `web_search_sources` |\n", "| Reasoning models | `model: gpt-5-mini`, `reasoning: {effort: low}`, `verbosity: low` |\n", "| Parallelism | `threads: ` |\n", "| Resilience | `timeout: `, `retries: ` |\n", "| Legacy endpoint | `url: https://api.openai.com/v1/chat/completions` |\n", - "| Saved WrangleWorks definition | `model_id: ` instead of an `output` schema; `api_key` is still required |" + "| WrangleWorks model | `model_id: ` instead of `api_key`/`model` |" ] } ], diff --git a/docs/extract_ai_configuration.md b/docs/extract_ai_configuration.md index dab71e7b0..47cb54485 100644 --- a/docs/extract_ai_configuration.md +++ b/docs/extract_ai_configuration.md @@ -19,28 +19,6 @@ versioned replacement YAML file to override the complete configuration. Recipes and Python calls can override these settings individually. Saved XL models and recipe outputs are compiled through the same definition compiler. -## Instructions - -Use `instructions` for guidance that applies to every input row: - -```yaml -wrangles: - - extract.ai: - input: Description - api_key: ${OPENAI_API_KEY} - instructions: - - Prefer explicit evidence over inferred evidence. - - Normalize dimensions to inches. - output: - Product Type: - type: string -``` - -Instructions are useful for decision rules, evidence priorities, -normalization requirements, or other behavior that applies to the complete -extraction. The former `messages` parameter remains available as a compatibility -alias but is no longer advertised in the recipe schema. Do not provide both. - ## Nullable output fields Defined output keys remain required so strict Structured Outputs always return @@ -67,7 +45,7 @@ cells as sequences; users do not need to quote every object key and value. ## Examples -Definitions support both field-specific and record examples. They compile to +Definitions support both field-specific and holistic examples. They compile to the same stable prompt representation and precede each row's dynamic input. For saved models, the field grid supports: @@ -83,9 +61,8 @@ expected output may be human-friendly JSON/YAML-like syntax. Use explicit list syntax for an array-valued paired output, such as `[Ceramic Tile, Slate]`. An explicit `null` is valid because output fields are nullable by default. -Field-specific examples teach only the named field. Paired field examples may -include optional `name` and `notes` metadata. Definitions may also provide -`record_examples` that pair one input with a multi-field output: +Field-specific examples teach only the named field. Definitions may also +provide holistic examples that pair one input with a multi-field output: ```yaml wrangles: @@ -96,16 +73,13 @@ wrangles: Power Source: type: string examples: - - name: cordless tool - notes: Voltage without a cord indicates a battery. - input: 18V cordless drill + - input: 18V cordless drill output: Battery - Corded Voltage: type: number - record_examples: + examples: - name: corded saw - notes: Use both fields from this complete example. input: 120V corded jig saw output: Power Source: Corded @@ -113,15 +87,13 @@ wrangles: ``` The first `Power Source` item is a paired field example; `Corded` remains -output-only value guidance. Top-level `record_examples` demonstrate the complete -record. Their output may be sparse: the compiler inserts `null` for omitted -output fields so every example demonstrates the complete required response -shape. Unknown fields and values that do not match the output schema fail during -compilation. Optional `name` and `notes` metadata are included in model guidance -at both levels. +output-only value guidance. Top-level `examples` are holistic. Their output may +be sparse: the compiler inserts `null` for omitted output fields so every +example demonstrates the complete required response shape. Unknown fields and +values that do not match the output schema fail during compilation. Saved-model content may likewise include a top-level `Examples` array of -record pairs. A dedicated WranglesXL interface for those examples can be +holistic pairs. A dedicated WranglesXL interface for those examples can be added later without changing the compiler or runtime contract. ## Result cache diff --git a/tests/recipes/wrangles/test_extract.py b/tests/recipes/wrangles/test_extract.py index d6a6f5cd4..ba1b702e6 100644 --- a/tests/recipes/wrangles/test_extract.py +++ b/tests/recipes/wrangles/test_extract.py @@ -5,70 +5,6 @@ from unittest.mock import patch -class TestExtractAIWebSearch: - @patch("wrangles.recipe_wrangles.extract._extract.ai") - def test_web_search_writes_a_dedicated_sources_column(self, extract_ai): - extract_ai.return_value = [ - { - "manufacturer": "Acme", - "web_search_sources": [{ - "title": "Acme product page", - "url": "https://example.com/acme", - }], - }, - { - "manufacturer": "Contoso", - "web_search_sources": [], - }, - ] - data = pd.DataFrame({"description": ["Acme part", "Contoso part"]}) - recipe = """ - wrangles: - - extract.ai: - input: description - api_key: test-key - web_search: true - output: - manufacturer: - type: string - description: Manufacturer name - """ - - result = wrangles.recipe.run(recipe, dataframe=data) - - assert result["manufacturer"].tolist() == ["Acme", "Contoso"] - assert result["web_search_sources"].tolist() == [ - [{ - "title": "Acme product page", - "url": "https://example.com/acme", - }], - [], - ] - assert extract_ai.call_args.kwargs["web_search"] is True - - @patch("wrangles.recipe_wrangles.extract._extract.ai") - def test_web_search_rejects_an_existing_sources_column(self, extract_ai): - data = pd.DataFrame({ - "description": ["Acme part"], - "web_search_sources": [[]], - }) - recipe = """ - wrangles: - - extract.ai: - input: description - api_key: test-key - web_search: true - output: - manufacturer: - type: string - """ - - with pytest.raises(ValueError, match="is reserved"): - wrangles.recipe.run(recipe, dataframe=data) - - extract_ai.assert_not_called() - - class TestExtractAddress: """ Test extract.address diff --git a/tests/recipes/wrangles/test_main.py b/tests/recipes/wrangles/test_main.py index 058ad1f2c..8e04b070d 100644 --- a/tests/recipes/wrangles/test_main.py +++ b/tests/recipes/wrangles/test_main.py @@ -9339,71 +9339,6 @@ def test_all_wrangle_docstrings_parse_as_yaml(self): assert not failures, 'Wrangle schema docstring YAML parse failures:\n' + '\n'.join(failures) - def test_extract_ai_schema_documents_public_parameters_and_example_shape(self): - import inspect - import yaml - - method = wrangles.recipe._recipe_wrangles.extract.ai - schema = yaml.safe_load(method.__doc__) - properties = schema["properties"] - parameters = { - name - for name in inspect.signature(method).parameters - if name not in {"df", "kwargs"} - } - - assert parameters <= properties.keys() - assert all( - isinstance(definition.get("description"), str) - and definition["description"].strip() - for definition in properties.values() - ) - assert properties["web_search"]["type"] == "boolean" - assert "web_search_sources" in properties["web_search"]["description"] - - assert "messages" not in properties - instructions = properties["instructions"] - assert instructions["title"] == "Instructions" - assert instructions["type"] == ["string", "array"] - assert "every input row" in instructions["description"] - assert "decision rules" in instructions["description"] - assert "after" not in instructions["description"].lower() - - assert "examples" not in properties - examples = properties["record_examples"] - assert examples["title"] == "Record examples" - assert examples["required"] == ["input", "output"] - assert examples["items"]["required"] == ["input", "output"] - assert set(examples["items"]["properties"]) == { - "name", - "notes", - "input", - "output", - } - assert all( - definition["description"].strip() - for definition in examples["items"]["properties"].values() - ) - - field_schema = next(iter(properties["output"]["patternProperties"].values())) - field_examples = field_schema["properties"]["examples"] - assert field_examples["title"] == "Field examples" - assert "backward-compatible" in field_examples["description"] - assert "optional name and notes" in field_examples["description"] - paired_field_example = field_examples["items"]["anyOf"][0] - assert paired_field_example["required"] == ["input", "output"] - assert set(paired_field_example["properties"]) == { - "name", - "notes", - "input", - "output", - } - assert all( - definition["description"].strip() - for definition in field_schema["properties"].values() - ) - assert "effort" in properties["reasoning"]["properties"] - def test_extract_codes_schema_matches_microservice_params(self): import yaml diff --git a/tests/samples/extract ai judge example.wrgl.yml b/tests/samples/extract ai judge example.wrgl.yml index 31193409b..1572a253c 100644 --- a/tests/samples/extract ai judge example.wrgl.yml +++ b/tests/samples/extract ai judge example.wrgl.yml @@ -33,7 +33,7 @@ wrangles: input: Judge Input api_key: ${OPENAI_API_KEY} model: gpt-5.4-mini - instructions: | + messages: | You are an expert classifier for supplier product content. Each input contains: @@ -128,7 +128,7 @@ wrangles: description: | True when confidence is Uncertain or Selected Classification is "None (needs review)"; otherwise false. - record_examples: + examples: - name: dynamic two-candidate example input: Input Description: 18V cordless drill with battery and charger diff --git a/tests/test_ai_definition.py b/tests/test_ai_definition.py index fd139a99d..00a63405e 100644 --- a/tests/test_ai_definition.py +++ b/tests/test_ai_definition.py @@ -457,18 +457,13 @@ def test_saved_model_compiles_new_field_example_columns_and_legacy_examples(): assert compiled.output["Power_Source"]["examples"] == ["Corded", "Battery"] -def test_recipe_field_pairs_and_record_examples_compile_to_stable_guidance(): +def test_recipe_field_pairs_and_holistic_examples_compile_to_stable_guidance(): compiled = ai_definition.compile_definition( { "Power Source": { "type": "string", "examples": [ - { - "name": "cordless tool", - "notes": "Voltage without a cord indicates a battery.", - "input": "18V cordless drill", - "output": "Battery", - }, + {"input": "18V cordless drill", "output": "Battery"}, "Corded", ], }, @@ -478,7 +473,6 @@ def test_recipe_field_pairs_and_record_examples_compile_to_stable_guidance(): examples=[ { "name": "corded saw", - "notes": "Use both fields from this complete record example.", "input": "120V corded jig saw", "output": {"Power Source": "Corded", "Voltage": 120}, }, @@ -492,15 +486,10 @@ def test_recipe_field_pairs_and_record_examples_compile_to_stable_guidance(): assert compiled.output["Power_Source"]["examples"] == ["Corded"] assert compiled.field_examples[0].field == "Power_Source" assert compiled.field_examples[0].output == "Battery" - assert compiled.field_examples[0].name == "cordless tool" - assert compiled.field_examples[0].notes == "Voltage without a cord indicates a battery." assert compiled.record_examples[0].output == { "Power_Source": "Corded", "Voltage": 120, } - assert compiled.record_examples[0].notes == ( - "Use both fields from this complete record example." - ) assert compiled.record_examples[1].output == { "Power_Source": None, "Voltage": None, @@ -509,13 +498,10 @@ def test_recipe_field_pairs_and_record_examples_compile_to_stable_guidance(): guidance = ai_definition.render_example_guidance(compiled) assert " tuple: f"{path}.examples[{index}].output", "must be provided for a paired field example.", ) - pair = { + pairs.append({ "input": self._parse_json_value(item["input"]), "output": self._parse_json_value(item["output"]), - } - for metadata_key in ("name", "notes"): - metadata_value = item.get(metadata_key) - if metadata_value not in (None, ""): - pair[metadata_key] = str(metadata_value) - pairs.append(pair) + }) if value_examples: schema["examples"] = value_examples @@ -435,15 +427,11 @@ def compile_record_examples( name = raw_example.get("name") if name in (None, ""): name = f"example-{index + 1}" - notes = raw_example.get("notes") - if notes in (None, ""): - notes = None compiled.append( CompiledRecordExample( name=str(name), input=example_input, output=complete_output, - notes=str(notes) if notes is not None else None, ) ) return compiled @@ -822,7 +810,7 @@ def compile_definition( Compile a recipe/Python output and optional saved XL model definition. Direct keyed output overrides fields from the saved model. Saved model - instructions and record examples run first, followed by call-level + instructions and holistic examples run first, followed by call-level messages and examples. """ compiler = _Compiler(source) @@ -930,8 +918,6 @@ def compile_definition( sanitized_output[sanitized_field], f"output.{original_field}.examples[{index}].output", ), - name=pair.get("name"), - notes=pair.get("notes"), ) ) @@ -991,17 +977,8 @@ def render_example_guidance(compiled: CompiledAIDefinition) -> str: if compiled.field_examples: blocks = [] for example in compiled.field_examples: - attributes = f" field={_json.dumps(example.field)}" - if example.name: - attributes += f" name={_json.dumps(example.name)}" - lines = [ - f"", - ] - if example.notes: - lines.append( - f"{_json.dumps(example.notes, ensure_ascii=False)}" - ) - lines.extend([ + blocks.append("\n".join([ + f'', f"{_json.dumps(example.input, ensure_ascii=False, default=str)}", ( "" @@ -1009,8 +986,7 @@ def render_example_guidance(compiled: CompiledAIDefinition) -> str: "" ), "", - ]) - blocks.append("\n".join(lines)) + ])) sections.append( "Each field example below demonstrates only the named output field. " "Do not infer values for other fields from its expected value.\n" @@ -1020,14 +996,8 @@ def render_example_guidance(compiled: CompiledAIDefinition) -> str: if compiled.record_examples: blocks = [] for example in compiled.record_examples: - lines = [ + blocks.append("\n".join([ f'', - ] - if example.notes: - lines.append( - f"{_json.dumps(example.notes, ensure_ascii=False)}" - ) - lines.extend([ f"{_json.dumps(example.input, ensure_ascii=False, default=str)}", ( "" @@ -1035,8 +1005,7 @@ def render_example_guidance(compiled: CompiledAIDefinition) -> str: "" ), "", - ]) - blocks.append("\n".join(lines)) + ])) sections.append( "The record examples below demonstrate the complete expected output shape.\n" + "\n".join(blocks) diff --git a/wrangles/extract.py b/wrangles/extract.py index 13f4f2649..68de22ed0 100644 --- a/wrangles/extract.py +++ b/wrangles/extract.py @@ -69,28 +69,6 @@ def _cacheable_ai_result(result) -> bool: return True -def _enable_responses_web_search(payload: dict) -> None: - """Add native web search without replacing expert Responses settings.""" - tools = payload.setdefault("tools", []) - if not isinstance(tools, list): - raise ValueError("OpenAI Responses 'tools' must be an array.") - if not any( - isinstance(tool, dict) - and tool.get("type") in {"web_search", "web_search_preview"} - for tool in tools - ): - tools.append({"type": "web_search"}) - - included = payload.setdefault("include", []) - if not isinstance(included, list): - raise ValueError("OpenAI Responses 'include' must be an array.") - source_include = "web_search_call.action.sources" - if source_include not in included: - included.append(source_include) - - payload.setdefault("tool_choice", "auto") - - def address( input: _Union[str, list], dataType: str, @@ -136,9 +114,8 @@ def ai( threads: int = None, timeout: float = None, retries: int = None, - messages: _Union[str, list] = None, - examples: _Union[dict, list] = None, - record_examples: _Union[dict, list] = None, + messages: list = None, + examples: list = None, url: str = None, strict: bool = None, reasoning: dict = None, @@ -149,8 +126,6 @@ def ai( store: bool = None, cache: bool = None, cache_ttl: float = None, - web_search: bool = False, - instructions: _Union[str, list] = None, **kwargs ) -> _Union[dict, list]: """ @@ -165,21 +140,17 @@ def ai( :param input: A single value or list of values to extract information from. If a list is provided, \ each element will be analyzed individually and a list of equal length will be returned. - :param api_key: OpenAI API key. + :param api_key: API Key :param output: (Optional) This can be a string prompting the output, a JSON schema definition \ of the output requested or a dict of JSON schema definitions. :param model_id: (Optional) An extract.ai model ID containing a saved definition. Use this or output. \ - If both are provided, named output fields take precedence over matching saved fields. + If both are provided, output that precedence over the definition from the model_id. :param model: (Optional) The model to use for the extraction. :param threads: (Optional) Number of threads to use for parallel processing. :param timeout: (Optional) Timeout in seconds for each API call. :param retries: (Optional) Number of retries to attempt on failure. - :param instructions: (Optional) Additional guidance applied to every input row. Use this for - decision rules, evidence priorities, normalization requirements, or other behavior that - applies to the complete extraction. - :param messages: (Optional) Compatibility alias for instructions. - :param examples: (Optional) Compatibility alias for record_examples. - :param record_examples: (Optional) Whole-record examples containing input and output, with optional name and notes. + :param messages: (Optional) Overall prompts to pass additional instructions. + :param examples: (Optional) Holistic examples containing paired input and output values. :param url: (Optional) Override the configured endpoint. :param strict: (Optional) Enable structured output strict mode. Dynamic object schemas \ automatically use non-strict mode and are validated locally. @@ -193,10 +164,8 @@ def ai( :param store: (Optional) Whether OpenAI may store Responses. Defaults to False. :param cache: (Optional) Use the bounded warm-instance result cache. Defaults to True. :param cache_ttl: (Optional) Override the result-cache TTL in seconds for this call. - :param web_search: (Optional) Enable native Responses web search. Each result then includes a - web_search_sources list containing source titles and URLs. Defaults to False. - :return: Extracted information. When web_search is true, returns a dictionary (or list of - dictionaries) containing web_search_sources, including for single-field output. + + :return: A scalar or list of extracted information. """ policy = _ai_config.extract_ai() provider = str(provider or policy.get("provider", "openai")).strip().lower() @@ -215,10 +184,6 @@ def ai( else: protocol = policy.get("protocol", "responses") protocol = _normalize_ai_protocol(protocol) - if not isinstance(web_search, bool): - raise ValueError("web_search must be true or false.") - if web_search and protocol != "responses": - raise ValueError("web_search is supported only with protocol='responses'.") if url: if protocol == "responses" and "/chat/completions" in url: @@ -255,14 +220,8 @@ def ai( raise ValueError("reasoning must be an object such as {'effort': 'none'}.") _validate_ai_runtime_settings(threads, timeout, retries, deadline) - if instructions not in (None, "") and messages not in (None, ""): - raise ValueError("Use instructions or messages, not both.") - if instructions in (None, ""): - instructions = messages - if record_examples not in (None, "") and examples not in (None, ""): - raise ValueError("Use record_examples or examples, not both.") - if record_examples in (None, ""): - record_examples = examples + if messages is None: + messages = [] # Ensure input is a list input_was_scalar = False @@ -278,8 +237,8 @@ def ai( compiled = _ai_definition.compile_definition( output, model=model, - messages=instructions, - examples=record_examples, + messages=messages, + examples=examples, strict=strict, saved_model_content=saved_model_content, source=f"saved model {model_id}" if model_id else "recipe/Python output", @@ -292,14 +251,6 @@ def ai( _needs_remap = compiled.needs_remap root_schema = compiled.root_schema example_guidance = _ai_definition.render_example_guidance(compiled) - if ( - web_search - and _openai_responses.WEB_SEARCH_SOURCES_KEY in compiled.output - ): - raise ValueError( - f"{_openai_responses.WEB_SEARCH_SOURCES_KEY!r} is reserved when " - "web_search is enabled. Choose a different output field name." - ) messages = [ { @@ -326,12 +277,6 @@ def ai( str(message.get("content", "")) for message in messages ) - if web_search: - instructions += "\n\n" + " ".join([ - "Web search is enabled for this call.", - "Information returned by the web search tool is authorized evidence in addition to DATA.", - "Use web search only when it helps answer the requested fields, and return null when neither DATA nor web evidence supports a field.", - ]) payload = { "model": model, @@ -347,8 +292,6 @@ def ai( "store": store, **_openai_responses.sanitize_request_params(kwargs), } - if web_search: - _enable_responses_web_search(payload) configured_reasoning = ( reasoning if reasoning is not None @@ -426,12 +369,12 @@ def ai( ] if input_was_scalar: - if output_generic_key and not web_search: + if output_generic_key: return results[0].get('output', 'Failed') else: return results[0] else: - if output_generic_key and not web_search: + if output_generic_key: return [x.get('output', 'Failed') for x in results] else: return results diff --git a/wrangles/openai_responses.py b/wrangles/openai_responses.py index 7dad8c0df..60afc8cc0 100644 --- a/wrangles/openai_responses.py +++ b/wrangles/openai_responses.py @@ -25,7 +25,6 @@ _LOG = _logging.getLogger(__name__) _LOCK = _threading.Lock() _SUCCESS_STATS = {} -WEB_SEARCH_SOURCES_KEY = "web_search_sources" _JSON_TYPE_MAP = { "string": str, "number": float, @@ -527,101 +526,11 @@ def format_input_data(data: _Any) -> str: return str(data) -def _uses_web_search(payload: dict) -> bool: - tools = payload.get("tools", []) - if not isinstance(tools, list): - return False - return any( - isinstance(tool, dict) - and tool.get("type") in {"web_search", "web_search_preview"} - for tool in tools - ) - - -def extract_web_search_sources(response_json: dict) -> list: - """Return cited and consulted web URLs in stable response order.""" - annotation_titles = {} - annotations = [] - output = response_json.get("output", []) - if not isinstance(output, list): - output = [] - - for item in output: - if not isinstance(item, dict) or item.get("type") != "message": - continue - content_items = item.get("content", []) - if not isinstance(content_items, list): - continue - for content in content_items: - if not isinstance(content, dict): - continue - content_annotations = content.get("annotations", []) - if not isinstance(content_annotations, list): - continue - for annotation in content_annotations: - if ( - not isinstance(annotation, dict) - or annotation.get("type") != "url_citation" - ): - continue - url = annotation.get("url") - if not isinstance(url, str) or not url.strip(): - continue - url = url.strip() - title = annotation.get("title") - title = title.strip() if isinstance(title, str) else "" - annotations.append((url, title)) - if title and url not in annotation_titles: - annotation_titles[url] = title - - sources = [] - positions = {} - - def add_source(url, title=""): - if not isinstance(url, str) or not url.strip(): - return - url = url.strip() - title = title.strip() if isinstance(title, str) else "" - title = title or annotation_titles.get(url, "") - if url in positions: - existing = sources[positions[url]] - if not existing["title"] and title: - existing["title"] = title - return - positions[url] = len(sources) - sources.append({"title": title, "url": url}) - - for item in output: - if not isinstance(item, dict) or item.get("type") != "web_search_call": - continue - action = item.get("action") - if not isinstance(action, dict): - continue - action_sources = action.get("sources", []) - if isinstance(action_sources, list): - for source in action_sources: - if isinstance(source, dict): - add_source(source.get("url"), source.get("title", "")) - add_source(action.get("url"), action.get("title", "")) - - for url, title in annotations: - add_source(url, title) - - return sources - - -def error_result( - required_fields: list, - message: str, - include_web_search_sources: bool = False, -) -> dict: - result = { +def error_result(required_fields: list, message: str) -> dict: + return { field: message for field in required_fields } - if include_web_search_sources: - result[WEB_SEARCH_SOURCES_KEY] = [] - return result def extract_response_text(response_json: dict) -> str: @@ -729,24 +638,13 @@ def call_structured( "content": f"DATA:\n{format_input_data(data)}", } ] - include_web_search_sources = _uses_web_search(request_payload) - - def failure(message: str, response_json: dict = None) -> dict: - result = error_result( - required_fields, - message, - include_web_search_sources=include_web_search_sources, - ) - if include_web_search_sources and isinstance(response_json, dict): - result[WEB_SEARCH_SOURCES_KEY] = extract_web_search_sources(response_json) - return result response = None backoff_time = 1 for attempt in range(retries + 1): remaining = _remaining_seconds(deadline_at) if remaining is not None and remaining <= 0: - return failure("Deadline Exceeded") + return error_result(required_fields, "Deadline Exceeded") request_timeout = timeout if remaining is not None: @@ -764,16 +662,14 @@ def failure(message: str, response_json: dict = None) -> dict: elapsed_seconds = _time.time() - started except _requests.exceptions.Timeout: if attempt >= retries: - return failure("Timed Out") + return error_result(required_fields, "Timed Out") except Exception as e: if attempt >= retries: - return failure(str(e)) + return error_result(required_fields, str(e)) if response is not None and response.ok: try: -response_json = None - response_json = response.json() - output_text = extract_response_text(response_json) + output_text = extract_response_text(response.json()) parsed = _json.loads(output_text) if not isinstance(parsed, dict): raise ValueError("Structured response was not a JSON object.") @@ -784,18 +680,10 @@ def failure(message: str, response_json: dict = None) -> dict: model=request_payload.get("model"), elapsed_seconds=elapsed_seconds, ) - validated = validate_structured_output(parsed, schema) - if include_web_search_sources: - validated[WEB_SEARCH_SOURCES_KEY] = extract_web_search_sources( - response_json - ) - return validated + return validate_structured_output(parsed, schema) except (_json.JSONDecodeError, _ValidationError, ValueError) as e: if attempt >= retries: - return failure( - f"Invalid structured response: {e}", - response_json=response_json, - ) + return error_result(required_fields, f"Invalid structured response: {e}") else: context = _response_context( response, @@ -812,7 +700,7 @@ def failure(message: str, response_json: dict = None) -> dict: raise ValueError("API Key provided is missing or invalid.") if attempt >= retries or not _should_retry(context): _log_api_error(context, final=True) - return failure(_error_message(context)) + return error_result(required_fields, _error_message(context)) _log_api_error(context, final=False) if response is not None and not response.ok: @@ -820,7 +708,7 @@ def failure(message: str, response_json: dict = None) -> dict: else: delay = _sleep_for_retry({}, backoff_time, deadline_at) if delay is None: - return failure("Deadline Exceeded") + return error_result(required_fields, "Deadline Exceeded") backoff_time *= 2 - return failure("Failed") + return error_result(required_fields, "Failed") diff --git a/wrangles/recipe_wrangles/extract.py b/wrangles/recipe_wrangles/extract.py index a61cb0fd7..c69650907 100644 --- a/wrangles/recipe_wrangles/extract.py +++ b/wrangles/recipe_wrangles/extract.py @@ -26,7 +26,6 @@ "concatenate": "concatenate", "concat": "concatenate", } -_WEB_SEARCH_SOURCES_KEY = "web_search_sources" def _normalize_output_format(output_format, default): @@ -294,18 +293,13 @@ def ai( input: list = None, output: _Union[dict, str, list] = None, model_id: str = None, - record_examples: _Union[dict, list] = None, output_format: str = None, char: str = ", ", - web_search: bool = False, - instructions: _Union[str, list] = None, **kwargs ): """ type: object - description: >- - Extract structured data from each input row using an AI model. Define - the desired fields with output, or reuse a saved definition with model_id. + description: Extract data using an AI model. additionalProperties: false required: - api_key @@ -320,26 +314,20 @@ def ai( - string - integer - array - description: >- - Input column name, column index, or list of columns supplied together - as DATA for each row. If omitted, all dataframe columns are supplied. - items: - type: [string, integer] + description: |- + Name or list of input columns to give to the AI + to use to determine the output. If not specified, all + columns will be used. output: type: [object, string, array] - description: >- - Desired extraction. Use an object keyed by output column name for - structured fields, a string for one prompted value, or an array of - field names/definitions. Each field may use the schema options below. + description: List and description of the output you want patternProperties: "^[a-zA-Z0-9 _-]+$": type: [object, string] properties: type: type: string - description: >- - JSON data type required for this field. If omitted, common - scalar types are accepted. Fields allow null by default. + description: The type of data you'd like the model to return. enum: - string - number @@ -350,14 +338,10 @@ def ai( - array description: type: string - description: >- - Plain-language definition of the value to extract, including - any selection, normalization, unit, or evidence rules. + description: Description of the output you'd like the model to return. enum: type: array - description: >- - Allowed output values. The model must choose one of these - values; null is also allowed unless nullable is false. + description: List of possible values for the output. default: type: - string @@ -367,12 +351,8 @@ def ai( - "null" - object - array - description: >- - JSON Schema annotation for a preferred default. extract.ai - does not substitute this value when evidence is missing; - describe fallback behavior explicitly or allow null. + description: A default value to return. examples: - title: Field examples type: - array - object @@ -382,250 +362,131 @@ def ai( - boolean - "null" description: >- - Field-specific examples. The backward-compatible form is a - scalar or list of typical output values. A paired example may - instead use input and output, with optional name and notes. - Paired examples apply only to this output field; use - record_examples for complete output records. - properties: - name: - type: string - description: Optional label included with this paired field example. - notes: - type: string - description: Optional explanatory guidance included with this paired field example. - input: - description: Source value or record for this paired field example. - output: - description: Expected value for this output field only. - items: - anyOf: - - type: object - required: - - input - - output - properties: - name: - type: string - description: Optional label included with this paired field example. - notes: - type: string - description: Optional explanatory guidance included with this paired field example. - input: - description: Source value or record for this paired field example. - output: - description: Expected value for this output field only. - - description: Backward-compatible output-only example value. + Provide typical output values, or paired objects with input + and output keys for field-specific examples. properties: type: - object - array - string description: >- - Child fields when type is object. Use an object to define a - schema for each child. A list or comma-separated string is a - shortcut that creates fixed child names. + Named properties for an object. A list or comma-separated + string creates fixed property names. additionalProperties: type: - boolean - object description: >- - Controls keys beyond properties when type is object. Set false - for fixed keys, true for arbitrary values, or provide one - schema applied to every dynamic value. Dynamic dictionaries - use non-strict provider mode plus local validation. + Set true, or provide a value schema, for a dynamic dictionary. + Dynamic dictionaries automatically use non-strict schema mode + while fixed portions of the output remain constrained. items: type: object - description: >- - Schema applied to every element when this field's type is array. + description: Schema for each item returned by an array. nullable: type: boolean description: >- Whether the field may return null. Defaults to true while the field key remains required. Set false to opt out. - record_examples: - title: Record examples + examples: type: - array - object description: >- - Whole-record examples. Each example has a separate input value or - record and the complete expected output record. Optional name and - notes provide model-visible context. Use {name: ..., notes: ..., - input: ..., output: ...}. Omitted output fields are completed with - null. This differs from examples nested under one output field, which - teach only that field. - required: - - input - - output - properties: - name: - type: string - description: Optional label used to identify this example in the prompt. - notes: - type: string - description: Optional explanatory guidance included with this example. - input: - description: Source value or record the example should match. - output: - description: Expected result using the field names defined by output. - items: - type: object - required: - - input - - output - properties: - name: - type: string - description: Optional label used to identify this example in the prompt. - notes: - type: string - description: Optional explanatory guidance included with this example. - input: - description: Source value or record the example should match. - output: - description: Expected result using the field names defined by output. + Holistic input/output example pairs spanning the complete output + record. Sparse outputs are completed with null for omitted fields. api_key: type: string - description: OpenAI API key used for this wrangle, normally supplied through a recipe variable. + description: API Key for the model model: type: string - description: >- - OpenAI model ID for this call. If omitted, uses the configured - extract.ai default; a saved model definition may supply its own model. + description: The name of the AI model to use threads: type: integer minimum: 1 - description: Maximum number of row-level requests sent in parallel. The configured default is 32. + description: The number of requests to send in parallel. The configured default is 32. timeout: type: number exclusiveMinimum: 0 - description: >- - Maximum seconds for one HTTP attempt. The configured default is 12; - deadline can end the overall call sooner. + description: Per-request timeout in seconds. The configured default is 12. retries: type: integer minimum: 0 description: >- - Number of additional attempts after a retryable failure. The configured - default is 1. Backoff and request timeouts remain bounded by deadline. + The number of times to retry if the request fails. + Defaults to 1. Retry delays and request timeouts are bounded + by the total deadline. url: type: string description: |- - Override the endpoint for the selected protocol. A chat/completions URL - selects the legacy protocol only when protocol is omitted; new recipes - should use the configured Responses endpoint. + Override the endpoint configured for the selected protocol. + Existing chat/completions URLs are detected for backwards compatibility, + but definitions should set protocol explicitly while being upgraded. provider: type: string - description: AI service provider. Currently only OpenAI is supported. + description: AI provider. Phase 1 supports OpenAI; this field reserves a stable provider boundary. enum: - openai protocol: type: string - description: >- - OpenAI API protocol. Responses is the configured default and is required - for web_search; chat_completions remains available for legacy definitions. + description: API protocol. Responses is the configured default. enum: - responses - chat_completions deadline: type: number exclusiveMinimum: 0 - description: >- - Total seconds allowed for the entire wrangle call, including queued - work, retries, and backoff. The configured default is 15. + description: Total call budget in seconds, including retries. The configured default is 15. store: type: boolean - description: Whether OpenAI may store Responses API results. Defaults to false. + description: Whether OpenAI may store Responses. Defaults to false. cache: type: boolean description: >- - Reuse identical successful results from the bounded warm-instance cache. - Defaults to true. Set false when fresh model or web results are required. + Use the bounded warm-instance result cache. Defaults to true. + Set false for a call-level bypass. cache_ttl: type: number exclusiveMinimum: 0 - description: >- - Maximum age in seconds for a cached result used by this call. Applies - to extracted values and web_search_sources together. - instructions: - title: Instructions + description: Override the result-cache TTL in seconds for this call. + messages: type: - string - array - description: >- - Additional guidance applied to every input row. Use this for decision - rules, evidence priorities, normalization requirements, or other - behavior that applies to the complete extraction. - items: - type: string + description: Optional. Provide additional overall instructions for the AI. model_id: type: string - description: >- - ID of a saved extract.ai definition. Use it instead of defining an - output schema. When output is also supplied with model_id in a recipe, - output names the destination column or columns for the saved fields. + description: Use a saved definition from an extract ai wrangle. strict: type: boolean description: >- - Require OpenAI structured-output strict mode. Defaults to true. - Definitions with dynamic dictionary keys automatically switch to - non-strict provider mode and are still validated locally. + Enable structured output strict mode. Default true. Definitions with + dynamic dictionaries automatically use non-strict provider mode and + are validated locally. output_format: type: string - description: >- - How extracted fields are written. columns writes one dataframe column - per field (default); dictionary keeps one object; concatenate joins - fields into one string using char. + description: Format of the extract output enum: - dictionary - columns - concatenate char: type: string - description: Separator used only when output_format is concatenate. Defaults to comma-space. + description: Character to use when output_format is concatenate reasoning: type: object description: >- - Responses API reasoning controls. Set effort for reasoning-capable - models. The configured default is none when that model supports it; - otherwise the provider default applies. - properties: - effort: - type: string - description: Amount of reasoning work requested from a compatible model. - enum: - - none - - minimal - - low - - medium - - high - - xhigh + Responses API reasoning options. Defaults to effort none for models + that support disabling reasoning; otherwise the provider default applies. verbosity: type: string - description: >- - Responses API text verbosity for compatible models. Defaults to low - when supported; ignored with a warning for incompatible models. + description: Responses API output verbosity. Defaults to low for models that support low verbosity. enum: - low - medium - high - web_search: - type: boolean - description: >- - Enable OpenAI Responses web search; the model decides when searching - helps. When true, every row also receives web_search_sources: a - deduplicated list of {title, url} objects in source order, or an empty - list when no source was used. This reserved column is automatic. - Requires protocol responses. Defaults to false. """ output_format_normalized = _normalize_output_format(output_format, "columns") - if not isinstance(web_search, bool): - raise ValueError("web_search must be true or false.") - if web_search and _WEB_SEARCH_SOURCES_KEY in df.columns: - raise ValueError( - f"Column {_WEB_SEARCH_SOURCES_KEY!r} is reserved when web_search is enabled." - ) # If input is provided, extract only those columns # Otherwise, provide the whole dataframe @@ -686,37 +547,15 @@ def ai( # If a schema has been provided, define the target columns if not target_columns and output is not None: target_columns = list(output.keys()) - if web_search and target_columns and _WEB_SEARCH_SOURCES_KEY in target_columns: - raise ValueError( - f"Output column {_WEB_SEARCH_SOURCES_KEY!r} is reserved when web_search is enabled." - ) results = _extract.ai( df_temp.to_dict(orient='records'), api_key=api_key, output=output, model_id=model_id, - record_examples=record_examples, - web_search=web_search, - instructions=instructions, **kwargs ) - web_search_sources = None - if web_search: - web_search_sources = [] - extraction_results = [] - for result in results: - if not isinstance(result, dict): - web_search_sources.append([]) - extraction_results.append(result) - continue - result = dict(result) - sources = result.pop(_WEB_SEARCH_SOURCES_KEY, []) - web_search_sources.append(sources if isinstance(sources, list) else []) - extraction_results.append(result) - results = extraction_results - try: exploded_df = _pd.json_normalize(results, max_level=0).fillna('').set_index(df.index) @@ -760,13 +599,6 @@ def ai( except: raise RuntimeError("Unable to parse response from AI model") - if web_search_sources is not None: - df[_WEB_SEARCH_SOURCES_KEY] = _pd.Series( - web_search_sources, - index=df.index, - dtype=object, - ) - return df