Skip to content

Commit db9ae7f

Browse files
fix: more filter_id issues (#1730)
* missed filter_id fixes * unit test * clarify filter comments * Use bracket notation for document body keys (#1734) Change assignments to the request body to use bracket notation (body['filename'], body['filter_id']) instead of dot notation. This ensures the string keys are set explicitly on the Record<string, string> and avoids relying on declared property names. * fix: Update integration.test.ts (#1737) --------- Co-authored-by: Edwin Jose <edwin.jose@datastax.com>
1 parent 1a059d2 commit db9ae7f

8 files changed

Lines changed: 136 additions & 44 deletions

File tree

sdks/python/openrag_sdk/documents.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ async def delete(
163163
except NotFoundError as e:
164164
# Keep delete idempotent for SDK callers: a missing document is not
165165
# an exception.
166-
# (Filter-not-found 404s do raise — that's a caller error, not idempotency.)
166+
# Filter-not-found 404s still raise because the filter_id is caller input.
167167
if filename is not None and getattr(e, "status_code", None) == 404:
168168
return DeleteDocumentResponse(
169169
success=False,

src/api/v1/_filter_resolution.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
without first GETting the filter, parsing its `query_data`, and resending the parts as
55
inline `filters`. This helper performs that lookup + normalization server-side.
66
7-
Wildcard handling mirrors `frontend/lib/filter-normalization.ts::buildSearchPayloadFilters`:
8-
a dimension like `data_sources: ["*"]` collapses to `[]` (i.e. "no filter on this field").
7+
Wildcard handling mirrors `frontend/lib/filter-normalization.ts::buildSearchPayloadFilters`.
8+
Each filter dimension is a list; if that list contains the wildcard value `"*"`
9+
(for example, `data_sources: ["*"]`), the dimension is treated as unscoped.
910
"""
1011

1112
import json
@@ -17,7 +18,7 @@
1718

1819

1920
def _strip_wildcards(filters: dict[str, Any] | None) -> dict[str, list[str]]:
20-
"""Drop `["*"]` and empty lists from each filter dimension."""
21+
"""Keep only filter dimensions that contain concrete values."""
2122
if not filters:
2223
return {}
2324
cleaned: dict[str, list[str]] = {}
@@ -66,3 +67,34 @@ async def resolve_filter_id(
6667
"limit": query_data.get("limit", 10),
6768
"score_threshold": query_data.get("scoreThreshold", 0),
6869
}
70+
71+
72+
def merge_filter_overrides(
73+
resolved: dict[str, Any],
74+
request_body: Any,
75+
) -> tuple[dict[str, Any] | None, int, float]:
76+
"""Merge resolved filter values with explicitly provided request fields.
77+
78+
Inline request fields override saved filter values by field presence, not by
79+
truthiness. This lets callers intentionally set values such as `limit=10`,
80+
`score_threshold=0`, or `filters={}`.
81+
"""
82+
provided_fields: set[str] = getattr(request_body, "model_fields_set", set())
83+
84+
filters: dict[str, Any] | None = resolved["filters"]
85+
if "filters" in provided_fields:
86+
inline_filters = request_body.filters
87+
if inline_filters:
88+
filters = {**resolved["filters"], **inline_filters}
89+
else:
90+
filters = inline_filters
91+
92+
limit = request_body.limit
93+
if "limit" not in provided_fields:
94+
limit = resolved["limit"]
95+
96+
score_threshold = request_body.score_threshold
97+
if "score_threshold" not in provided_fields:
98+
score_threshold = resolved["score_threshold"]
99+
100+
return filters, limit, score_threshold

src/api/v1/chat.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from fastapi.responses import JSONResponse, StreamingResponse
1414
from pydantic import BaseModel
1515

16-
from api.v1._filter_resolution import resolve_filter_id
16+
from api.v1._filter_resolution import merge_filter_overrides, resolve_filter_id
1717
from auth_context import set_auth_context, set_score_threshold, set_search_filters, set_search_limit
1818
from dependencies import (
1919
get_chat_service,
@@ -147,13 +147,9 @@ async def chat_create_endpoint(
147147
user_id=user.user_id,
148148
jwt_token=jwt_token,
149149
)
150-
# Inline values override per-field; defaults (10 / 0) fall back to the filter.
151-
if not body.filters:
152-
resolved_filters = resolved["filters"]
153-
if body.limit == 10:
154-
resolved_limit = resolved["limit"]
155-
if body.score_threshold == 0:
156-
resolved_score_threshold = resolved["score_threshold"]
150+
resolved_filters, resolved_limit, resolved_score_threshold = merge_filter_overrides(
151+
resolved, body
152+
)
157153

158154
if resolved_filters:
159155
set_search_filters(resolved_filters)

src/api/v1/documents.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,10 @@ async def ingest_endpoint(
5050
POST /v1/documents/ingest
5151
Request: multipart/form-data with "file" field
5252
53-
NOTE: `create_filter` is kept here for response-shape compatibility — the
54-
non-v1 onboarding flow consumes the `create_filter` field echoed back in
55-
the response. v1 SDK consumers do not currently have a workflow that uses
56-
it, and the field is never forwarded to the actual ingest task. It should
57-
be removed in a future major version of the v1 API once we are willing to
58-
take the breaking change (response no longer contains `create_filter`).
53+
NOTE: `create_filter` is upload-router/onboarding metadata. The router
54+
echoes it in the upload response so onboarding can create a saved knowledge
55+
filter after ingestion completes. It does not affect task creation or
56+
ingestion behavior, and the v1 SDK does not expose this option.
5957
"""
6058
return await upload_ingest_router(
6159
file=file,
@@ -139,8 +137,8 @@ async def delete_document_endpoint(
139137
Provide exactly one of:
140138
- `filename`: delete all chunks for that filename.
141139
- `filter_id`: resolve the filter's `data_sources` and delete chunks for
142-
each of those filenames. Wildcard (`["*"]`) or empty `data_sources`
143-
is rejected to prevent mass deletion.
140+
each concrete filename. Empty `data_sources` or a list containing the
141+
wildcard value `"*"` is rejected to prevent mass deletion.
144142
"""
145143
if bool(body.filename) == bool(body.filter_id):
146144
return JSONResponse(
@@ -153,7 +151,7 @@ async def delete_document_endpoint(
153151
body.filter_id,
154152
knowledge_filter_service,
155153
user_id=user.user_id,
156-
jwt_token=None,
154+
jwt_token=user.jwt_token,
157155
)
158156
filenames = resolved["filters"].get("data_sources") or []
159157
if not filenames:
@@ -164,19 +162,21 @@ async def delete_document_endpoint(
164162

165163
results = []
166164
total_deleted = 0
165+
statuses = []
167166
for fname in filenames:
168167
payload, _status = await delete_documents_by_filename_core(
169168
filename=fname,
170169
session_manager=session_manager,
171170
user_id=user.user_id,
172-
jwt_token=None,
171+
jwt_token=user.jwt_token,
173172
)
174173
results.append(payload)
174+
statuses.append(_status)
175175
total_deleted += payload.get("deleted_chunks", 0) or 0
176176

177177
return JSONResponse(
178178
{
179-
"success": True,
179+
"success": all(200 <= status < 300 for status in statuses),
180180
"deleted_chunks": total_deleted,
181181
"filenames": filenames,
182182
"filter_id": body.filter_id,
@@ -188,6 +188,6 @@ async def delete_document_endpoint(
188188
filename=body.filename,
189189
session_manager=session_manager,
190190
user_id=user.user_id,
191-
jwt_token=None,
191+
jwt_token=user.jwt_token,
192192
)
193193
return JSONResponse(payload, status_code=status_code)

src/api/v1/search.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from fastapi.responses import JSONResponse
1212
from pydantic import BaseModel
1313

14-
from api.v1._filter_resolution import resolve_filter_id
14+
from api.v1._filter_resolution import merge_filter_overrides, resolve_filter_id
1515
from auth_context import set_auth_context
1616
from dependencies import (
1717
get_knowledge_filter_service,
@@ -44,9 +44,8 @@ async def search_endpoint(
4444
if not query:
4545
return JSONResponse({"error": "Query is required"}, status_code=400)
4646

47-
# API-key auth has no JWT; the gate inside search_service.search()
48-
# skips set_auth_context() in that case, leaving search_tool() unable to
49-
# resolve the user. Set it explicitly here, mirroring v1 chat.
47+
# API-key requests can arrive without a JWT. Set the auth context before
48+
# resolving filters so search_tool() can still identify the caller.
5049
set_auth_context(user.user_id, user.jwt_token)
5150

5251
resolved_filters = body.filters
@@ -57,14 +56,11 @@ async def search_endpoint(
5756
body.filter_id,
5857
knowledge_filter_service,
5958
user_id=user.user_id,
60-
jwt_token=None,
59+
jwt_token=user.jwt_token,
60+
)
61+
resolved_filters, resolved_limit, resolved_score_threshold = merge_filter_overrides(
62+
resolved, body
6163
)
62-
if not body.filters:
63-
resolved_filters = resolved["filters"]
64-
if body.limit == 10:
65-
resolved_limit = resolved["limit"]
66-
if body.score_threshold == 0:
67-
resolved_score_threshold = resolved["score_threshold"]
6864

6965
logger.debug(
7066
"Public API search request",

tests/integration/sdk/test_documents.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,40 +155,50 @@ async def _create_filter(self, client, data_sources: list[str]) -> str:
155155
}
156156
)
157157
assert result.success is True, f"Failed to create filter: {result.error}"
158+
assert isinstance(result.id, str) and result.id, (
159+
f"Filter creation returned no id: {result.error}"
160+
)
158161
return result.id
159162

160163
@pytest.mark.asyncio
161164
async def test_delete_documents_by_filter_id(self, client, tmp_path):
162165
"""Deleting by filter_id removes only the filenames in the filter's data_sources."""
163166
alpha, beta = await self._ingest_two(client, tmp_path)
164-
filter_id = await self._create_filter(client, [alpha.name])
167+
filter_id = None
165168

166169
try:
170+
filter_id = await self._create_filter(client, [alpha.name])
167171
result = await client.documents.delete(filter_id=filter_id)
168172
assert result.success is True
169173
assert result.filter_id == filter_id
170174
assert alpha.name in (result.filenames or [])
171175
assert beta.name not in (result.filenames or [])
172-
# Beta still searchable
173-
still_there = await client.search.query("tigers")
176+
# Beta still exists; scope the verification to beta's filename so
177+
# the assertion does not depend on semantic ranking.
178+
still_there = await client.search.query(
179+
"*", filters={"data_sources": [beta.name]}, limit=5
180+
)
174181
assert any(r.filename == beta.name for r in still_there.results), (
175182
"Beta should still be present after filter-id delete of alpha"
176183
)
177184
finally:
178-
await client.knowledge_filters.delete(filter_id)
185+
if filter_id is not None:
186+
await client.knowledge_filters.delete(filter_id)
179187
# Best-effort cleanup
180188
await client.documents.delete(alpha.name)
181189
await client.documents.delete(beta.name)
182190

183191
@pytest.mark.asyncio
184192
async def test_delete_by_filter_id_with_wildcard_rejects(self, client):
185-
"""A filter with `["*"]` data_sources must NOT be allowed to mass-delete."""
186-
filter_id = await self._create_filter(client, ["*"])
193+
"""A filter whose data_sources list contains `"*"` is rejected."""
194+
filter_id = None
187195
try:
196+
filter_id = await self._create_filter(client, ["*"])
188197
with pytest.raises(OpenRAGError):
189198
await client.documents.delete(filter_id=filter_id)
190199
finally:
191-
await client.knowledge_filters.delete(filter_id)
200+
if filter_id is not None:
201+
await client.knowledge_filters.delete(filter_id)
192202

193203
@pytest.mark.asyncio
194204
async def test_delete_with_both_filename_and_filter_id_rejects(self, client):

tests/integration/sdk/test_filters.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ async def _create_filter_for(client, name: str, data_sources: list[str]) -> str:
5959
}
6060
)
6161
assert result.success is True, f"Failed to create filter: {result.error}"
62+
assert isinstance(result.id, str) and result.id, (
63+
f"Filter creation returned no id: {result.error}"
64+
)
6265
return result.id
6366

6467

@@ -138,7 +141,7 @@ async def test_filter_id_in_chat_actually_filters(self, client, tmp_path):
138141

139142
@pytest.mark.asyncio
140143
async def test_filter_id_in_chat_inline_overrides(self, client, tmp_path):
141-
"""Inline `filters` win over filter_id per the v1 override contract."""
144+
"""Inline filters override the resolved filter_id per field."""
142145
alpha, beta = await _ingest_pair(client, tmp_path)
143146
filter_id = await _create_filter_for(client, "SDK chat inline-override", [alpha.name])
144147

@@ -215,7 +218,7 @@ async def test_filter_id_in_search_actually_filters(self, client, tmp_path):
215218

216219
@pytest.mark.asyncio
217220
async def test_filter_id_in_search_inline_overrides(self, client, tmp_path):
218-
"""Inline filters override the resolved filter_id per-field."""
221+
"""Inline filters override the resolved filter_id per field."""
219222
alpha, beta = await _ingest_pair(client, tmp_path)
220223
filter_id = await _create_filter_for(client, "SDK search inline-override", [alpha.name])
221224

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from typing import Any
2+
3+
from pydantic import BaseModel
4+
5+
from api.v1._filter_resolution import merge_filter_overrides
6+
7+
8+
class _RequestBody(BaseModel):
9+
filters: dict[str, Any] | None = None
10+
limit: int = 10
11+
score_threshold: float = 0
12+
13+
14+
def test_merge_filter_overrides_uses_resolved_values_when_fields_absent():
15+
resolved = {
16+
"filters": {"data_sources": ["alpha.md"], "owners": ["alice"]},
17+
"limit": 5,
18+
"score_threshold": 0.4,
19+
}
20+
21+
filters, limit, score_threshold = merge_filter_overrides(resolved, _RequestBody())
22+
23+
assert filters == resolved["filters"]
24+
assert limit == 5
25+
assert score_threshold == 0.4
26+
27+
28+
def test_merge_filter_overrides_respects_explicit_defaults_and_empty_filters():
29+
resolved = {
30+
"filters": {"data_sources": ["alpha.md"]},
31+
"limit": 5,
32+
"score_threshold": 0.4,
33+
}
34+
body = _RequestBody(filters={}, limit=10, score_threshold=0)
35+
36+
filters, limit, score_threshold = merge_filter_overrides(resolved, body)
37+
38+
assert filters == {}
39+
assert limit == 10
40+
assert score_threshold == 0
41+
42+
43+
def test_merge_filter_overrides_merges_partial_inline_filters_per_field():
44+
resolved = {
45+
"filters": {"data_sources": ["alpha.md"], "owners": ["alice"]},
46+
"limit": 5,
47+
"score_threshold": 0.4,
48+
}
49+
body = _RequestBody(filters={"data_sources": ["beta.md"]})
50+
51+
filters, limit, score_threshold = merge_filter_overrides(resolved, body)
52+
53+
assert filters == {"data_sources": ["beta.md"], "owners": ["alice"]}
54+
assert limit == 5
55+
assert score_threshold == 0.4

0 commit comments

Comments
 (0)