diff --git a/tests/test_search_legacy_routing.py b/tests/test_search_legacy_routing.py new file mode 100644 index 0000000..8ccfe70 --- /dev/null +++ b/tests/test_search_legacy_routing.py @@ -0,0 +1,70 @@ +import warnings + +from videodb.collection import Collection +from videodb.video import Video +import videodb.search as search_module + + +class FakeConnection: + def __init__(self, response=None): + self.posts = [] + self.response = response or {"results": []} + + def post(self, path, data=None, **kwargs): + self.posts.append({"path": path, "data": data or {}, "kwargs": kwargs}) + return self.response + + +def setup_function(): + search_module._RESPONSE_WARNING_EMITTED.clear() + + +def test_video_search_routes_stitch_rerank_options_to_legacy_endpoint_without_local_warning(): + conn = FakeConnection() + video = Video(conn, id="video-1", collection_id="collection-1") + + with warnings.catch_warnings(record=True) as emitted: + warnings.simplefilter("always") + result = video.search( + "find moments", + stitch=False, + rerank=True, + rerank_params={"top_k": 3}, + ) + + assert len(emitted) == 0 + assert result.warnings == [] + assert len(conn.posts) == 1 + assert conn.posts[0]["path"] == "video/video-1/search" + assert conn.posts[0]["data"]["stitch"] is False + assert conn.posts[0]["data"]["rerank"] is True + assert conn.posts[0]["data"]["rerank_params"] == {"top_k": 3} + + +def test_collection_search_routes_stitch_rerank_options_to_legacy_endpoint(): + conn = FakeConnection() + collection = Collection(conn, id="collection-1") + + collection.search( + "find moments", + stitch=False, + rerank=True, + rerank_params={"rank_fields": ["text"]}, + ) + + assert len(conn.posts) == 1 + assert conn.posts[0]["path"] == "collection/collection-1/search" + assert conn.posts[0]["data"]["stitch"] is False + assert conn.posts[0]["data"]["rerank"] is True + assert conn.posts[0]["data"]["rerank_params"] == {"rank_fields": ["text"]} + + +def test_video_search_still_routes_to_v2_when_no_legacy_params_are_present(): + conn = FakeConnection({"response_type": "shots", "results": []}) + video = Video(conn, id="video-1", collection_id="collection-1") + + video.search("find moments") + + assert len(conn.posts) == 1 + assert conn.posts[0]["path"] == "video/video-1/search/v2" + assert conn.posts[0]["data"] == {"query": "find moments"} diff --git a/tests/test_search_v2_migration_warnings.py b/tests/test_search_v2_migration_warnings.py deleted file mode 100644 index 2af8290..0000000 --- a/tests/test_search_v2_migration_warnings.py +++ /dev/null @@ -1,358 +0,0 @@ -import pytest - -import videodb.search as search_module -from videodb.collection import Collection -from videodb.exceptions import SearchError -from videodb.video import Video - - -WARNING = { - "code": "search_v2_migration_dual_read", - "message": "search() now uses Search V2 and may also check older indexes during migration.", - "docs": [{"label": "Search V2 search", "url": "https://example.test/search"}], -} -ASK_WARNING = { - "code": "ask_requires_search_v2_index", - "message": "ask() uses Search V2 indexes only.", - "docs": [{"label": "Ask", "url": "https://example.test/ask"}], -} -SEMANTIC_WARNING = { - "code": "semantic_search_v2_migration_dual_read", - "message": "semantic_search() may also check older indexes during migration.", - "docs": [{"label": "Semantic search", "url": "https://example.test/semantic"}], -} - - -class FakeConnection: - def __init__(self): - self.calls = [] - - def post(self, path, data=None, **kwargs): - self.calls.append((path, data or {})) - if path == "compile": - return { - "stream_url": "https://stream.example.test/compiled.m3u8", - "player_url": "https://player.example.test/watch?v=compiled", - } - if path.endswith("/ask"): - return {"answer": "I do not have enough information to answer.", "sources": [], "warnings": [ASK_WARNING]} - if path.endswith("/semantic-search"): - return {"results": _results("semantic"), "warnings": [SEMANTIC_WARNING]} - if path.endswith("/query"): - return {"results": _results("query"), "warnings": [WARNING]} - if path.endswith("/aggregate"): - return {"results": [{"label": "object", "count": 2}], "warnings": [WARNING]} - if path.endswith("/search/v2"): - if (data or {}).get("mode") == "deepsearch": - return {"response_type": "deepsearch", "results": _results("deepsearch"), "warnings": [WARNING]} - return {"response_type": "shots", "results": _results("v2"), "warnings": [WARNING]} - if path.endswith("/search"): - return {"results": _results("legacy")} - raise AssertionError(f"unexpected path: {path}") - - -class FakeCollectionConnection(FakeConnection): - pass - - -def _results(text): - return [ - { - "collection_id": "c1", - "video_id": "v1", - "length": 30, - "title": "video", - "docs": [{"start": 1, "end": 3, "text": text, "score": 0.8}], - } - ] - - -@pytest.fixture(autouse=True) -def reset_warning_state(): - search_module._LEGACY_SEARCH_WARNING_EMITTED.clear() - yield - search_module._LEGACY_SEARCH_WARNING_EMITTED.clear() - - -def test_search_v2_server_warnings_are_emitted_and_exposed(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2") as emitted: - response = video.search("find cars", filter={"legacy_key": "legacy_value"}) - - assert len(emitted) == 1 - assert conn.calls[-1] == ( - "video/v1/search/v2", - {"query": "find cars", "filter": {"legacy_key": "legacy_value"}}, - ) - assert response.warnings == [WARNING] - assert response.results.warnings == [WARNING] - assert response.shots[0].text == "v2" - - -def test_deepsearch_server_warnings_are_emitted_and_exposed_on_search_response(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): - response = video.search("find cars", mode="deepsearch") - - assert conn.calls[-1] == ("video/v1/search/v2", {"query": "find cars", "mode": "deepsearch"}) - assert response.response_type == "deepsearch" - assert response.warnings == [WARNING] - assert response.results.warnings == [WARNING] - - -def test_ask_server_warnings_are_emitted_and_exposed_on_ask_response(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"ask\(\) uses Search V2 indexes only"): - response = video.ask("what happened?") - - assert conn.calls[-1] == ( - "video/v1/ask", - {"question": "what happened?", "top_k": 15, "mode": "default", "include_sources": False}, - ) - assert response.answer == "I do not have enough information to answer." - assert response.warnings == [ASK_WARNING] - - -def test_search_response_compile_delegates_for_shots(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): - response = video.search("find cars") - - assert response.compile() == "https://stream.example.test/compiled.m3u8" - assert conn.calls[-1] == ( - "compile", - [{"video_id": "v1", "collection_id": "c1", "shots": [(1, 3)]}], - ) - assert response.results.stream_url == "https://stream.example.test/compiled.m3u8" - - -def test_search_response_play_delegates_for_shots(monkeypatch): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - played = [] - - monkeypatch.setattr(search_module, "play_stream", lambda url: played.append(url) or f"played:{url}") - with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): - response = video.search("find cars") - - assert response.play() == "played:https://stream.example.test/compiled.m3u8" - assert played == ["https://stream.example.test/compiled.m3u8"] - - -def test_search_response_embed_code_delegates_for_deepsearch(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): - response = video.search("find cars", mode="deepsearch") - - html = response.get_embed_code(width="640", height=360, title="Result", allow_fullscreen=False) - assert response.response_type == "deepsearch" - assert 'src="https://player.example.test/embed?v=compiled"' in html - assert 'width="640"' in html - assert 'height="360"' in html - assert 'title="Result"' in html - assert "allowfullscreen" not in html - - -def test_search_response_media_helpers_raise_for_non_shot_response(): - response = search_module.SearchResponse( - FakeConnection(), - response_type="aggregate", - results=[{"label": "car", "count": 2}], - ) - - with pytest.raises(SearchError, match="does not contain shot results"): - response.compile() - with pytest.raises(SearchError, match="does not contain shot results"): - response.play() - with pytest.raises(SearchError, match="does not contain shot results"): - response.get_embed_code() - - -def test_semantic_search_accepts_plural_selectors_emits_and_exposes_warnings(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"semantic_search\(\) may also check older indexes"): - response = video.semantic_search("find cars", index_names=["scene"], index_ids=["idx-v2"]) - - assert conn.calls[-1] == ( - "video/v1/semantic-search", - { - "query": "find cars", - "index_names": ["scene"], - "index_ids": ["idx-v2"], - "top_k": 10, - "score_threshold": None, - "filter": None, - "return_fields": None, - }, - ) - assert response.warnings == [SEMANTIC_WARNING] - assert response.shots[0].text == "semantic" - - -def test_semantic_search_rejects_singular_selectors_in_sdk_signature(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.raises(TypeError, match="unexpected keyword argument 'index_name'"): - video.semantic_search("find cars", index_name="scene") - with pytest.raises(TypeError, match="unexpected keyword argument 'index_id'"): - video.semantic_search("find cars", index_id="idx-old") - assert conn.calls == [] - - -def test_query_supports_singular_index_name_emits_and_exposes_warnings(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): - response = video.query(index_name="scene") - - assert conn.calls[-1] == ( - "video/v1/query", - { - "index_name": "scene", - "index_id": None, - "filter": None, - "limit": 100, - "return_fields": None, - "sort": None, - }, - ) - assert response.warnings == [WARNING] - assert response.shots[0].text == "query" - - -def test_query_supports_singular_index_id(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): - response = video.query(index_id="idx-v2") - - assert conn.calls[-1][0] == "video/v1/query" - assert conn.calls[-1][1]["index_name"] is None - assert conn.calls[-1][1]["index_id"] == "idx-v2" - assert response.warnings == [WARNING] - - -def test_query_rejects_plural_selectors_in_sdk_signature(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.raises(TypeError, match="unexpected keyword argument 'index_ids'"): - video.query(index_ids=["idx-v2"]) - assert conn.calls == [] - - -def test_aggregate_keeps_singular_index_name_emits_and_returns_raw_warning_payload(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): - response = video.aggregate(index_name="scene") - - assert conn.calls[-1] == ( - "video/v1/aggregate", - { - "index_name": "scene", - "index_id": None, - "filter": None, - "group_by": None, - "metric": "count", - "limit": 100, - "sort": None, - }, - ) - assert response["warnings"] == [WARNING] - - -def test_aggregate_keeps_singular_index_id(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"search\(\) now uses Search V2"): - response = video.aggregate(index_id="idx-v2") - - assert conn.calls[-1][0] == "video/v1/aggregate" - assert conn.calls[-1][1]["index_name"] is None - assert conn.calls[-1][1]["index_id"] == "idx-v2" - assert response["warnings"] == [WARNING] - - -def test_search_index_id_routes_video_to_legacy_with_python_warning_and_scene_index_id_mapping(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match="This search used legacy search because legacy parameters were provided") as emitted: - response = video.search("find cars", index_id="old-scene-index") - - assert len(emitted) == 1 - assert conn.calls[-1][0] == "video/v1/search" - assert conn.calls[-1][1]["scene_index_id"] == "old-scene-index" - assert response.warnings == [] - assert response.shots[0].text == "legacy" - - -def test_search_index_id_routes_collection_to_legacy_with_python_warning_and_scene_index_id_mapping(): - conn = FakeCollectionConnection() - collection = Collection(conn, id="c1") - - with pytest.warns(UserWarning, match="This search used legacy search because legacy parameters were provided"): - response = collection.search("find cars", index_id="old-scene-index") - - assert conn.calls[-1][0] == "collection/c1/search" - assert conn.calls[-1][1]["scene_index_id"] == "old-scene-index" - assert response.warnings == [] - - -def test_explicit_legacy_search_warns_once_and_uses_old_endpoint(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.warns(UserWarning, match=r"legacy_search\(\) searches older spoken-word and scene indexes only") as emitted: - video.legacy_search("find cars") - video.legacy_search("find people") - - assert len(emitted) == 1 - assert [call[0] for call in conn.calls] == ["video/v1/search", "video/v1/search"] - - -def test_search_rejects_mixed_legacy_and_v2_params_before_http(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.raises(ValueError, match="Cannot mix legacy search parameters with Search V2 parameters"): - video.search("find cars", index_id="old-scene-index", top_k=5) - assert conn.calls == [] - - -def test_search_rejects_index_selectors_before_http(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.raises(ValueError, match=r"search\(\) chooses indexes automatically"): - video.search("find cars", index_names=["scene"]) - with pytest.raises(ValueError, match=r"search\(\) chooses indexes automatically"): - video.search("find cars", index_ids=["idx-v2"]) - assert conn.calls == [] - - -def test_search_rejects_internal_deepsearch_config_before_http(): - conn = FakeConnection() - video = Video(conn, id="v1", collection_id="c1") - - with pytest.raises(ValueError, match=r"deepsearch_config is not a public search\(\) option"): - video.search("find cars", deepsearch_config={"internal": True}) - assert conn.calls == [] diff --git a/videodb/collection.py b/videodb/collection.py index 6751cf6..6414080 100644 --- a/videodb/collection.py +++ b/videodb/collection.py @@ -22,8 +22,6 @@ SearchFactory, SearchResponse, SearchResult, - warn_explicit_legacy_search_once, - warn_legacy_search_once, warn_response_warnings_once, ) @@ -477,10 +475,21 @@ def search( config: Optional[Dict[str, Any]] = None, **kwargs, ) -> Union[SearchResponse, SearchResult, RTStreamSearchResult]: - """Search the collection. + """Search this collection using Search V2 by default. - New search is used by default. Calls that use legacy-shaped parameters are - routed to :meth:`legacy_search` with a warning. + Pass Search V2 options such as ``top_k``, ``mode``, ``return_fields``, + ``include_clip``, ``session_id``, or ``config`` for the new search API. + Calls with legacy options such as ``search_type``, ``index_type``, + ``result_threshold``, ``scene_index_id``, ``index_id``, ``stitch``, + ``rerank``, or ``rerank_params`` are routed to :meth:`legacy_search`. + Do not mix Search V2 and legacy options in one call. + + Server-provided warnings are exposed on ``response.warnings`` and are + emitted as Python ``UserWarning`` messages. + + :param str query: Natural-language search query. + :param dict config: Optional Search V2 request configuration. + :return: ``SearchResponse`` for Search V2, ``SearchResult`` for legacy, or ``RTStreamSearchResult`` for RTStream legacy search. """ old_params = { "search_type", @@ -492,6 +501,9 @@ def search( "algorithm", "sort_docs_on", "namespace", + "stitch", + "rerank", + "rerank_params", } new_params = { "top_k", @@ -525,10 +537,6 @@ def search( has_new = any(k in kwargs and kwargs[k] is not None for k in new_params) has_unsupported = any(k in kwargs and kwargs[k] is not None for k in unsupported_params) - if kwargs.get("deepsearch_config") is not None: - raise ValueError( - "deepsearch_config is not a public search() option. Use mode='deepsearch', top_k, session_id, and return_fields for DeepSearch requests." - ) if has_old and (has_new or has_unsupported): raise ValueError( "Cannot mix legacy search parameters with Search V2 parameters. " @@ -541,7 +549,6 @@ def search( ) if has_old: - warn_legacy_search_once() return self.legacy_search(query=query, _skip_warning=True, **kwargs) return self._new_search(query=query, **kwargs) @@ -561,6 +568,18 @@ def ask( mode: str = "default", include_sources: bool = False, ) -> AskResponse: + """Ask a question over this collection's Search V2 indexes. + + ``ask()`` is Search V2 only; it does not search legacy indexes. If the + server cannot answer from indexed content, warnings are available on + ``response.warnings``. + + :param str question: Question to answer from the collection. + :param int top_k: Maximum number of source shots to retrieve. + :param str mode: Search mode to use. + :param bool include_sources: Include source shots in the response. + :return: ``AskResponse`` with ``answer``, ``sources``, and ``warnings``. + """ ask_data = self._connection.post( path=f"{ApiPath.collection}/{self.id}/{ApiPath.ask}", data={ @@ -582,6 +601,21 @@ def semantic_search( return_fields: Optional[Union[List, Dict, str]] = None, index_ids: Optional[Union[List[str], str]] = None, ) -> SearchResult: + """Run direct Search V2 semantic retrieval for this collection. + + Use ``index_names`` or ``index_ids`` to target semantic indexes. Singular + legacy selectors such as ``index_name``/``index_id`` are not accepted by + this method. + + :param str query: Natural-language query. + :param index_names: Optional Search V2 semantic index name or names. + :param int top_k: Maximum number of shots to return. + :param float score_threshold: Optional minimum similarity score. + :param filter: Optional Search V2 filter. + :param return_fields: Optional metadata fields to include. + :param index_ids: Optional Search V2 index ID or IDs. + :return: ``SearchResult`` with shots and server-provided ``warnings``. + """ search_data = self._connection.post( path=f"{ApiPath.collection}/{self.id}/{ApiPath.semantic_search}", data={ @@ -605,6 +639,19 @@ def query( sort: Optional[Union[str, List[Tuple[str, str]]]] = None, index_id: Optional[str] = None, ) -> SearchResult: + """Run a structured Search V2 query for this collection. + + ``query()`` is V2-only and is intended for filtering, sorting, and + retrieving indexed records without natural-language planning. + + :param str index_name: Optional Search V2 index name. + :param filter: Optional Search V2 filter. + :param int limit: Maximum number of records to return. + :param return_fields: Optional fields to include in each result. + :param sort: Optional sort field or ``[(field, direction)]`` list. + :param str index_id: Optional Search V2 index ID. + :return: ``SearchResult`` with shots and server-provided ``warnings``. + """ query_data = self._connection.post( path=f"{ApiPath.collection}/{self.id}/{ApiPath.query}", data={ @@ -628,6 +675,20 @@ def aggregate( sort: Optional[Union[str, List[Tuple[str, str]]]] = None, index_id: Optional[str] = None, ) -> Union[Dict, List[Dict]]: + """Run a Search V2 aggregate over this collection's indexed records. + + Use this for counts, facets, and grouped metrics. ``aggregate()`` is + V2-only and returns the server aggregate payload directly. + + :param str index_name: Optional Search V2 index name. + :param filter: Optional Search V2 filter. + :param str group_by: Optional field to group by. + :param str metric: Aggregate metric, default ``"count"``. + :param int limit: Maximum number of aggregate rows. + :param sort: Optional sort field or ``[(field, direction)]`` list. + :param str index_id: Optional Search V2 index ID. + :return: Aggregate dict/list; dict responses may include ``warnings``. + """ aggregate_data = self._connection.post( path=f"{ApiPath.collection}/{self.id}/{ApiPath.aggregate}", data={ @@ -658,28 +719,38 @@ def legacy_search( scene_index_id: Optional[str] = None, index_id: Optional[str] = None, algorithm: Optional[str] = None, + stitch: Optional[bool] = None, + rerank: Optional[bool] = None, + rerank_params: Optional[Dict[str, Any]] = None, _skip_warning: bool = False, ) -> Union[SearchResult, RTStreamSearchResult]: - """Search for a query in the collection. - - :param str query: Query to search for - :param SearchType search_type: Type of search to perform (optional) - :param IndexType index_type: Type of index to search (optional) - :param int result_threshold: Number of results to return (optional) - :param float score_threshold: Threshold score for the search (optional) - :param float dynamic_score_percentage: Percentage of dynamic score to consider (optional) - :param list filter: Additional metadata filters (optional) - :param str sort_docs_on: Sort docs within each video by "score" or "start" (optional) - :param str namespace: Search namespace (optional, "rtstream" to search RTStreams) - :param str scene_index_id: Filter by specific scene index (optional) - :raise SearchError: If the search fails - :return: :class:`SearchResult ` or - :class:`RTStreamSearchResult ` object - :rtype: Union[:class:`videodb.search.SearchResult`, - :class:`videodb.rtstream.RTStreamSearchResult`] - """ - if not _skip_warning: - warn_explicit_legacy_search_once() + """Search this collection using legacy spoken-word, scene, or RTStream indexes. + + Use this when you intentionally want older indexes. New applications + should prefer :meth:`search`, :meth:`semantic_search`, :meth:`query`, + :meth:`aggregate`, or :meth:`ask` for Search V2 indexes. Set + ``namespace="rtstream"`` to search legacy RTStream results. The + ``index_id`` keyword is accepted as an alias for ``scene_index_id``. + + Server-provided migration warnings are available on ``result.warnings``. + + :param str query: Query to search for. + :param SearchType search_type: Legacy search type. + :param IndexType index_type: Legacy index type. + :param int result_threshold: Number of results to return. + :param float score_threshold: Minimum score threshold. + :param float dynamic_score_percentage: Dynamic score percentage. + :param list filter: Legacy metadata filters. + :param str sort_docs_on: Sort docs within each video by ``"score"`` or ``"start"``. + :param str namespace: Search namespace; use ``"rtstream"`` for RTStream legacy search. + :param str scene_index_id: Filter by a specific scene index. + :param str index_id: Alias for ``scene_index_id``. + :param str algorithm: Legacy keyword search algorithm when applicable. + :param bool stitch: Whether legacy search should stitch adjacent results. + :param bool rerank: Whether legacy search should rerank results. + :param dict rerank_params: Legacy reranking parameters. + :return: ``SearchResult`` with warnings, or ``RTStreamSearchResult`` for RTStream legacy search. + """ if scene_index_id is None and index_id is not None: scene_index_id = index_id @@ -718,6 +789,14 @@ def legacy_search( ] return RTStreamSearchResult(collection_id=self.id, shots=shots) + legacy_options = {} + if stitch is not None: + legacy_options["stitch"] = stitch + if rerank is not None: + legacy_options["rerank"] = rerank + if rerank_params is not None: + legacy_options["rerank_params"] = rerank_params + search = SearchFactory(self._connection).get_search(search_type) return search.search_inside_collection( collection_id=self.id, @@ -731,6 +810,7 @@ def legacy_search( filter=filter, scene_index_id=scene_index_id, algorithm=algorithm, + **legacy_options, ) def search_title(self, query) -> List[Video]: diff --git a/videodb/search.py b/videodb/search.py index 32cb102..fac8682 100644 --- a/videodb/search.py +++ b/videodb/search.py @@ -16,34 +16,16 @@ from videodb.shot import Shot -_LEGACY_SEARCH_WARNING = ( - "This search used legacy search because legacy parameters were provided. " - "Use legacy_search(...) to keep searching older indexes, or remove legacy parameters and create Search V2 indexes. " - "Docs: https://videodb-docs-indexing-search-v2.mintlify.app/api-reference/search-v2/legacy-search" -) -_EXPLICIT_LEGACY_SEARCH_WARNING = ( - "legacy_search() searches older spoken-word and scene indexes only. " - "Create Search V2 indexes to use search(), semantic_search(), query(), aggregate(), and ask(). " - "Docs: https://videodb-docs-indexing-search-v2.mintlify.app/pages/understand/indexing-pipelines/create-an-index" -) -_LEGACY_SEARCH_WARNING_EMITTED = set() +_RESPONSE_WARNING_EMITTED = set() def _warn_once(key: str, message: str, stacklevel: int = 4): - if key in _LEGACY_SEARCH_WARNING_EMITTED: + if key in _RESPONSE_WARNING_EMITTED: return - _LEGACY_SEARCH_WARNING_EMITTED.add(key) + _RESPONSE_WARNING_EMITTED.add(key) warnings.warn(message, UserWarning, stacklevel=stacklevel) -def warn_legacy_search_once(): - _warn_once("legacy_params", _LEGACY_SEARCH_WARNING) - - -def warn_explicit_legacy_search_once(): - _warn_once("legacy_search", _EXPLICIT_LEGACY_SEARCH_WARNING) - - def _warning_docs_text(warning: dict) -> str: docs = warning.get("docs") or [] parts = [] diff --git a/videodb/video.py b/videodb/video.py index 6c8d124..8fc624a 100644 --- a/videodb/video.py +++ b/videodb/video.py @@ -20,8 +20,6 @@ SearchFactory, SearchResponse, SearchResult, - warn_explicit_legacy_search_once, - warn_legacy_search_once, warn_response_warnings_once, ) from videodb.shot import Shot @@ -99,10 +97,21 @@ def search( config: Optional[Dict[str, Any]] = None, **kwargs, ) -> Union[SearchResponse, SearchResult]: - """Search this video. + """Search this video using Search V2 by default. - New search is used by default. Calls that use legacy-shaped parameters are - routed to :meth:`legacy_search` with a warning. + Pass Search V2 options such as ``top_k``, ``mode``, ``return_fields``, + ``include_clip``, ``session_id``, or ``config`` for the new search API. + Calls with legacy options such as ``search_type``, ``index_type``, + ``result_threshold``, ``scene_index_id``, ``index_id``, ``stitch``, + ``rerank``, or ``rerank_params`` are routed to :meth:`legacy_search`. + Do not mix Search V2 and legacy options in one call. + + Server-provided warnings are exposed on ``response.warnings`` and are + emitted as Python ``UserWarning`` messages. + + :param str query: Natural-language search query. + :param dict config: Optional Search V2 request configuration. + :return: ``SearchResponse`` for Search V2, or ``SearchResult`` for legacy-routed calls. """ old_params = { "search_type", @@ -114,6 +123,9 @@ def search( "algorithm", "sort_docs_on", "namespace", + "stitch", + "rerank", + "rerank_params", } new_params = { "top_k", @@ -144,10 +156,6 @@ def search( has_new = any(k in kwargs and kwargs[k] is not None for k in new_params) has_unsupported = any(k in kwargs and kwargs[k] is not None for k in unsupported_params) - if kwargs.get("deepsearch_config") is not None: - raise ValueError( - "deepsearch_config is not a public search() option. Use mode='deepsearch', top_k, session_id, and return_fields for DeepSearch requests." - ) if has_old and (has_new or has_unsupported): raise ValueError( "Cannot mix legacy search parameters with Search V2 parameters. " @@ -160,7 +168,6 @@ def search( ) if has_old: - warn_legacy_search_once() return self.legacy_search(query=query, _skip_warning=True, **kwargs) return self._new_search(query=query, **kwargs) @@ -180,6 +187,18 @@ def ask( mode: str = "default", include_sources: bool = False, ) -> AskResponse: + """Ask a question over this video's Search V2 indexes. + + ``ask()`` is Search V2 only; it does not search legacy indexes. If the + server cannot answer from indexed content, warnings are available on + ``response.warnings``. + + :param str question: Question to answer from the video. + :param int top_k: Maximum number of source shots to retrieve. + :param str mode: Search mode to use. + :param bool include_sources: Include source shots in the response. + :return: ``AskResponse`` with ``answer``, ``sources``, and ``warnings``. + """ ask_data = self._connection.post( path=f"{ApiPath.video}/{self.id}/{ApiPath.ask}", data={ @@ -201,6 +220,21 @@ def semantic_search( return_fields: Optional[Union[List, Dict, str]] = None, index_ids: Optional[Union[List[str], str]] = None, ) -> SearchResult: + """Run direct Search V2 semantic retrieval for this video. + + Use ``index_names`` or ``index_ids`` to target semantic indexes. Singular + legacy selectors such as ``index_name``/``index_id`` are not accepted by + this method. + + :param str query: Natural-language query. + :param index_names: Optional Search V2 semantic index name or names. + :param int top_k: Maximum number of shots to return. + :param float score_threshold: Optional minimum similarity score. + :param filter: Optional Search V2 filter. + :param return_fields: Optional metadata fields to include. + :param index_ids: Optional Search V2 index ID or IDs. + :return: ``SearchResult`` with shots and server-provided ``warnings``. + """ search_data = self._connection.post( path=f"{ApiPath.video}/{self.id}/{ApiPath.semantic_search}", data={ @@ -224,6 +258,19 @@ def query( sort: Optional[Union[str, List[Tuple[str, str]]]] = None, index_id: Optional[str] = None, ) -> SearchResult: + """Run a structured Search V2 query for this video. + + ``query()`` is V2-only and is intended for filtering, sorting, and + retrieving indexed records without natural-language planning. + + :param str index_name: Optional Search V2 index name. + :param filter: Optional Search V2 filter. + :param int limit: Maximum number of records to return. + :param return_fields: Optional fields to include in each result. + :param sort: Optional sort field or ``[(field, direction)]`` list. + :param str index_id: Optional Search V2 index ID. + :return: ``SearchResult`` with shots and server-provided ``warnings``. + """ query_data = self._connection.post( path=f"{ApiPath.video}/{self.id}/{ApiPath.query}", data={ @@ -247,6 +294,20 @@ def aggregate( sort: Optional[Union[str, List[Tuple[str, str]]]] = None, index_id: Optional[str] = None, ) -> Union[Dict, List[Dict]]: + """Run a Search V2 aggregate over this video's indexed records. + + Use this for counts, facets, and grouped metrics. ``aggregate()`` is + V2-only and returns the server aggregate payload directly. + + :param str index_name: Optional Search V2 index name. + :param filter: Optional Search V2 filter. + :param str group_by: Optional field to group by. + :param str metric: Aggregate metric, default ``"count"``. + :param int limit: Maximum number of aggregate rows. + :param sort: Optional sort field or ``[(field, direction)]`` list. + :param str index_id: Optional Search V2 index ID. + :return: Aggregate dict/list; dict responses may include ``warnings``. + """ aggregate_data = self._connection.post( path=f"{ApiPath.video}/{self.id}/{ApiPath.aggregate}", data={ @@ -274,20 +335,25 @@ def legacy_search( filter: List[Dict[str, Any]] = [], **kwargs, ) -> SearchResult: - """Search for a query in the video. + """Search this video using legacy spoken-word or scene indexes. + + Use this when you intentionally want older indexes. New applications + should prefer :meth:`search`, :meth:`semantic_search`, :meth:`query`, + :meth:`aggregate`, or :meth:`ask` for Search V2 indexes. The ``index_id`` + keyword is accepted as an alias for ``scene_index_id``. + + Server-provided migration warnings are available on ``result.warnings``. :param str query: Query to search for. - :param SearchType search_type: (optional) Type of search to perform :class:`SearchType ` object - :param IndexType index_type: (optional) Type of index to search :class:`IndexType ` object - :param int result_threshold: (optional) Number of results to return - :param float score_threshold: (optional) Threshold score for the search - :param float dynamic_score_percentage: (optional) Percentage of dynamic score to consider - :raise SearchError: If the search fails - :return: :class:`SearchResult ` object - :rtype: :class:`videodb.search.SearchResult` + :param SearchType search_type: Legacy search type. + :param IndexType index_type: Legacy index type. + :param int result_threshold: Number of results to return. + :param float score_threshold: Minimum score threshold. + :param float dynamic_score_percentage: Dynamic score percentage. + :param list filter: Legacy metadata filters. + :return: ``SearchResult`` with shots and server-provided ``warnings``. """ - if not kwargs.pop("_skip_warning", False): - warn_explicit_legacy_search_once() + kwargs.pop("_skip_warning", False) if kwargs.get("scene_index_id") is None and kwargs.get("index_id") is not None: kwargs["scene_index_id"] = kwargs.get("index_id") kwargs.pop("index_id", None)