mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(infoquest): contain malformed search payloads instead of crashing on them (#6932)
* fix(infoquest): contain malformed search payloads instead of crashing on them
`clean_results` indexed the envelope directly:
for content_list in raw_results:
content = content_list["content"]
results = content["results"]
if results.get("organic"):
for result in results["organic"]:
so a payload that did not match the expected shape either raised a raw Python
error that the tool then reported to the agent as the search result, or was
silently swallowed:
{"search_result": {"results": "maintenance"}} -> {"error": "... error='string indices must be integers'"}
{"search_result": {"results": [{"content": "x"}]}} -> {"error": "... error='string indices must be integers'"}
{"search_result": {"results": [1, 2]}} -> {"error": "... error='int' object is not subscriptable"}
{"search_result": {}} -> {"error": "... error='results'"}
and, worst of them, a malformed container was skipped silently, so the call
answered `[]` -- telling the agent the query legitimately matched nothing:
{"results": [{"content": {"results": {"organic": "x"}}}]} -> []
{"results": [{"content": {"results": {"organic": {"a": 1}}}}]} -> []
This is the containment rule the sibling bundled providers already follow
(brave #6550, groundroute #6603, tavily #6645, sofya #6719, serper #6779,
serply #6796, tencent-wsa #6799): a payload that is not a result page is a
format error, not an empty search.
`_envelope_entries` now validates the `content.results` envelope and
`_rows` validates one vertical's container, both shared by the web and image
verticals. A missing or null container stays an empty search; a container that
is not a list, or a non-empty one holding no object entries, returns the
provider's format error; malformed entries in an otherwise valid payload are
skipped in order so the usable rows survive. Transport failures keep their
existing error text.
Diagnostics log the container, the envelope position, or the unexpected type --
never queries or payload values.
* test(infoquest): cover the envelope guards and log skipped rows by position
Three review follow-ups on the malformed-payload containment: drop the
accidentally stacked @staticmethod on _envelope_entries, give _rows the same
indexed skip warning _envelope_entries already emits (with the container name
so a provider-side drift leaves a trace), and pin the four isinstance guards --
a non-object top-level payload and a non-object search_result across both
verticals -- which no existing case reached.
This commit is contained in:
parent
072e48a338
commit
4f35eb586a
@ -190,6 +190,8 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta
|
||||
|
||||
Serply web search contains malformed HTTP-success payloads the same way in every vertical: a `results`/`entries`/`articles` container that is not a list, or a non-empty list with no object entries, returns the provider's format error, while non-object entries in an otherwise valid list are skipped in order. Missing, null, or empty containers keep the existing "No results found" response.
|
||||
|
||||
InfoQuest web and image search contain malformed HTTP-success payloads the same way: a `search_result` envelope that is not an object, or an `organic`/`top_stories.items`/`images_results` container that is not a list or that holds no usable entry, returns the provider's format error, while malformed entries in an otherwise valid payload are skipped so the usable rows survive. Missing, null, or empty containers keep the existing empty result. Format errors log the container or entry position only, without queries or payload values.
|
||||
|
||||
Jina, Browserless, and InfoQuest web fetches resolve relative links and image sources using the requested page URL (or a usable HTML base URL), so returned Markdown includes complete destinations. Link resolution preserves the surrounding HTML source, including malformed-page formatting.
|
||||
|
||||
Self-hosted Browserless, Crawl4AI, Firecrawl, and fastCRW fetch backends require outbound isolation from private, loopback, link-local, and cloud-metadata networks before setting `network_isolation_confirmed: true` on each tool. DeerFlow rejects private or unverifiable backends by default because those services handle redirects, DNS resolution, and subresources themselves. `allow_private_addresses` permits intentional internal target URLs and does not bypass the backend check. See [delegated fetch deployment](backend/docs/CONFIGURATION.md#delegated-fetch-backend-isolation); `make doctor` reports backend configurations that would be refused.
|
||||
|
||||
@ -18,6 +18,16 @@ logger = logging.getLogger(__name__)
|
||||
_REQUEST_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
class InfoQuestResponseError(ValueError):
|
||||
"""Raised when InfoQuest answers HTTP 200 with a payload that is not a result page.
|
||||
|
||||
Search answers HTTP 200 with a ``search_result`` envelope as its only
|
||||
contract. An error document delivered with the same status is not an empty
|
||||
search, so the caller reports a format error rather than an empty result --
|
||||
the containment rule the sibling bundled providers already follow.
|
||||
"""
|
||||
|
||||
|
||||
class InfoQuestClient:
|
||||
"""Client for interacting with the InfoQuest web search and fetch API."""
|
||||
|
||||
@ -182,7 +192,67 @@ class InfoQuestClient:
|
||||
return response_json
|
||||
|
||||
@staticmethod
|
||||
def clean_results(raw_results: list[dict[str, dict[str, dict[str, Any]]]]) -> list[dict]:
|
||||
def _envelope_entries(raw_results: object) -> list[dict]:
|
||||
"""Return the ``content``/``results`` envelopes of a search payload.
|
||||
|
||||
Search answers HTTP 200 with a ``search_result`` envelope as its only
|
||||
contract. A payload that is not a list, or whose entries are not objects
|
||||
carrying object ``content.results`` mappings, is a format error: indexing
|
||||
it directly raised ``TypeError``/``KeyError`` and the tool reported that
|
||||
raw Python error text to the agent instead of the provider's format
|
||||
error. Entries that do not fit the envelope are skipped in order so the
|
||||
usable pages of an otherwise valid payload survive.
|
||||
"""
|
||||
if not isinstance(raw_results, list):
|
||||
logger.error("InfoQuest returned an unexpected search payload type: %s", type(raw_results).__name__)
|
||||
raise InfoQuestResponseError("InfoQuest returned an unexpected response format")
|
||||
entries: list[dict] = []
|
||||
for index, entry in enumerate(raw_results):
|
||||
if not isinstance(entry, dict):
|
||||
logger.warning("Skipping malformed InfoQuest result entry at index %d", index)
|
||||
continue
|
||||
content = entry.get("content")
|
||||
results = content.get("results") if isinstance(content, dict) else None
|
||||
if not isinstance(results, dict):
|
||||
logger.warning("Skipping InfoQuest result entry without a usable content.results mapping at index %d", index)
|
||||
continue
|
||||
entries.append(results)
|
||||
if raw_results and not entries:
|
||||
logger.error("InfoQuest returned a search payload with no usable result envelopes")
|
||||
raise InfoQuestResponseError("InfoQuest returned an unexpected response format")
|
||||
return entries
|
||||
|
||||
@staticmethod
|
||||
def _rows(value: object, container: str) -> list[dict]:
|
||||
"""Return the object rows of one vertical, or [] when the key is absent.
|
||||
|
||||
A missing or null container is an empty search. A container that is not
|
||||
a list, or a non-empty list holding no object entries, is malformed: the
|
||||
caller reports the provider's format error rather than an empty result,
|
||||
which would tell the agent the query legitimately matched nothing.
|
||||
Non-object entries are skipped in order so the valid rows survive, and
|
||||
each skip is logged with the container name and the entry index --
|
||||
``_envelope_entries`` reports its skips the same way, and a provider-side
|
||||
drift would otherwise shed entries with no trace at all.
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
logger.error("InfoQuest returned an unexpected result container type: %s", type(value).__name__)
|
||||
raise InfoQuestResponseError("InfoQuest returned an unexpected response format")
|
||||
rows: list[dict] = []
|
||||
for index, row in enumerate(value):
|
||||
if not isinstance(row, dict):
|
||||
logger.warning("Skipping malformed InfoQuest result entry at %s[%d]", container, index)
|
||||
continue
|
||||
rows.append(row)
|
||||
if value and not rows:
|
||||
logger.error("InfoQuest returned a result container with no usable entries")
|
||||
raise InfoQuestResponseError("InfoQuest returned an unexpected response format")
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
def clean_results(cls, raw_results: list[dict[str, dict[str, dict[str, Any]]]]) -> list[dict]:
|
||||
"""Clean results from InfoQuest Web-Search API."""
|
||||
logger.debug("Processing web-search results")
|
||||
|
||||
@ -190,32 +260,29 @@ class InfoQuestClient:
|
||||
clean_results = []
|
||||
counts = {"pages": 0, "news": 0}
|
||||
|
||||
for content_list in raw_results:
|
||||
content = content_list["content"]
|
||||
results = content["results"]
|
||||
for results in cls._envelope_entries(raw_results):
|
||||
organic_results = cls._rows(results.get("organic"), "organic")
|
||||
for result in organic_results:
|
||||
clean_result = {
|
||||
"type": "page",
|
||||
}
|
||||
if "title" in result:
|
||||
clean_result["title"] = result["title"]
|
||||
if "desc" in result:
|
||||
clean_result["desc"] = result["desc"]
|
||||
clean_result["snippet"] = result["desc"]
|
||||
if "url" in result:
|
||||
clean_result["url"] = result["url"]
|
||||
url = clean_result["url"]
|
||||
if isinstance(url, str) and url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
clean_results.append(clean_result)
|
||||
counts["pages"] += 1
|
||||
|
||||
if results.get("organic"):
|
||||
organic_results = results["organic"]
|
||||
for result in organic_results:
|
||||
clean_result = {
|
||||
"type": "page",
|
||||
}
|
||||
if "title" in result:
|
||||
clean_result["title"] = result["title"]
|
||||
if "desc" in result:
|
||||
clean_result["desc"] = result["desc"]
|
||||
clean_result["snippet"] = result["desc"]
|
||||
if "url" in result:
|
||||
clean_result["url"] = result["url"]
|
||||
url = clean_result["url"]
|
||||
if isinstance(url, str) and url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
clean_results.append(clean_result)
|
||||
counts["pages"] += 1
|
||||
|
||||
if results.get("top_stories"):
|
||||
news = results["top_stories"]
|
||||
for obj in news["items"]:
|
||||
top_stories = results.get("top_stories")
|
||||
if isinstance(top_stories, dict):
|
||||
news_items = cls._rows(top_stories.get("items"), "top_stories.items")
|
||||
for obj in news_items:
|
||||
clean_result = {
|
||||
"type": "news",
|
||||
}
|
||||
@ -233,6 +300,9 @@ class InfoQuestClient:
|
||||
seen_urls.add(url)
|
||||
clean_results.append(clean_result)
|
||||
counts["news"] += 1
|
||||
elif top_stories is not None:
|
||||
logger.error("InfoQuest returned an unexpected 'top_stories' container type: %s", type(top_stories).__name__)
|
||||
raise InfoQuestResponseError("InfoQuest returned an unexpected response format")
|
||||
logger.debug(f"Results processing completed | total_results={len(clean_results)} | pages={counts['pages']} | news_items={counts['news']} | unique_urls={len(seen_urls)}")
|
||||
|
||||
return clean_results
|
||||
@ -261,12 +331,21 @@ class InfoQuestClient:
|
||||
site,
|
||||
output_format,
|
||||
)
|
||||
if not isinstance(raw_results, dict):
|
||||
logger.error("InfoQuest returned an unexpected search payload type: %s", type(raw_results).__name__)
|
||||
return "Error: InfoQuest returned an unexpected response format"
|
||||
if "search_result" in raw_results:
|
||||
logger.debug("InfoQuest Web-Search - Successfully extracted search_result from JSON response")
|
||||
results = raw_results["search_result"]
|
||||
if not isinstance(results, dict):
|
||||
logger.error("InfoQuest returned an unexpected 'search_result' payload type: %s", type(results).__name__)
|
||||
return "Error: InfoQuest returned an unexpected response format"
|
||||
|
||||
logger.debug("InfoQuest Web-Search - Processing raw search results")
|
||||
cleaned_results = self.clean_results(results["results"])
|
||||
# A missing or null ``results`` is an empty search; a container
|
||||
# that is not a list, or one holding no usable entry, is the
|
||||
# provider's format error (raised by ``clean_results``).
|
||||
cleaned_results = self.clean_results(results.get("results") if results.get("results") is not None else [])
|
||||
|
||||
result_json = json.dumps(cleaned_results, indent=2, ensure_ascii=False)
|
||||
|
||||
@ -283,13 +362,15 @@ class InfoQuestClient:
|
||||
logger.warning("InfoQuest Web-Search - Neither search_result nor content field found in JSON response")
|
||||
return json.dumps(raw_results, indent=2, ensure_ascii=False)
|
||||
|
||||
except InfoQuestResponseError:
|
||||
return "Error: InfoQuest returned an unexpected response format"
|
||||
except Exception as e:
|
||||
error_message = f"InfoQuest Web-Search - Search tool execution failed | mode=asynchronous | error={str(e)}"
|
||||
logger.error(error_message)
|
||||
return f"Error: {error_message}"
|
||||
|
||||
@staticmethod
|
||||
def clean_results_with_image_search(raw_results: list[dict[str, dict[str, dict[str, Any]]]]) -> list[dict]:
|
||||
@classmethod
|
||||
def clean_results_with_image_search(cls, raw_results: list[dict[str, dict[str, dict[str, Any]]]]) -> list[dict]:
|
||||
"""Clean results from InfoQuest Web-Search API."""
|
||||
logger.debug("Processing web-search results")
|
||||
|
||||
@ -297,23 +378,18 @@ class InfoQuestClient:
|
||||
clean_results = []
|
||||
counts = {"images": 0}
|
||||
|
||||
for content_list in raw_results:
|
||||
content = content_list["content"]
|
||||
results = content["results"]
|
||||
|
||||
if results.get("images_results"):
|
||||
images_results = results["images_results"]
|
||||
for result in images_results:
|
||||
clean_result = {}
|
||||
if "original" in result:
|
||||
clean_result["image_url"] = result["original"]
|
||||
url = clean_result["image_url"]
|
||||
if isinstance(url, str) and url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
clean_results.append(clean_result)
|
||||
counts["images"] += 1
|
||||
if "title" in result:
|
||||
clean_result["title"] = result["title"]
|
||||
for results in cls._envelope_entries(raw_results):
|
||||
for result in cls._rows(results.get("images_results"), "images_results"):
|
||||
clean_result = {}
|
||||
if "original" in result:
|
||||
clean_result["image_url"] = result["original"]
|
||||
url = clean_result["image_url"]
|
||||
if isinstance(url, str) and url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
clean_results.append(clean_result)
|
||||
counts["images"] += 1
|
||||
if "title" in result:
|
||||
clean_result["title"] = result["title"]
|
||||
logger.debug(f"Results processing completed | total_results={len(clean_results)} | images={counts['images']} | unique_urls={len(seen_urls)}")
|
||||
|
||||
return clean_results
|
||||
@ -383,12 +459,19 @@ class InfoQuestClient:
|
||||
output_format,
|
||||
)
|
||||
|
||||
if not isinstance(raw_results, dict):
|
||||
logger.error("InfoQuest returned an unexpected image search payload type: %s", type(raw_results).__name__)
|
||||
return "Error: InfoQuest returned an unexpected response format"
|
||||
if "search_result" in raw_results:
|
||||
logger.debug("InfoQuest Image Search - Successfully extracted search_result from JSON response")
|
||||
results = raw_results["search_result"]
|
||||
if not isinstance(results, dict):
|
||||
logger.error("InfoQuest returned an unexpected 'search_result' payload type: %s", type(results).__name__)
|
||||
return "Error: InfoQuest returned an unexpected response format"
|
||||
|
||||
logger.debug(f"InfoQuest Image Search - Processing raw image search results: {results}")
|
||||
cleaned_results = self.clean_results_with_image_search(results["results"])
|
||||
images = results.get("results")
|
||||
cleaned_results = self.clean_results_with_image_search(images if images is not None else [])
|
||||
|
||||
result_json = json.dumps(cleaned_results, indent=2, ensure_ascii=False)
|
||||
|
||||
@ -405,6 +488,8 @@ class InfoQuestClient:
|
||||
logger.warning("InfoQuest Image Search - Neither search_result nor content field found in JSON response")
|
||||
return json.dumps(raw_results, indent=2, ensure_ascii=False)
|
||||
|
||||
except InfoQuestResponseError:
|
||||
return "Error: InfoQuest returned an unexpected response format"
|
||||
except Exception as e:
|
||||
error_message = f"InfoQuest Image Search - Image search tool execution failed | mode=asynchronous | error={str(e)}"
|
||||
logger.error(error_message)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Tests for InfoQuest client and tools."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@ -420,3 +421,241 @@ class TestRedirectParity:
|
||||
await InfoQuestClient().image_search_raw_results("query")
|
||||
|
||||
mock_async_client_cls.assert_called_once_with(follow_redirects=True)
|
||||
|
||||
|
||||
class TestInfoQuestMalformedSearchPayloads:
|
||||
"""A malformed 200 search payload is a format error, not an empty search.
|
||||
|
||||
InfoQuest answers HTTP 200 with a ``search_result`` envelope as its only
|
||||
contract. An error document delivered with the same status used to either
|
||||
end the research loop as "the query matched nothing" or surface a raw
|
||||
Python error string, so the contraction rule the sibling bundled providers
|
||||
already follow applies here too.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _client(payload):
|
||||
if payload is None:
|
||||
# `json=None` omits the body entirely; a literal `null` document is
|
||||
# what actually reaches the outer "is it an object" guard.
|
||||
response = httpx.Response(200, text="null", request=httpx.Request("POST", "https://search.infoquest.bytepluses.com"))
|
||||
else:
|
||||
response = httpx.Response(200, json=payload, request=httpx.Request("POST", "https://search.infoquest.bytepluses.com"))
|
||||
return _mock_async_client(response)
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"search_result": {"results": "maintenance window"}},
|
||||
{"search_result": {"results": {"organic": "x"}}},
|
||||
{"search_result": {"results": [1, 2]}},
|
||||
{"search_result": {"results": [{"content": "x"}]}},
|
||||
{"search_result": {"results": [{"content": {"results": {"organic": "x"}}}]}},
|
||||
{"search_result": {"results": [{"content": {"results": {"organic": {"a": 1}}}}]}},
|
||||
{"search_result": {"results": [{"content": {"results": {"top_stories": {"items": "x"}}}}]}},
|
||||
],
|
||||
ids=[
|
||||
"results-is-a-string",
|
||||
"organic-is-a-string",
|
||||
"results-entries-not-objects",
|
||||
"content-is-not-an-object",
|
||||
"organic-container-is-not-a-list",
|
||||
"organic-container-is-a-populated-dict",
|
||||
"top-stories-items-is-not-a-list",
|
||||
],
|
||||
)
|
||||
async def test_web_search_reports_a_format_error(self, payload, monkeypatch):
|
||||
"""Each malformed container returns the provider's format error."""
|
||||
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
|
||||
|
||||
result = await InfoQuestClient().web_search("test query")
|
||||
|
||||
assert result == "Error: InfoQuest returned an unexpected response format"
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
["not-an-object"],
|
||||
"maintenance window",
|
||||
7,
|
||||
None,
|
||||
{"search_result": "not-an-object"},
|
||||
{"search_result": []},
|
||||
{"search_result": [1, 2]},
|
||||
{"search_result": None},
|
||||
],
|
||||
ids=[
|
||||
"payload-is-a-list",
|
||||
"payload-is-a-string",
|
||||
"payload-is-an-int",
|
||||
"payload-is-null",
|
||||
"search-result-is-a-string",
|
||||
"search-result-is-a-list",
|
||||
"search-result-is-a-populated-list",
|
||||
"search-result-is-null",
|
||||
],
|
||||
)
|
||||
async def test_web_search_rejects_non_object_envelopes(self, payload, monkeypatch):
|
||||
"""A top-level payload or ``search_result`` that is not an object is malformed.
|
||||
|
||||
The two outermost ``isinstance`` guards were the last uncovered branches:
|
||||
a top-level list/scalar used to reach ``"search_result" in raw_results``
|
||||
and a non-object ``search_result`` used to reach ``.get``, neither of
|
||||
which is the provider's format error.
|
||||
"""
|
||||
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
|
||||
|
||||
result = await InfoQuestClient().web_search("test query")
|
||||
|
||||
assert result == "Error: InfoQuest returned an unexpected response format"
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
["not-an-object"],
|
||||
"maintenance window",
|
||||
None,
|
||||
{"search_result": "not-an-object"},
|
||||
{"search_result": []},
|
||||
{"search_result": None},
|
||||
],
|
||||
ids=[
|
||||
"payload-is-a-list",
|
||||
"payload-is-a-string",
|
||||
"payload-is-null",
|
||||
"search-result-is-a-string",
|
||||
"search-result-is-a-list",
|
||||
"search-result-is-null",
|
||||
],
|
||||
)
|
||||
async def test_image_search_rejects_non_object_envelopes(self, payload, monkeypatch):
|
||||
"""The image vertical shares the outer-envelope guards."""
|
||||
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
|
||||
|
||||
result = await InfoQuestClient().image_search("test query")
|
||||
|
||||
assert result == "Error: InfoQuest returned an unexpected response format"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_web_search_logs_the_container_and_index_of_a_skipped_row(self, monkeypatch, caplog):
|
||||
"""A dropped non-object row is reported with its container and position.
|
||||
|
||||
``_envelope_entries`` already warned this way; ``_rows`` shed the same
|
||||
kind of drift silently, so a payload like ``{"organic": [1, {"url": ...}]}``
|
||||
lost entries with no trace. The README documents logging the container
|
||||
name and entry position only, never the payload values.
|
||||
"""
|
||||
payload = {
|
||||
"search_result": {
|
||||
"results": [
|
||||
{
|
||||
"content": {
|
||||
"results": {
|
||||
"organic": [
|
||||
1,
|
||||
{"title": "Kept", "desc": "d", "url": "https://example.com/1"},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="deerflow.community.infoquest.infoquest_client"):
|
||||
result = await InfoQuestClient().web_search("test query")
|
||||
|
||||
assert [row["title"] for row in json.loads(result)] == ["Kept"]
|
||||
warnings = [record.getMessage() for record in caplog.records if record.levelno == logging.WARNING]
|
||||
assert any("organic[0]" in message for message in warnings), warnings
|
||||
assert not any("example.com" in message for message in warnings), warnings
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"search_result": {}},
|
||||
{"search_result": {"results": None}},
|
||||
{"search_result": {"results": []}},
|
||||
{"search_result": {"results": [{"content": {"results": {"organic": []}}}]}},
|
||||
],
|
||||
ids=["search-result-is-empty", "results-is-null", "results-is-empty-list", "organic-is-empty"],
|
||||
)
|
||||
async def test_web_search_keeps_an_empty_search_empty(self, payload, monkeypatch):
|
||||
"""A missing, null or empty container stays an empty search."""
|
||||
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
|
||||
|
||||
result = await InfoQuestClient().web_search("test query")
|
||||
|
||||
assert json.loads(result) == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_web_search_keeps_the_valid_rows_of_a_mixed_payload(self, monkeypatch):
|
||||
"""A payload with usable rows still answers with those rows."""
|
||||
payload = {
|
||||
"search_result": {
|
||||
"results": [
|
||||
{"content": {"results": {"organic": [{"title": "Test Page", "desc": "d", "url": "https://example.com/1"}]}}},
|
||||
"malformed",
|
||||
]
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
|
||||
|
||||
result = await InfoQuestClient().web_search("test query")
|
||||
|
||||
rows = json.loads(result)
|
||||
assert [row["title"] for row in rows] == ["Test Page"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"search_result": {"results": "maintenance window"}},
|
||||
{"search_result": {"results": [{"content": {"results": {"images_results": "x"}}}]}},
|
||||
{"search_result": {"results": [{"content": {"results": {"images_results": [1, 2]}}}]}},
|
||||
],
|
||||
ids=["results-is-a-string", "images-container-is-not-a-list", "images-entries-not-objects"],
|
||||
)
|
||||
async def test_image_search_reports_a_format_error(self, payload, monkeypatch):
|
||||
"""The image vertical shares the containment rule."""
|
||||
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
|
||||
|
||||
result = await InfoQuestClient().image_search("test query")
|
||||
|
||||
assert result == "Error: InfoQuest returned an unexpected response format"
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"search_result": {"results": []}},
|
||||
{"search_result": {"results": [{"content": {"results": {"images_results": []}}}]}},
|
||||
],
|
||||
ids=["results-is-empty-list", "images-is-empty"],
|
||||
)
|
||||
async def test_image_search_keeps_an_empty_search_empty(self, payload, monkeypatch):
|
||||
"""The image vertical keeps empty containers empty as well."""
|
||||
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=self._client(payload)))
|
||||
|
||||
result = await InfoQuestClient().image_search("test query")
|
||||
|
||||
assert json.loads(result) == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_web_search_keeps_other_failures_as_their_own_errors(self, monkeypatch):
|
||||
"""Transport failures keep their existing error text."""
|
||||
client = MagicMock()
|
||||
client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr("deerflow.community.infoquest.infoquest_client.httpx.AsyncClient", MagicMock(return_value=client))
|
||||
|
||||
result = await InfoQuestClient().web_search("test query")
|
||||
|
||||
assert result.startswith("Error: ")
|
||||
assert "unexpected response format" not in result
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user