From 1d70abfe85e8e8bc7efaf595c23e8b7219c37edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Mon, 2 Mar 2026 17:03:16 +0100 Subject: [PATCH] feat: add tags filtering and q description fix for list documents API (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Pydantic AI integration to CI, release pipeline, and docs - Add test-pydantic-ai-integration job to CI (test.yml) - Add build, publish, and artifact steps to release workflow (release.yml) - Add hindsight-integrations/pydantic-ai to release.sh version bumping - Add Pydantic AI documentation page (sdks/integrations/pydantic-ai.md) - Add Pydantic AI entry to sidebar with icon * docs: remove Requirements section from pydantic-ai integration page * feat: add tags filtering and fix offset pagination docs for list documents API - Add `tags` and `tags_match` query params to GET /banks/{bank_id}/documents - Supports any, all, any_strict, all_strict matching modes (default: any_strict) - Fix `q` param description — it's a case-insensitive substring match on document ID only - Add tests for offset pagination and all tags_match modes - Regenerate OpenAPI spec and Python/TypeScript/Go clients - Document the new filtering options in docs/developer/api/documents.mdx * fix(cli): pass new tags/tags_match args to list_documents --- hindsight-api/hindsight_api/api/http.py | 20 +- .../hindsight_api/engine/interface.py | 7 +- .../hindsight_api/engine/memory_engine.py | 17 +- hindsight-api/tests/test_list_documents.py | 174 ++++++++++++++++++ hindsight-cli/src/api.rs | 2 + hindsight-clients/go/api/openapi.yaml | 26 ++- hindsight-clients/go/api_documents.go | 33 ++++ .../hindsight_client_api/api/documents_api.py | 52 +++++- .../typescript/generated/types.gen.ts | 14 ++ .../docs/developer/api/documents.mdx | 45 +++++ hindsight-docs/examples/api/documents.mjs | 41 +++++ hindsight-docs/examples/api/documents.py | 39 ++++ hindsight-docs/static/openapi.json | 37 +++- 13 files changed, 490 insertions(+), 17 deletions(-) create mode 100644 hindsight-api/tests/test_list_documents.py diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index f4684901..59e5961f 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -3064,7 +3064,13 @@ def _register_routes(app: FastAPI): ) async def api_list_documents( bank_id: str, - q: str | None = None, + q: str | None = Query( + None, description="Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024')" + ), + tags: list[str] | None = Query(None, description="Filter documents by tags"), + tags_match: str = Query( + "any_strict", description="How to match tags: 'any', 'all', 'any_strict', 'all_strict'" + ), limit: int = 100, offset: int = 0, request_context: RequestContext = Depends(get_request_context), @@ -3074,13 +3080,21 @@ def _register_routes(app: FastAPI): Args: bank_id: Memory Bank ID (from path) - q: Search query (searches document ID and metadata) + q: Case-insensitive substring filter on document ID + tags: Filter documents by tags + tags_match: How to match tags (any, all, any_strict, all_strict) limit: Maximum number of results (default: 100) offset: Offset for pagination (default: 0) """ try: data = await app.state.memory.list_documents( - bank_id=bank_id, search_query=q, limit=limit, offset=offset, request_context=request_context + bank_id=bank_id, + search_query=q, + tags=tags, + tags_match=tags_match, + limit=limit, + offset=offset, + request_context=request_context, ) return data except OperationValidationError as e: diff --git a/hindsight-api/hindsight_api/engine/interface.py b/hindsight-api/hindsight_api/engine/interface.py index 6115dfde..296dfde2 100644 --- a/hindsight-api/hindsight_api/engine/interface.py +++ b/hindsight-api/hindsight_api/engine/interface.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from hindsight_api.engine.memory_engine import Budget from hindsight_api.engine.response_models import RecallResult, ReflectResult + from hindsight_api.engine.search.tags import TagsMatch from hindsight_api.models import RequestContext @@ -337,6 +338,8 @@ class MemoryEngineInterface(ABC): bank_id: str, *, search_query: str | None = None, + tags: list[str] | None = None, + tags_match: "TagsMatch" = "any_strict", limit: int = 100, offset: int = 0, request_context: "RequestContext", @@ -346,7 +349,9 @@ class MemoryEngineInterface(ABC): Args: bank_id: The memory bank ID. - search_query: Search query. + search_query: Case-insensitive substring filter on document ID. + tags: Filter by tags. + tags_match: How to match tags (any, all, any_strict, all_strict). limit: Maximum results. offset: Pagination offset. request_context: Request context for authentication. diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index 22e888f5..84b20f2a 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -184,7 +184,7 @@ from .retain import bank_utils, embedding_utils from .retain.types import RetainContentDict from .search import think_utils from .search.reranking import CrossEncoderReranker -from .search.tags import TagsMatch +from .search.tags import TagsMatch, build_tags_where_clause from .task_backend import BrokerTaskBackend, SyncTaskBackend, TaskBackend @@ -4142,6 +4142,8 @@ class MemoryEngine(MemoryEngineInterface): bank_id: str, *, search_query: str | None = None, + tags: list[str] | None = None, + tags_match: "TagsMatch" = "any_strict", limit: int = 100, offset: int = 0, request_context: "RequestContext", @@ -4152,6 +4154,8 @@ class MemoryEngine(MemoryEngineInterface): Args: bank_id: bank ID (required) search_query: Search in document ID + tags: Filter by tags + tags_match: How to match tags (any, all, any_strict, all_strict) limit: Maximum number of results offset: Offset for pagination request_context: Request context for authentication. @@ -4182,7 +4186,16 @@ class MemoryEngine(MemoryEngineInterface): query_conditions.append(f"id ILIKE ${param_count}") query_params.append(f"%{search_query}%") + tags_clause, tags_params, next_param = build_tags_where_clause( + tags, param_offset=param_count + 1, match=tags_match + ) + query_params.extend(tags_params) + param_count = next_param - 1 # next_param is next available; convert to last used + where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else "" + if tags_clause: + # tags_clause starts with "AND", append after WHERE conditions + where_clause = where_clause + " " + tags_clause if where_clause else "WHERE " + tags_clause[4:].lstrip() # Get total count count_query = f""" @@ -6038,8 +6051,6 @@ class MemoryEngine(MemoryEngineInterface): async with acquire_with_retry(pool) as conn: # Build filters - from .search.tags import build_tags_where_clause - filters = ["bank_id = $1"] params: list[Any] = [bank_id] param_idx = 2 diff --git a/hindsight-api/tests/test_list_documents.py b/hindsight-api/tests/test_list_documents.py new file mode 100644 index 00000000..acd4a9a4 --- /dev/null +++ b/hindsight-api/tests/test_list_documents.py @@ -0,0 +1,174 @@ +""" +Tests for list_documents pagination and tags filtering. +""" +from datetime import datetime, timezone + +import pytest + + +async def _retain_doc(memory, bank_id, document_id, tags, request_context): + """Helper to retain a document with given tags. Uses gibberish content to avoid LLM + fact extraction (documents are persisted even with zero facts).""" + await memory.retain_batch_async( + bank_id=bank_id, + contents=[{"content": f"xyzabc123 !@# $$$ {document_id}"}], + document_id=document_id, + document_tags=tags or None, + request_context=request_context, + ) + + +@pytest.mark.asyncio +async def test_list_documents_offset_pagination(memory, request_context): + """offset parameter returns the correct slice of documents.""" + bank_id = f"test_list_docs_offset_{datetime.now(timezone.utc).timestamp()}" + + try: + for i in range(4): + await _retain_doc(memory, bank_id, f"doc-{i:02d}", [], request_context) + + # All documents, ordered by created_at DESC → doc-03, doc-02, doc-01, doc-00 + all_docs = await memory.list_documents( + bank_id=bank_id, limit=10, offset=0, request_context=request_context + ) + assert all_docs["total"] == 4 + assert len(all_docs["items"]) == 4 + all_ids = [d["id"] for d in all_docs["items"]] + + # offset=2 should skip the first two and return the remaining two + page2 = await memory.list_documents( + bank_id=bank_id, limit=10, offset=2, request_context=request_context + ) + assert page2["total"] == 4 # total is always the full count + assert len(page2["items"]) == 2 + assert [d["id"] for d in page2["items"]] == all_ids[2:] + + # offset beyond total returns empty items but correct total + beyond = await memory.list_documents( + bank_id=bank_id, limit=10, offset=10, request_context=request_context + ) + assert beyond["total"] == 4 + assert beyond["items"] == [] + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_list_documents_tags_filter_any_strict(memory, request_context): + """tags filter with any_strict returns only tagged documents that match.""" + bank_id = f"test_list_docs_tags_{datetime.now(timezone.utc).timestamp()}" + + try: + await _retain_doc(memory, bank_id, "doc-alpha", ["team-a"], request_context) + await _retain_doc(memory, bank_id, "doc-beta", ["team-b"], request_context) + await _retain_doc(memory, bank_id, "doc-both", ["team-a", "team-b"], request_context) + await _retain_doc(memory, bank_id, "doc-untagged", [], request_context) + + # any_strict: only docs with at least one of the given tags, untagged excluded + result = await memory.list_documents( + bank_id=bank_id, + tags=["team-a"], + tags_match="any_strict", + request_context=request_context, + ) + ids = {d["id"] for d in result["items"]} + assert ids == {"doc-alpha", "doc-both"} + assert result["total"] == 2 + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_list_documents_tags_filter_any_includes_untagged(memory, request_context): + """tags filter with 'any' mode includes untagged documents.""" + bank_id = f"test_list_docs_tags_any_{datetime.now(timezone.utc).timestamp()}" + + try: + await _retain_doc(memory, bank_id, "doc-tagged", ["team-a"], request_context) + await _retain_doc(memory, bank_id, "doc-other", ["team-b"], request_context) + await _retain_doc(memory, bank_id, "doc-untagged", [], request_context) + + result = await memory.list_documents( + bank_id=bank_id, + tags=["team-a"], + tags_match="any", + request_context=request_context, + ) + ids = {d["id"] for d in result["items"]} + # "any" includes untagged + matching tagged + assert "doc-tagged" in ids + assert "doc-untagged" in ids + assert "doc-other" not in ids + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_list_documents_tags_filter_all_strict(memory, request_context): + """tags filter with all_strict returns only docs that have ALL the specified tags.""" + bank_id = f"test_list_docs_tags_all_{datetime.now(timezone.utc).timestamp()}" + + try: + await _retain_doc(memory, bank_id, "doc-a-only", ["team-a"], request_context) + await _retain_doc(memory, bank_id, "doc-a-and-b", ["team-a", "team-b"], request_context) + await _retain_doc(memory, bank_id, "doc-untagged", [], request_context) + + result = await memory.list_documents( + bank_id=bank_id, + tags=["team-a", "team-b"], + tags_match="all_strict", + request_context=request_context, + ) + ids = {d["id"] for d in result["items"]} + assert ids == {"doc-a-and-b"} + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_list_documents_no_tags_filter_returns_all(memory, request_context): + """When no tags filter is specified, all documents are returned.""" + bank_id = f"test_list_docs_no_tags_{datetime.now(timezone.utc).timestamp()}" + + try: + await _retain_doc(memory, bank_id, "doc-tagged", ["team-a"], request_context) + await _retain_doc(memory, bank_id, "doc-untagged", [], request_context) + + result = await memory.list_documents( + bank_id=bank_id, + tags=None, + request_context=request_context, + ) + ids = {d["id"] for d in result["items"]} + assert ids == {"doc-tagged", "doc-untagged"} + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_list_documents_tags_and_search_query_combined(memory, request_context): + """tags filter and q (search_query) can be combined.""" + bank_id = f"test_list_docs_tags_q_{datetime.now(timezone.utc).timestamp()}" + + try: + await _retain_doc(memory, bank_id, "report-2024", ["team-a"], request_context) + await _retain_doc(memory, bank_id, "report-2025", ["team-b"], request_context) + await _retain_doc(memory, bank_id, "summary-2024", ["team-a"], request_context) + + result = await memory.list_documents( + bank_id=bank_id, + search_query="report", + tags=["team-a"], + tags_match="any_strict", + request_context=request_context, + ) + ids = {d["id"] for d in result["items"]} + assert ids == {"report-2024"} + + finally: + await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index 2aa23acf..ceba328e 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -300,6 +300,8 @@ impl ApiClient { offset.map(|o| o as i64), q, None, + None, + None, ).await?; Ok(response.into_inner()) }) diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 3a5a8285..bad0388e 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -1173,7 +1173,9 @@ paths: title: Bank Id type: string style: simple - - explode: true + - description: Case-insensitive substring filter on document ID (e.g. 'report' + matches 'report-2024') + explode: true in: query name: q required: false @@ -1181,6 +1183,28 @@ paths: nullable: true type: string style: form + - description: Filter documents by tags + explode: true + in: query + name: tags + required: false + schema: + items: + type: string + nullable: true + type: array + style: form + - description: "How to match tags: 'any', 'all', 'any_strict', 'all_strict'" + explode: true + in: query + name: tags_match + required: false + schema: + default: any_strict + description: "How to match tags: 'any', 'all', 'any_strict', 'all_strict'" + title: Tags Match + type: string + style: form - explode: true in: query name: limit diff --git a/hindsight-clients/go/api_documents.go b/hindsight-clients/go/api_documents.go index 67d6da5a..8b771311 100644 --- a/hindsight-clients/go/api_documents.go +++ b/hindsight-clients/go/api_documents.go @@ -17,6 +17,7 @@ import ( "net/http" "net/url" "strings" + "reflect" ) @@ -409,16 +410,31 @@ type ApiListDocumentsRequest struct { ApiService *DocumentsAPIService bankId string q *string + tags *[]string + tagsMatch *string limit *int32 offset *int32 authorization *string } +// Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024') func (r ApiListDocumentsRequest) Q(q string) ApiListDocumentsRequest { r.q = &q return r } +// Filter documents by tags +func (r ApiListDocumentsRequest) Tags(tags []string) ApiListDocumentsRequest { + r.tags = &tags + return r +} + +// How to match tags: 'any', 'all', 'any_strict', 'all_strict' +func (r ApiListDocumentsRequest) TagsMatch(tagsMatch string) ApiListDocumentsRequest { + r.tagsMatch = &tagsMatch + return r +} + func (r ApiListDocumentsRequest) Limit(limit int32) ApiListDocumentsRequest { r.limit = &limit return r @@ -480,6 +496,23 @@ func (a *DocumentsAPIService) ListDocumentsExecute(r ApiListDocumentsRequest) (* if r.q != nil { parameterAddToHeaderOrQuery(localVarQueryParams, "q", r.q, "form", "") } + if r.tags != nil { + t := *r.tags + if reflect.TypeOf(t).Kind() == reflect.Slice { + s := reflect.ValueOf(t) + for i := 0; i < s.Len(); i++ { + parameterAddToHeaderOrQuery(localVarQueryParams, "tags", s.Index(i).Interface(), "form", "multi") + } + } else { + parameterAddToHeaderOrQuery(localVarQueryParams, "tags", t, "form", "multi") + } + } + if r.tagsMatch != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "tags_match", r.tagsMatch, "form", "") + } else { + var defaultValue string = "any_strict" + r.tagsMatch = &defaultValue + } if r.limit != nil { parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "") } else { diff --git a/hindsight-clients/python/hindsight_client_api/api/documents_api.py b/hindsight-clients/python/hindsight_client_api/api/documents_api.py index 483c1a67..51179c65 100644 --- a/hindsight-clients/python/hindsight_client_api/api/documents_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/documents_api.py @@ -16,8 +16,9 @@ from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated -from pydantic import StrictInt, StrictStr -from typing import Optional +from pydantic import Field, StrictInt, StrictStr +from typing import List, Optional +from typing_extensions import Annotated from hindsight_client_api.models.chunk_response import ChunkResponse from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse from hindsight_client_api.models.document_response import DocumentResponse @@ -909,7 +910,9 @@ class DocumentsApi: async def list_documents( self, bank_id: StrictStr, - q: Optional[StrictStr] = None, + q: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024')")] = None, + tags: Annotated[Optional[List[StrictStr]], Field(description="Filter documents by tags")] = None, + tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags: 'any', 'all', 'any_strict', 'all_strict'")] = None, limit: Optional[StrictInt] = None, offset: Optional[StrictInt] = None, authorization: Optional[StrictStr] = None, @@ -932,8 +935,12 @@ class DocumentsApi: :param bank_id: (required) :type bank_id: str - :param q: + :param q: Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024') :type q: str + :param tags: Filter documents by tags + :type tags: List[str] + :param tags_match: How to match tags: 'any', 'all', 'any_strict', 'all_strict' + :type tags_match: str :param limit: :type limit: int :param offset: @@ -965,6 +972,8 @@ class DocumentsApi: _param = self._list_documents_serialize( bank_id=bank_id, q=q, + tags=tags, + tags_match=tags_match, limit=limit, offset=offset, authorization=authorization, @@ -993,7 +1002,9 @@ class DocumentsApi: async def list_documents_with_http_info( self, bank_id: StrictStr, - q: Optional[StrictStr] = None, + q: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024')")] = None, + tags: Annotated[Optional[List[StrictStr]], Field(description="Filter documents by tags")] = None, + tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags: 'any', 'all', 'any_strict', 'all_strict'")] = None, limit: Optional[StrictInt] = None, offset: Optional[StrictInt] = None, authorization: Optional[StrictStr] = None, @@ -1016,8 +1027,12 @@ class DocumentsApi: :param bank_id: (required) :type bank_id: str - :param q: + :param q: Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024') :type q: str + :param tags: Filter documents by tags + :type tags: List[str] + :param tags_match: How to match tags: 'any', 'all', 'any_strict', 'all_strict' + :type tags_match: str :param limit: :type limit: int :param offset: @@ -1049,6 +1064,8 @@ class DocumentsApi: _param = self._list_documents_serialize( bank_id=bank_id, q=q, + tags=tags, + tags_match=tags_match, limit=limit, offset=offset, authorization=authorization, @@ -1077,7 +1094,9 @@ class DocumentsApi: async def list_documents_without_preload_content( self, bank_id: StrictStr, - q: Optional[StrictStr] = None, + q: Annotated[Optional[StrictStr], Field(description="Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024')")] = None, + tags: Annotated[Optional[List[StrictStr]], Field(description="Filter documents by tags")] = None, + tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags: 'any', 'all', 'any_strict', 'all_strict'")] = None, limit: Optional[StrictInt] = None, offset: Optional[StrictInt] = None, authorization: Optional[StrictStr] = None, @@ -1100,8 +1119,12 @@ class DocumentsApi: :param bank_id: (required) :type bank_id: str - :param q: + :param q: Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024') :type q: str + :param tags: Filter documents by tags + :type tags: List[str] + :param tags_match: How to match tags: 'any', 'all', 'any_strict', 'all_strict' + :type tags_match: str :param limit: :type limit: int :param offset: @@ -1133,6 +1156,8 @@ class DocumentsApi: _param = self._list_documents_serialize( bank_id=bank_id, q=q, + tags=tags, + tags_match=tags_match, limit=limit, offset=offset, authorization=authorization, @@ -1157,6 +1182,8 @@ class DocumentsApi: self, bank_id, q, + tags, + tags_match, limit, offset, authorization, @@ -1169,6 +1196,7 @@ class DocumentsApi: _host = None _collection_formats: Dict[str, str] = { + 'tags': 'multi', } _path_params: Dict[str, str] = {} @@ -1188,6 +1216,14 @@ class DocumentsApi: _query_params.append(('q', q)) + if tags is not None: + + _query_params.append(('tags', tags)) + + if tags_match is not None: + + _query_params.append(('tags_match', tags_match)) + if limit is not None: _query_params.append(('limit', limit)) diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 5cfb72e8..d85e7c84 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -3082,8 +3082,22 @@ export type ListDocumentsData = { query?: { /** * Q + * + * Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024') */ q?: string | null; + /** + * Tags + * + * Filter documents by tags + */ + tags?: Array | null; + /** + * Tags Match + * + * How to match tags: 'any', 'all', 'any_strict', 'all_strict' + */ + tags_match?: string; /** * Limit */ diff --git a/hindsight-docs/docs/developer/api/documents.mdx b/hindsight-docs/docs/developer/api/documents.mdx index c6d25789..bd3ea29c 100644 --- a/hindsight-docs/docs/developer/api/documents.mdx +++ b/hindsight-docs/docs/developer/api/documents.mdx @@ -131,6 +131,51 @@ hindsight document delete my-bank meeting-2024-03-15 Deleting a document permanently removes all memories extracted from it. This action cannot be undone. ::: +## List Documents + +List documents in a bank with optional filtering by ID and tags. + + + + + + + + + + +```bash +# List all documents +hindsight document list my-bank + +# Filter by ID substring +hindsight document list my-bank --q report + +# Filter by tags +hindsight document list my-bank --tags team-a --tags team-b +``` + + + + +### Filtering Options + +| Parameter | Description | +|---|---| +| `q` | Case-insensitive substring match on document ID. `report` matches `report-2024`, `annual-report`, etc. | +| `tags` | Filter by document tags. Accepts multiple values. | +| `tags_match` | How to match tags (default: `any_strict`). See below. | +| `limit` / `offset` | Pagination. Default limit is 100. | + +**`tags_match` modes:** + +| Mode | Behaviour | +|---|---| +| `any_strict` *(default)* | Document must have **at least one** of the specified tags. Untagged docs excluded. | +| `any` | Same as `any_strict` but also includes untagged documents. | +| `all_strict` | Document must have **all** specified tags. Untagged docs excluded. | +| `all` | Same as `all_strict` but also includes untagged documents. | + ## Document Response Format ```json diff --git a/hindsight-docs/examples/api/documents.mjs b/hindsight-docs/examples/api/documents.mjs index d973a2e3..0f725b48 100644 --- a/hindsight-docs/examples/api/documents.mjs +++ b/hindsight-docs/examples/api/documents.mjs @@ -44,6 +44,47 @@ await client.retain('my-bank', 'Project deadline: April 15 (extended)', { // [/docs:document-update] +// [docs:document-list] +const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' })); + +// List all documents +const { data: allDocs } = await sdk.listDocuments({ + client: apiClient, + path: { bank_id: 'my-bank' } +}); +console.log(`Total documents: ${allDocs.total}`); + +// Filter by document ID substring +const { data: reportDocs } = await sdk.listDocuments({ + client: apiClient, + path: { bank_id: 'my-bank' }, + query: { q: 'report' } +}); + +// Filter by tags — only docs tagged with "team-a" (untagged excluded) +const { data: taggedDocs } = await sdk.listDocuments({ + client: apiClient, + path: { bank_id: 'my-bank' }, + query: { tags: ['team-a'], tags_match: 'any_strict' } +}); + +// Combine ID search and tags +const { data: filtered } = await sdk.listDocuments({ + client: apiClient, + path: { bank_id: 'my-bank' }, + query: { q: 'meeting', tags: ['team-a', 'team-b'], tags_match: 'all_strict' } +}); + +// Paginate +const { data: page } = await sdk.listDocuments({ + client: apiClient, + path: { bank_id: 'my-bank' }, + query: { limit: 20, offset: 40 } +}); +console.log(`Page items: ${page.items.length}`); +// [/docs:document-list] + + // [docs:document-get] const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' })); diff --git a/hindsight-docs/examples/api/documents.py b/hindsight-docs/examples/api/documents.py index f51e0489..3d9d1010 100644 --- a/hindsight-docs/examples/api/documents.py +++ b/hindsight-docs/examples/api/documents.py @@ -56,6 +56,45 @@ client.retain( # [/docs:document-update] +# [docs:document-list] +from hindsight_client_api import ApiClient, Configuration +from hindsight_client_api.api import DocumentsApi + +async def list_documents_example(): + config = Configuration(host="http://localhost:8888") + api_client = ApiClient(config) + api = DocumentsApi(api_client) + + # List all documents + result = await api.list_documents(bank_id="my-bank") + print(f"Total documents: {result.total}") + + # Filter by document ID substring + result = await api.list_documents(bank_id="my-bank", q="report") + + # Filter by tags — only docs tagged with "team-a" (untagged excluded) + result = await api.list_documents( + bank_id="my-bank", + tags=["team-a"], + tags_match="any_strict", + ) + + # Combine ID search and tags + result = await api.list_documents( + bank_id="my-bank", + q="meeting", + tags=["team-a", "team-b"], + tags_match="all_strict", # must have both tags + ) + + # Paginate + result = await api.list_documents(bank_id="my-bank", limit=20, offset=40) + print(f"Page items: {len(result.items)}") + +asyncio.run(list_documents_example()) +# [/docs:document-list] + + # [docs:document-get] import asyncio from hindsight_client_api import ApiClient, Configuration diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index 32803c75..082ad671 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -1750,8 +1750,43 @@ "type": "null" } ], + "description": "Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024')", "title": "Q" - } + }, + "description": "Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024')" + }, + { + "name": "tags", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Filter documents by tags", + "title": "Tags" + }, + "description": "Filter documents by tags" + }, + { + "name": "tags_match", + "in": "query", + "required": false, + "schema": { + "type": "string", + "description": "How to match tags: 'any', 'all', 'any_strict', 'all_strict'", + "default": "any_strict", + "title": "Tags Match" + }, + "description": "How to match tags: 'any', 'all', 'any_strict', 'all_strict'" }, { "name": "limit",