From 1b4ad7f435f0632ef112db269b3105765fb92f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Sat, 7 Mar 2026 09:00:13 +0100 Subject: [PATCH] feat: change tags for a document (#517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add update document tags endpoint with observation invalidation Adds PATCH /v1/default/banks/{bank_id}/documents/{document_id} to change tags on a document without re-processing content. - Updates tags on the document and all associated memory units atomically - Invalidates observations derived from the document's memory units - Resets consolidated_at on the document's own units for re-consolidation - Also resets consolidated_at on co-source memories from other documents that shared those observations (matching delete_document behavior) - Triggers async consolidation when observations are invalidated - 9 new tests covering all invalidation scenarios UI: adds inline tag editor to the document detail panel in the control plane Docs: new "Update Document Tags" section in documents.mdx with Python/JS examples * refactor: simplify UpdateDocumentTagsResponse to {success: true} * refactor: make PATCH /documents generic update_document endpoint Renames update_document_tags → update_document (engine + HTTP + clients + UI). Currently only tags are supported; the structure is open for future fields. Tags are the only field with side effects (observation invalidation + re-consolidation). --- hindsight-api/hindsight_api/api/http.py | 73 ++++ .../hindsight_api/engine/memory_engine.py | 134 ++++++++ .../tests/test_observation_invalidation.py | 278 +++++++++++++++ hindsight-clients/go/api/openapi.yaml | 78 +++++ hindsight-clients/go/api_documents.go | 141 ++++++++ .../go/model_update_document_request.go | 127 +++++++ .../go/model_update_document_response.go | 130 +++++++ .../python/.openapi-generator/FILES | 2 + .../python/hindsight_client_api/__init__.py | 2 + .../hindsight_client_api/api/documents_api.py | 323 ++++++++++++++++++ .../hindsight_client_api/models/__init__.py | 2 + .../models/update_document_request.py | 92 +++++ .../models/update_document_response.py | 87 +++++ .../typescript/generated/sdk.gen.ts | 28 ++ .../typescript/generated/types.gen.ts | 68 ++++ .../app/api/documents/[documentId]/route.ts | 38 ++- .../src/components/documents-view.tsx | 120 ++++++- hindsight-control-plane/src/lib/api.ts | 14 + hindsight-control-plane/tsconfig.json | 4 +- .../docs/developer/api/documents.mdx | 28 ++ hindsight-docs/examples/api/documents.mjs | 23 ++ hindsight-docs/examples/api/documents.py | 29 ++ hindsight-docs/static/openapi.json | 116 +++++++ 23 files changed, 1928 insertions(+), 9 deletions(-) create mode 100644 hindsight-clients/go/model_update_document_request.go create mode 100644 hindsight-clients/go/model_update_document_response.go create mode 100644 hindsight-clients/python/hindsight_client_api/models/update_document_request.py create mode 100644 hindsight-clients/python/hindsight_client_api/models/update_document_response.py diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index 5de04c82..ab1b5e08 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -1206,6 +1206,30 @@ class DocumentResponse(BaseModel): tags: list[str] = FieldWithDefault(list, description="Tags associated with this document") +class UpdateDocumentRequest(BaseModel): + """Request model for updating a document's mutable fields.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "tags": ["team-a", "team-b"], + } + } + ) + + tags: list[str] | None = Field( + default=None, + description="New tags for the document and its memory units. " + "Triggers observation invalidation and re-consolidation.", + ) + + +class UpdateDocumentResponse(BaseModel): + """Response model for update document endpoint.""" + + success: bool = True + + class DeleteDocumentResponse(BaseModel): """Response model for delete document endpoint.""" @@ -3304,6 +3328,55 @@ def _register_routes(app: FastAPI): logger.error(f"Error in /v1/default/chunks/{chunk_id}: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) + @app.patch( + "/v1/default/banks/{bank_id}/documents/{document_id:path}", + response_model=UpdateDocumentResponse, + summary="Update document", + description="Update mutable fields on a document without re-processing its content.\n\n" + "**Tags** (`tags`): Propagated to all associated memory units. Observations derived from " + "those units are invalidated and queued for re-consolidation under the new tags. " + "Co-source memories from other documents that shared those observations are also reset.\n\n" + "At least one field must be provided.", + operation_id="update_document", + tags=["Documents"], + ) + async def api_update_document( + bank_id: str, + document_id: str, + body: UpdateDocumentRequest, + request_context: RequestContext = Depends(get_request_context), + ): + """ + Update mutable fields on a document without re-processing its content. + + Args: + bank_id: Memory Bank ID (from path) + document_id: Document ID (from path) + body: Fields to update (tags, metadata, context) + """ + if body.tags is None: + raise HTTPException(status_code=422, detail="At least one field (tags) must be provided") + try: + result = await app.state.memory.update_document( + document_id, + bank_id, + tags=body.tags, + request_context=request_context, + ) + if not result: + raise HTTPException(status_code=404, detail="Document not found") + return UpdateDocumentResponse(success=True) + except OperationValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.reason) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/documents/{document_id}: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + @app.delete( "/v1/default/banks/{bank_id}/documents/{document_id:path}", response_model=DeleteDocumentResponse, diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index bc47a4b3..3ca38ec4 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -3440,6 +3440,140 @@ class MemoryEngine(MemoryEngineInterface): return result + async def update_document( + self, + document_id: str, + bank_id: str, + *, + tags: list[str] | None = None, + request_context: "RequestContext", + ) -> bool: + """ + Update mutable fields on a document without re-processing its content. + + Tag changes propagate to all associated memory units and trigger observation + invalidation + re-consolidation (same semantics as delete_document): + - Observations referencing the document's memory units are deleted. + - The document's own units and any co-source memories from other documents + have consolidated_at reset so they are re-consolidated under the new tags. + + Args: + document_id: Document ID to update + bank_id: Bank ID that owns the document + tags: New tags to apply to the document and all its memory units (optional) + request_context: Request context for authentication. + + Returns: + True if the document was found and updated, False if not found + """ + await self._authenticate_tenant(request_context) + if self._operation_validator: + from hindsight_api.extensions import BankWriteContext + + ctx = BankWriteContext(bank_id=bank_id, operation="update_document", request_context=request_context) + await self._validate_operation(self._operation_validator.validate_bank_write(ctx)) + pool = await self._get_pool() + invalidated_obs = 0 + async with acquire_with_retry(pool) as conn: + async with conn.transaction(): + set_parts: list[str] = ["updated_at = now()"] + params: list[Any] = [] + p = 1 + + if tags is not None: + set_parts.append(f"tags = ${p}") + params.append(tags) + p += 1 + + params.extend([document_id, bank_id]) + doc_id_found = await conn.fetchval( + f""" + UPDATE {fq_table("documents")} + SET {", ".join(set_parts)} + WHERE id = ${p} AND bank_id = ${p + 1} + RETURNING id + """, + *params, + ) + if not doc_id_found: + return False + + if tags is not None: + unit_rows = await conn.fetch( + f"SELECT id FROM {fq_table('memory_units')} WHERE document_id = $1 AND fact_type IN ('experience', 'world')", + document_id, + ) + unit_ids = [str(row["id"]) for row in unit_rows] + + await conn.execute( + f"UPDATE {fq_table('memory_units')} SET tags = $1 WHERE document_id = $2", + tags, + document_id, + ) + + if unit_ids: + import uuid as uuid_module + + unit_uuids = [uuid_module.UUID(uid) for uid in unit_ids] + unit_uuid_set = {str(u) for u in unit_uuids} + affected_obs = await conn.fetch( + f""" + SELECT id, source_memory_ids FROM {fq_table("memory_units")} + WHERE bank_id = $1 + AND fact_type = 'observation' + AND source_memory_ids && $2::uuid[] + """, + bank_id, + unit_uuids, + ) + if affected_obs: + obs_ids = [obs["id"] for obs in affected_obs] + + seen: set[str] = set() + other_source_uuids: list[uuid_module.UUID] = [] + for obs in affected_obs: + for src_id in obs["source_memory_ids"] or []: + src_str = str(src_id) + if src_str not in unit_uuid_set and src_str not in seen: + other_source_uuids.append(src_id) + seen.add(src_str) + + await conn.execute( + f"DELETE FROM {fq_table('memory_units')} WHERE id = ANY($1::uuid[])", + obs_ids, + ) + await conn.execute( + f""" + UPDATE {fq_table("memory_units")} + SET consolidated_at = NULL + WHERE id = ANY($1::uuid[]) + AND fact_type IN ('experience', 'world') + """, + unit_uuids, + ) + if other_source_uuids: + await conn.execute( + f""" + UPDATE {fq_table("memory_units")} + SET consolidated_at = NULL + WHERE id = ANY($1::uuid[]) + AND fact_type IN ('experience', 'world') + """, + other_source_uuids, + ) + invalidated_obs = len(obs_ids) + logger.info( + f"[OBSERVATIONS] Deleted {invalidated_obs} observations, reset " + f"{len(unit_ids)} document source memories and " + f"{len(other_source_uuids)} co-source memories for re-consolidation " + f"after document update on '{document_id}' in bank {bank_id}" + ) + + if invalidated_obs > 0: + await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context) + + return True + async def delete_memory_unit( self, unit_id: str, diff --git a/hindsight-api/tests/test_observation_invalidation.py b/hindsight-api/tests/test_observation_invalidation.py index 97511ddb..de21f4b2 100644 --- a/hindsight-api/tests/test_observation_invalidation.py +++ b/hindsight-api/tests/test_observation_invalidation.py @@ -455,3 +455,281 @@ class TestClearObservationsForMemory: assert await _get_consolidated_at(conn, m2) is None await memory.delete_bank(bank_id, request_context=request_context) + + +# --------------------------------------------------------------------------- +# Tests: update_document +# --------------------------------------------------------------------------- + + +async def _insert_document_with_memories( + conn, bank_id: str, doc_id: str, memories: list[tuple[str, str]] +) -> list[uuid.UUID]: + """Insert a document and attach memory units to it. Returns list of memory UUIDs.""" + await conn.execute( + """ + INSERT INTO documents (id, bank_id, original_text, content_hash, created_at, updated_at) + VALUES ($1, $2, 'some doc', 'hash123', NOW(), NOW()) + """, + doc_id, + bank_id, + ) + mem_ids = [] + for text, fact_type in memories: + mem_id = uuid.uuid4() + await conn.execute( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, event_date, document_id, created_at, updated_at, consolidated_at) + VALUES ($1, $2, $3, $4, NOW(), $5, NOW(), NOW(), NOW()) + """, + mem_id, + bank_id, + text, + fact_type, + doc_id, + ) + mem_ids.append(mem_id) + return mem_ids + + +class TestUpdateDocumentTagsObservationCleanup: + + @pytest.mark.asyncio + async def test_update_tags_returns_updated_document( + self, memory: MemoryEngine, request_context: RequestContext + ): + """update_document returns the updated document with new tags.""" + bank_id = f"test-tag-update-basic-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + doc_id = f"doc-{uuid.uuid4().hex[:8]}" + await _insert_document_with_memories(conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]) + + result = await memory.update_document( + doc_id, bank_id, tags=["new-tag"], request_context=request_context + ) + + assert result is True + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_update_tags_returns_none_for_missing_document( + self, memory: MemoryEngine, request_context: RequestContext + ): + """update_document returns False when document does not exist.""" + bank_id = f"test-tag-update-missing-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + result = await memory.update_document( + "nonexistent-doc", bank_id, tags=["tag"], request_context=request_context + ) + + assert result is False + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_update_tags_propagates_to_memory_units( + self, memory: MemoryEngine, request_context: RequestContext + ): + """Changing document tags also updates all associated memory unit tags.""" + bank_id = f"test-tag-update-propagate-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + doc_id = f"doc-{uuid.uuid4().hex[:8]}" + mem_ids = await _insert_document_with_memories( + conn, bank_id, doc_id, [("Alice loves hiking.", "experience"), ("Alice hikes weekly.", "world")] + ) + + with patch.object(memory, "submit_async_consolidation", new=AsyncMock()): + await memory.update_document( + doc_id, bank_id, tags=["new-tag"], request_context=request_context + ) + + async with pool.acquire() as conn: + for mem_id in mem_ids: + tags = await conn.fetchval( + "SELECT tags FROM memory_units WHERE id = $1", mem_id + ) + assert list(tags) == ["new-tag"], f"Memory unit {mem_id} should have updated tags" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_update_tags_invalidates_observations( + self, memory: MemoryEngine, request_context: RequestContext + ): + """Observations referencing the document's memory units are deleted on tag change.""" + bank_id = f"test-tag-update-obs-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + doc_id = f"doc-{uuid.uuid4().hex[:8]}" + mem_ids = await _insert_document_with_memories( + conn, bank_id, doc_id, [("Alice loves hiking.", "experience")] + ) + obs_id = await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids) + + with patch.object(memory, "submit_async_consolidation", new=AsyncMock()): + await memory.update_document( + doc_id, bank_id, tags=["new-tag"], request_context=request_context + ) + + async with pool.acquire() as conn: + obs_ids = await _get_observation_ids(conn, bank_id) + assert str(obs_id) not in obs_ids, "Observation should have been invalidated" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_update_tags_resets_consolidated_at_on_affected_units( + self, memory: MemoryEngine, request_context: RequestContext + ): + """Affected memory units get consolidated_at reset for re-consolidation under new tags.""" + bank_id = f"test-tag-update-reset-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + doc_id = f"doc-{uuid.uuid4().hex[:8]}" + mem_ids = await _insert_document_with_memories( + conn, bank_id, doc_id, [("Alice loves hiking.", "experience")] + ) + obs_id = await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids) + + # Verify memory starts consolidated + assert await _get_consolidated_at(conn, mem_ids[0]) is not None + + with patch.object(memory, "submit_async_consolidation", new=AsyncMock()): + await memory.update_document( + doc_id, bank_id, tags=["new-tag"], request_context=request_context + ) + + async with pool.acquire() as conn: + consolidated_at = await _get_consolidated_at(conn, mem_ids[0]) + assert consolidated_at is None, "Memory unit should be reset for re-consolidation" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_update_tags_triggers_consolidation_when_observations_invalidated( + self, memory: MemoryEngine, request_context: RequestContext + ): + """submit_async_consolidation is called when observations are invalidated.""" + bank_id = f"test-tag-update-cons-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + doc_id = f"doc-{uuid.uuid4().hex[:8]}" + mem_ids = await _insert_document_with_memories( + conn, bank_id, doc_id, [("Alice loves hiking.", "experience")] + ) + await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids) + + with patch.object(memory, "submit_async_consolidation", new=AsyncMock()) as mock_consolidate: + await memory.update_document( + doc_id, bank_id, tags=["new-tag"], request_context=request_context + ) + mock_consolidate.assert_awaited_once() + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_update_tags_no_consolidation_when_no_observations( + self, memory: MemoryEngine, request_context: RequestContext + ): + """submit_async_consolidation is NOT called when no observations are invalidated.""" + bank_id = f"test-tag-update-nocons-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + doc_id = f"doc-{uuid.uuid4().hex[:8]}" + await _insert_document_with_memories( + conn, bank_id, doc_id, [("Alice loves hiking.", "experience")] + ) + # No observations inserted + + with patch.object(memory, "submit_async_consolidation", new=AsyncMock()) as mock_consolidate: + await memory.update_document( + doc_id, bank_id, tags=["new-tag"], request_context=request_context + ) + mock_consolidate.assert_not_awaited() + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_update_tags_resets_co_source_memories_from_other_documents( + self, memory: MemoryEngine, request_context: RequestContext + ): + """Co-source memories from other documents that shared an invalidated observation are also reset.""" + bank_id = f"test-tag-update-cosource-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + doc_id = f"doc-{uuid.uuid4().hex[:8]}" + doc_mem_ids = await _insert_document_with_memories( + conn, bank_id, doc_id, [("Alice loves hiking.", "experience")] + ) + # Unrelated memory from another document — co-sourced in the same observation + other_mem = await _insert_memory(conn, bank_id, "Alice also rock-climbs.") + obs_id = await _insert_observation( + conn, bank_id, "Alice loves outdoor activities.", doc_mem_ids + [other_mem] + ) + + # Verify other_mem starts consolidated + assert await _get_consolidated_at(conn, other_mem) is not None + + with patch.object(memory, "submit_async_consolidation", new=AsyncMock()): + await memory.update_document( + doc_id, bank_id, tags=["new-tag"], request_context=request_context + ) + + async with pool.acquire() as conn: + obs_ids = await _get_observation_ids(conn, bank_id) + assert str(obs_id) not in obs_ids, "Observation should have been invalidated" + + # other_mem (co-source from another document) must also be reset + consolidated_at = await _get_consolidated_at(conn, other_mem) + assert consolidated_at is None, "Co-source memory from other document should be reset" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_update_tags_does_not_affect_unrelated_observations( + self, memory: MemoryEngine, request_context: RequestContext + ): + """Observations referencing memories from a different document are not affected.""" + bank_id = f"test-tag-update-unrelated-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + doc_id = f"doc-{uuid.uuid4().hex[:8]}" + mem_ids = await _insert_document_with_memories( + conn, bank_id, doc_id, [("Alice loves hiking.", "experience")] + ) + # Unrelated memory not in the document + unrelated = await _insert_memory(conn, bank_id, "Bob likes cycling.") + unrelated_obs_id = await _insert_observation( + conn, bank_id, "Bob is a cyclist.", [unrelated] + ) + + with patch.object(memory, "submit_async_consolidation", new=AsyncMock()): + await memory.update_document( + doc_id, bank_id, tags=["new-tag"], request_context=request_context + ) + + async with pool.acquire() as conn: + obs_ids = await _get_observation_ids(conn, bank_id) + assert str(unrelated_obs_id) in obs_ids, "Unrelated observation should remain untouched" + + await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 977985db..b1d83e5f 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -1435,6 +1435,61 @@ paths: summary: Get document details tags: - Documents + patch: + description: |- + Update mutable fields on a document without re-processing its content. + + **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. + + At least one field must be provided. + operationId: update_document + parameters: + - explode: false + in: path + name: bank_id + required: true + schema: + title: Bank Id + type: string + style: simple + - explode: false + in: path + name: document_id + required: true + schema: + title: Document Id + type: string + style: simple + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDocumentRequest' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDocumentResponse' + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Update document + tags: + - Documents /v1/default/banks/{bank_id}/tags: get: description: "List all unique tags in a memory bank with usage counts. Supports\ @@ -4791,6 +4846,29 @@ components: required: - disposition title: UpdateDispositionRequest + UpdateDocumentRequest: + description: Request model for updating a document's mutable fields. + example: + tags: + - team-a + - team-b + properties: + tags: + items: + type: string + nullable: true + type: array + title: UpdateDocumentRequest + UpdateDocumentResponse: + description: Response model for update document endpoint. + example: + success: true + properties: + success: + default: true + title: Success + type: boolean + title: UpdateDocumentResponse UpdateMentalModelRequest: description: Request model for updating a mental model. example: diff --git a/hindsight-clients/go/api_documents.go b/hindsight-clients/go/api_documents.go index 34f99170..16a8c328 100644 --- a/hindsight-clients/go/api_documents.go +++ b/hindsight-clients/go/api_documents.go @@ -591,3 +591,144 @@ func (a *DocumentsAPIService) ListDocumentsExecute(r ApiListDocumentsRequest) (* return localVarReturnValue, localVarHTTPResponse, nil } + +type ApiUpdateDocumentRequest struct { + ctx context.Context + ApiService *DocumentsAPIService + bankId string + documentId string + updateDocumentRequest *UpdateDocumentRequest + authorization *string +} + +func (r ApiUpdateDocumentRequest) UpdateDocumentRequest(updateDocumentRequest UpdateDocumentRequest) ApiUpdateDocumentRequest { + r.updateDocumentRequest = &updateDocumentRequest + return r +} + +func (r ApiUpdateDocumentRequest) Authorization(authorization string) ApiUpdateDocumentRequest { + r.authorization = &authorization + return r +} + +func (r ApiUpdateDocumentRequest) Execute() (*UpdateDocumentResponse, *http.Response, error) { + return r.ApiService.UpdateDocumentExecute(r) +} + +/* +UpdateDocument Update document + +Update mutable fields on a document without re-processing its content. + +**Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. + +At least one field must be provided. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param bankId + @param documentId + @return ApiUpdateDocumentRequest +*/ +func (a *DocumentsAPIService) UpdateDocument(ctx context.Context, bankId string, documentId string) ApiUpdateDocumentRequest { + return ApiUpdateDocumentRequest{ + ApiService: a, + ctx: ctx, + bankId: bankId, + documentId: documentId, + } +} + +// Execute executes the request +// @return UpdateDocumentResponse +func (a *DocumentsAPIService) UpdateDocumentExecute(r ApiUpdateDocumentRequest) (*UpdateDocumentResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPatch + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *UpdateDocumentResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DocumentsAPIService.UpdateDocument") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v1/default/banks/{bank_id}/documents/{document_id}" + localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"document_id"+"}", url.PathEscape(parameterValueToString(r.documentId, "documentId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.updateDocumentRequest == nil { + return localVarReturnValue, nil, reportError("updateDocumentRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + // body params + localVarPostBody = r.updateDocumentRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/hindsight-clients/go/model_update_document_request.go b/hindsight-clients/go/model_update_document_request.go new file mode 100644 index 00000000..5bb5be94 --- /dev/null +++ b/hindsight-clients/go/model_update_document_request.go @@ -0,0 +1,127 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.4.16 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" +) + +// checks if the UpdateDocumentRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UpdateDocumentRequest{} + +// UpdateDocumentRequest Request model for updating a document's mutable fields. +type UpdateDocumentRequest struct { + Tags []string `json:"tags,omitempty"` +} + +// NewUpdateDocumentRequest instantiates a new UpdateDocumentRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUpdateDocumentRequest() *UpdateDocumentRequest { + this := UpdateDocumentRequest{} + return &this +} + +// NewUpdateDocumentRequestWithDefaults instantiates a new UpdateDocumentRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUpdateDocumentRequestWithDefaults() *UpdateDocumentRequest { + this := UpdateDocumentRequest{} + return &this +} + +// GetTags returns the Tags field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateDocumentRequest) GetTags() []string { + if o == nil { + var ret []string + return ret + } + return o.Tags +} + +// GetTagsOk returns a tuple with the Tags field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateDocumentRequest) GetTagsOk() ([]string, bool) { + if o == nil || IsNil(o.Tags) { + return nil, false + } + return o.Tags, true +} + +// HasTags returns a boolean if a field has been set. +func (o *UpdateDocumentRequest) HasTags() bool { + if o != nil && !IsNil(o.Tags) { + return true + } + + return false +} + +// SetTags gets a reference to the given []string and assigns it to the Tags field. +func (o *UpdateDocumentRequest) SetTags(v []string) { + o.Tags = v +} + +func (o UpdateDocumentRequest) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UpdateDocumentRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if o.Tags != nil { + toSerialize["tags"] = o.Tags + } + return toSerialize, nil +} + +type NullableUpdateDocumentRequest struct { + value *UpdateDocumentRequest + isSet bool +} + +func (v NullableUpdateDocumentRequest) Get() *UpdateDocumentRequest { + return v.value +} + +func (v *NullableUpdateDocumentRequest) Set(val *UpdateDocumentRequest) { + v.value = val + v.isSet = true +} + +func (v NullableUpdateDocumentRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableUpdateDocumentRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUpdateDocumentRequest(val *UpdateDocumentRequest) *NullableUpdateDocumentRequest { + return &NullableUpdateDocumentRequest{value: val, isSet: true} +} + +func (v NullableUpdateDocumentRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUpdateDocumentRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_update_document_response.go b/hindsight-clients/go/model_update_document_response.go new file mode 100644 index 00000000..55c11a45 --- /dev/null +++ b/hindsight-clients/go/model_update_document_response.go @@ -0,0 +1,130 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.4.16 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" +) + +// checks if the UpdateDocumentResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UpdateDocumentResponse{} + +// UpdateDocumentResponse Response model for update document endpoint. +type UpdateDocumentResponse struct { + Success *bool `json:"success,omitempty"` +} + +// NewUpdateDocumentResponse instantiates a new UpdateDocumentResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUpdateDocumentResponse() *UpdateDocumentResponse { + this := UpdateDocumentResponse{} + var success bool = true + this.Success = &success + return &this +} + +// NewUpdateDocumentResponseWithDefaults instantiates a new UpdateDocumentResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUpdateDocumentResponseWithDefaults() *UpdateDocumentResponse { + this := UpdateDocumentResponse{} + var success bool = true + this.Success = &success + return &this +} + +// GetSuccess returns the Success field value if set, zero value otherwise. +func (o *UpdateDocumentResponse) GetSuccess() bool { + if o == nil || IsNil(o.Success) { + var ret bool + return ret + } + return *o.Success +} + +// GetSuccessOk returns a tuple with the Success field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateDocumentResponse) GetSuccessOk() (*bool, bool) { + if o == nil || IsNil(o.Success) { + return nil, false + } + return o.Success, true +} + +// HasSuccess returns a boolean if a field has been set. +func (o *UpdateDocumentResponse) HasSuccess() bool { + if o != nil && !IsNil(o.Success) { + return true + } + + return false +} + +// SetSuccess gets a reference to the given bool and assigns it to the Success field. +func (o *UpdateDocumentResponse) SetSuccess(v bool) { + o.Success = &v +} + +func (o UpdateDocumentResponse) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UpdateDocumentResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Success) { + toSerialize["success"] = o.Success + } + return toSerialize, nil +} + +type NullableUpdateDocumentResponse struct { + value *UpdateDocumentResponse + isSet bool +} + +func (v NullableUpdateDocumentResponse) Get() *UpdateDocumentResponse { + return v.value +} + +func (v *NullableUpdateDocumentResponse) Set(val *UpdateDocumentResponse) { + v.value = val + v.isSet = true +} + +func (v NullableUpdateDocumentResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableUpdateDocumentResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUpdateDocumentResponse(val *UpdateDocumentResponse) *NullableUpdateDocumentResponse { + return &NullableUpdateDocumentResponse{value: val, isSet: true} +} + +func (v NullableUpdateDocumentResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUpdateDocumentResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index 1afdb67d..dafa1cb0 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -88,6 +88,8 @@ hindsight_client_api/models/token_usage.py hindsight_client_api/models/tool_calls_include_options.py hindsight_client_api/models/update_directive_request.py hindsight_client_api/models/update_disposition_request.py +hindsight_client_api/models/update_document_request.py +hindsight_client_api/models/update_document_response.py hindsight_client_api/models/update_mental_model_request.py hindsight_client_api/models/update_webhook_request.py hindsight_client_api/models/validation_error.py diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index f1cf6197..be624d22 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -113,6 +113,8 @@ from hindsight_client_api.models.token_usage import TokenUsage from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest +from hindsight_client_api.models.update_document_request import UpdateDocumentRequest +from hindsight_client_api.models.update_document_response import UpdateDocumentResponse from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest from hindsight_client_api.models.validation_error import ValidationError 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 8afc4e6e..8b739e8a 100644 --- a/hindsight-clients/python/hindsight_client_api/api/documents_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/documents_api.py @@ -23,6 +23,8 @@ 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 from hindsight_client_api.models.list_documents_response import ListDocumentsResponse +from hindsight_client_api.models.update_document_request import UpdateDocumentRequest +from hindsight_client_api.models.update_document_response import UpdateDocumentResponse from hindsight_client_api.api_client import ApiClient, RequestSerialized from hindsight_client_api.api_response import ApiResponse @@ -1268,3 +1270,324 @@ class DocumentsApi: ) + + + @validate_call + async def update_document( + self, + bank_id: StrictStr, + document_id: StrictStr, + update_document_request: UpdateDocumentRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> UpdateDocumentResponse: + """Update document + + Update mutable fields on a document without re-processing its content. **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. At least one field must be provided. + + :param bank_id: (required) + :type bank_id: str + :param document_id: (required) + :type document_id: str + :param update_document_request: (required) + :type update_document_request: UpdateDocumentRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._update_document_serialize( + bank_id=bank_id, + document_id=document_id, + update_document_request=update_document_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "UpdateDocumentResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def update_document_with_http_info( + self, + bank_id: StrictStr, + document_id: StrictStr, + update_document_request: UpdateDocumentRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[UpdateDocumentResponse]: + """Update document + + Update mutable fields on a document without re-processing its content. **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. At least one field must be provided. + + :param bank_id: (required) + :type bank_id: str + :param document_id: (required) + :type document_id: str + :param update_document_request: (required) + :type update_document_request: UpdateDocumentRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._update_document_serialize( + bank_id=bank_id, + document_id=document_id, + update_document_request=update_document_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "UpdateDocumentResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def update_document_without_preload_content( + self, + bank_id: StrictStr, + document_id: StrictStr, + update_document_request: UpdateDocumentRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Update document + + Update mutable fields on a document without re-processing its content. **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. At least one field must be provided. + + :param bank_id: (required) + :type bank_id: str + :param document_id: (required) + :type document_id: str + :param update_document_request: (required) + :type update_document_request: UpdateDocumentRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._update_document_serialize( + bank_id=bank_id, + document_id=document_id, + update_document_request=update_document_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "UpdateDocumentResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _update_document_serialize( + self, + bank_id, + document_id, + update_document_request, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + if document_id is not None: + _path_params['document_id'] = document_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + if update_document_request is not None: + _body_params = update_document_request + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='PATCH', + resource_path='/v1/default/banks/{bank_id}/documents/{document_id}', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index 4b334910..69fde0be 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -87,6 +87,8 @@ from hindsight_client_api.models.token_usage import TokenUsage from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest +from hindsight_client_api.models.update_document_request import UpdateDocumentRequest +from hindsight_client_api.models.update_document_response import UpdateDocumentResponse from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest from hindsight_client_api.models.validation_error import ValidationError diff --git a/hindsight-clients/python/hindsight_client_api/models/update_document_request.py b/hindsight-clients/python/hindsight_client_api/models/update_document_request.py new file mode 100644 index 00000000..dd0410a5 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/update_document_request.py @@ -0,0 +1,92 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.4.16 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self + +class UpdateDocumentRequest(BaseModel): + """ + Request model for updating a document's mutable fields. + """ # noqa: E501 + tags: Optional[List[StrictStr]] = None + __properties: ClassVar[List[str]] = ["tags"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of UpdateDocumentRequest from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # set to None if tags (nullable) is None + # and model_fields_set contains the field + if self.tags is None and "tags" in self.model_fields_set: + _dict['tags'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of UpdateDocumentRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "tags": obj.get("tags") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/update_document_response.py b/hindsight-clients/python/hindsight_client_api/models/update_document_response.py new file mode 100644 index 00000000..7e8861dc --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/update_document_response.py @@ -0,0 +1,87 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.4.16 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, StrictBool +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self + +class UpdateDocumentResponse(BaseModel): + """ + Response model for update document endpoint. + """ # noqa: E501 + success: Optional[StrictBool] = True + __properties: ClassVar[List[str]] = ["success"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of UpdateDocumentResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of UpdateDocumentResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "success": obj.get("success") if obj.get("success") is not None else True + }) + return _obj + + diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index 1b153eef..47ad9503 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -161,6 +161,9 @@ import type { UpdateDirectiveData, UpdateDirectiveErrors, UpdateDirectiveResponses, + UpdateDocumentData, + UpdateDocumentErrors, + UpdateDocumentResponses, UpdateMentalModelData, UpdateMentalModelErrors, UpdateMentalModelResponses, @@ -679,6 +682,31 @@ export const getDocument = ( ThrowOnError >({ url: "/v1/default/banks/{bank_id}/documents/{document_id}", ...options }); +/** + * Update document + * + * Update mutable fields on a document without re-processing its content. + * + * **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. + * + * At least one field must be provided. + */ +export const updateDocument = ( + options: Options, +) => + (options.client ?? client).patch< + UpdateDocumentResponses, + UpdateDocumentErrors, + ThrowOnError + >({ + url: "/v1/default/banks/{bank_id}/documents/{document_id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + /** * List tags * diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index b6723f7c..a44b9419 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -2081,6 +2081,32 @@ export type UpdateDispositionRequest = { disposition: DispositionTraits; }; +/** + * UpdateDocumentRequest + * + * Request model for updating a document's mutable fields. + */ +export type UpdateDocumentRequest = { + /** + * Tags + * + * New tags for the document and its memory units. Triggers observation invalidation and re-consolidation. + */ + tags?: Array | null; +}; + +/** + * UpdateDocumentResponse + * + * Response model for update document endpoint. + */ +export type UpdateDocumentResponse = { + /** + * Success + */ + success?: boolean; +}; + /** * UpdateMentalModelRequest * @@ -3535,6 +3561,48 @@ export type GetDocumentResponses = { export type GetDocumentResponse = GetDocumentResponses[keyof GetDocumentResponses]; +export type UpdateDocumentData = { + body: UpdateDocumentRequest; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Document Id + */ + document_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/documents/{document_id}"; +}; + +export type UpdateDocumentErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateDocumentError = + UpdateDocumentErrors[keyof UpdateDocumentErrors]; + +export type UpdateDocumentResponses = { + /** + * Successful Response + */ + 200: UpdateDocumentResponse; +}; + +export type UpdateDocumentResponse2 = + UpdateDocumentResponses[keyof UpdateDocumentResponses]; + export type ListTagsData = { body?: never; headers?: { diff --git a/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts b/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts index 6f3e9fcd..7df4a992 100644 --- a/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts +++ b/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, lowLevelClient, DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, @@ -26,6 +26,42 @@ export async function GET( } } +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ documentId: string }> } +) { + try { + const { documentId } = await params; + const searchParams = request.nextUrl.searchParams; + const bankId = searchParams.get("bank_id"); + + if (!bankId) { + return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); + } + + const body = await request.json(); + const response = await fetch( + `${DATAPLANE_URL}/v1/default/banks/${bankId}/documents/${documentId}`, + { + method: "PATCH", + headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(body), + } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: response.statusText })); + return NextResponse.json(error, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error updating document tags:", error); + return NextResponse.json({ error: "Failed to update document tags" }, { status: 500 }); + } +} + export async function DELETE( request: NextRequest, { params }: { params: Promise<{ documentId: string }> } diff --git a/hindsight-control-plane/src/components/documents-view.tsx b/hindsight-control-plane/src/components/documents-view.tsx index 5cb3ec00..68fbc492 100644 --- a/hindsight-control-plane/src/components/documents-view.tsx +++ b/hindsight-control-plane/src/components/documents-view.tsx @@ -23,7 +23,16 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { X, Trash2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; +import { + X, + Trash2, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Pencil, + Check, +} from "lucide-react"; const ITEMS_PER_PAGE = 50; @@ -44,6 +53,11 @@ export function DocumentsView() { const [loadingDocument, setLoadingDocument] = useState(false); const [deletingDocumentId, setDeletingDocumentId] = useState(null); + // Tag editing state + const [editingTags, setEditingTags] = useState(false); + const [tagInput, setTagInput] = useState(""); + const [savingTags, setSavingTags] = useState(false); + // Delete confirmation dialog state const [documentToDelete, setDocumentToDelete] = useState<{ id: string; @@ -85,6 +99,8 @@ export function DocumentsView() { setLoadingDocument(true); setSelectedDocument({ id: documentId }); // Set placeholder to show loading + setEditingTags(false); + setTagInput(""); try { const doc: any = await client.getDocument(documentId, currentBank); @@ -133,6 +149,41 @@ export function DocumentsView() { setDocumentToDelete({ id: documentId, memoryCount }); }; + const startEditTags = () => { + setTagInput((selectedDocument?.tags ?? []).join(", ")); + setEditingTags(true); + }; + + const cancelEditTags = () => { + setEditingTags(false); + setTagInput(""); + }; + + const saveDocumentTags = async () => { + if (!currentBank || !selectedDocument) return; + + const newTags = tagInput + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + + setSavingTags(true); + try { + await client.updateDocument(selectedDocument.id, currentBank, newTags); + setSelectedDocument({ ...selectedDocument, tags: newTags }); + // Update tags in the documents list too + setDocuments((prev) => + prev.map((d) => (d.id === selectedDocument.id ? { ...d, tags: newTags } : d)) + ); + setEditingTags(false); + setTagInput(""); + } catch (error) { + console.error("Error updating document tags:", error); + } finally { + setSavingTags(false); + } + }; + // Auto-load documents when component mounts or bank changes useEffect(() => { if (currentBank) { @@ -417,11 +468,64 @@ export function DocumentsView() { )} {/* Tags */} - {selectedDocument.tags && selectedDocument.tags.length > 0 && ( -
-
- Tags +
+
+
Tags
+ {!editingTags && ( + + )} +
+ {editingTags ? ( +
+ setTagInput(e.target.value)} + placeholder="tag1, tag2, tag3" + className="text-sm h-8" + onKeyDown={(e) => { + if (e.key === "Enter") saveDocumentTags(); + if (e.key === "Escape") cancelEditTags(); + }} + autoFocus + /> +

+ Comma-separated. Leave empty to remove all tags. +

+
+ + +
+ ) : selectedDocument.tags && selectedDocument.tags.length > 0 ? (
{selectedDocument.tags.map((tag: string, i: number) => ( ))}
-
- )} + ) : ( +
No tags
+ )} +
{/* Delete Button */}
diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index 2dac161a..b8b21d75 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -336,6 +336,20 @@ export class ControlPlaneClient { return this.fetchApi(`/api/documents/${documentId}?bank_id=${bankId}`); } + /** + * Update tags on a document and its associated memory units + */ + async updateDocument(documentId: string, bankId: string, tags: string[]) { + return this.fetchApi<{ success: boolean }>( + `/api/documents/${encodeURIComponent(documentId)}?bank_id=${bankId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tags }), + } + ); + } + /** * Delete document and all its associated memory units */ diff --git a/hindsight-control-plane/tsconfig.json b/hindsight-control-plane/tsconfig.json index fda5a815..84516a6c 100644 --- a/hindsight-control-plane/tsconfig.json +++ b/hindsight-control-plane/tsconfig.json @@ -50,7 +50,9 @@ ".next-64080/types/**/*.ts", ".next-64080/dev/types/**/*.ts", ".next-50432/types/**/*.ts", - ".next-50432/dev/types/**/*.ts" + ".next-50432/dev/types/**/*.ts", + ".next-54840/types/**/*.ts", + ".next-54840/dev/types/**/*.ts" ], "exclude": [ "node_modules" diff --git a/hindsight-docs/docs/developer/api/documents.mdx b/hindsight-docs/docs/developer/api/documents.mdx index bd3ea29c..cbbc5a13 100644 --- a/hindsight-docs/docs/developer/api/documents.mdx +++ b/hindsight-docs/docs/developer/api/documents.mdx @@ -107,6 +107,34 @@ hindsight document get my-bank meeting-2024-03-15 +## Update Document + +Update mutable fields on an existing document without re-processing the content. Currently supports updating `tags`. + + + + + + + + + + +```bash +# Replace tags with new values +hindsight document update-tags my-bank meeting-2024-03-15 --tags team-a --tags team-b + +# Remove all tags +hindsight document update-tags my-bank meeting-2024-03-15 +``` + + + + +:::info Observations are re-consolidated +When tags change, any consolidated observations derived from the document's memories are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. +::: + ## Delete Document Remove a document and all its associated memories: diff --git a/hindsight-docs/examples/api/documents.mjs b/hindsight-docs/examples/api/documents.mjs index 2892d473..d3ac35da 100644 --- a/hindsight-docs/examples/api/documents.mjs +++ b/hindsight-docs/examples/api/documents.mjs @@ -103,6 +103,29 @@ console.log(`Created: ${doc.created_at}`); // [/docs:document-get] +// [docs:document-update] +// Fix tags on a document retained with the wrong scope +const { data: updateResult, error: updateError } = await sdk.updateDocument({ + client: apiClient, + path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15-section-1' }, + body: { tags: ['team-a', 'team-b'] } +}); + +if (updateError) { + throw new Error(`Failed to update tags: ${JSON.stringify(updateError)}`); +} + +console.log(`Updated: ${updateResult.success}`); + +// Remove all tags (make document visible everywhere) +await sdk.updateDocument({ + client: apiClient, + path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15-section-1' }, + body: { tags: [] } +}); +// [/docs:document-update] + + // [docs:document-delete] // Delete document and all its memories const { data: deleteResult } = await sdk.deleteDocument({ diff --git a/hindsight-docs/examples/api/documents.py b/hindsight-docs/examples/api/documents.py index 2dc12a29..a2ae3a90 100644 --- a/hindsight-docs/examples/api/documents.py +++ b/hindsight-docs/examples/api/documents.py @@ -120,6 +120,35 @@ asyncio.run(get_document_example()) # [/docs:document-get] +# [docs:document-update] +from hindsight_client_api import ApiClient, Configuration +from hindsight_client_api.api import DocumentsApi +from hindsight_client_api.models import UpdateDocumentRequest + +async def update_document_example(): + config = Configuration(host="http://localhost:8888") + api_client = ApiClient(config) + api = DocumentsApi(api_client) + + # Fix tags on a document retained with the wrong scope + result = await api.update_document( + bank_id="my-bank", + document_id="meeting-2024-03-15", + update_document_request=UpdateDocumentRequest(tags=["team-a", "team-b"]), + ) + print(f"Updated: {result.success}") + + # Remove all tags (make document visible everywhere) + await api.update_document( + bank_id="my-bank", + document_id="meeting-2024-03-15", + update_document_request=UpdateDocumentRequest(tags=[]), + ) + +asyncio.run(update_document_example()) +# [/docs:document-update] + + # [docs:document-delete] from hindsight_client_api import ApiClient, Configuration from hindsight_client_api.api import DocumentsApi diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index 013be0ba..779f1dab 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -2048,6 +2048,82 @@ } } }, + "patch": { + "tags": [ + "Documents" + ], + "summary": "Update document", + "description": "Update mutable fields on a document without re-processing its content.\n\n**Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.\n\nAt least one field must be provided.", + "operationId": "update_document", + "parameters": [ + { + "name": "bank_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Bank Id" + } + }, + { + "name": "document_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Document Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDocumentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDocumentResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, "delete": { "tags": [ "Documents" @@ -7503,6 +7579,46 @@ "title": "UpdateDispositionRequest", "description": "Request model for updating disposition traits." }, + "UpdateDocumentRequest": { + "properties": { + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags", + "description": "New tags for the document and its memory units. Triggers observation invalidation and re-consolidation." + } + }, + "type": "object", + "title": "UpdateDocumentRequest", + "description": "Request model for updating a document's mutable fields.", + "example": { + "tags": [ + "team-a", + "team-b" + ] + } + }, + "UpdateDocumentResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "default": true + } + }, + "type": "object", + "title": "UpdateDocumentResponse", + "description": "Response model for update document endpoint." + }, "UpdateMentalModelRequest": { "properties": { "name": {