From 94ace5e3f5dc324cd14ce298296da7416cb112b7 Mon Sep 17 00:00:00 2001 From: ajspig Date: Mon, 17 Aug 2026 17:43:05 -0400 Subject: [PATCH] feat: improving single conclusion retrieval --- docs/v3/openapi.json | 39 -------------- sdks/python/src/honcho/aio.py | 20 +++++-- sdks/python/src/honcho/conclusions.py | 20 +++++-- sdks/typescript/__tests__/conclusions.test.ts | 4 +- sdks/typescript/src/conclusions.ts | 18 +++++-- src/routers/conclusions.py | 22 -------- tests/routes/test_conclusions.py | 54 +++++++++++-------- 7 files changed, 76 insertions(+), 101 deletions(-) diff --git a/docs/v3/openapi.json b/docs/v3/openapi.json index 56fafac4..c3bc4fff 100644 --- a/docs/v3/openapi.json +++ b/docs/v3/openapi.json @@ -2423,45 +2423,6 @@ } }, "/v3/workspaces/{workspace_id}/conclusions/{conclusion_id}": { - "get": { - "tags": ["conclusions"], - "summary": "Get Conclusion", - "description": "Get a single Conclusion by ID.", - "operationId": "get_conclusion_v3_workspaces__workspace_id__conclusions__conclusion_id__get", - "security": [{ "HTTPBearer": [] }], - "parameters": [ - { - "name": "workspace_id", - "in": "path", - "required": true, - "schema": { "type": "string", "title": "Workspace Id" } - }, - { - "name": "conclusion_id", - "in": "path", - "required": true, - "schema": { "type": "string", "title": "Conclusion Id" } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Conclusion" } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/HTTPValidationError" } - } - } - } - } - }, "delete": { "tags": ["conclusions"], "summary": "Delete Conclusion", diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index c51d0900..8fae9301 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -52,7 +52,7 @@ from .conclusions import ( Conclusion, _reject_reserved_filter_keys, ) -from .http import routes +from .http import NotFoundError, routes from .message import Message from .mixins import AsyncMetadataConfigMixin from .pagination import AsyncPage @@ -1603,15 +1603,25 @@ class ConclusionScopeAio: async def get(self, conclusion_id: str) -> Conclusion: """Get a single conclusion by ID asynchronously. + Equivalent to ``list`` with an ``id`` filter. + Returns: The Conclusion object, including its attribution fields (`source_ids`, `times_derived`) + + Raises: + NotFoundError: If no conclusion with the given ID exists """ await self._scope._honcho._ensure_workspace_async() - data = await self._scope._honcho._async_http_client.get( - routes.conclusion(self._scope.workspace_id, conclusion_id) + data = await self._scope._honcho._async_http_client.post( + routes.conclusions_list(self._scope.workspace_id), + body={"filters": {"id": conclusion_id}}, + query={"page": 1, "size": 1}, ) - return Conclusion.from_api_response(ConclusionResponse.model_validate(data)) + items = data.get("items", []) + if not items: + raise NotFoundError("Conclusion not found") + return Conclusion.from_api_response(ConclusionResponse.model_validate(items[0])) async def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: """Get multiple conclusions by ID in a single call asynchronously. @@ -1662,7 +1672,7 @@ class ConclusionScopeAio: size: Number of results per page. Default: 50. reverse: If True, reverses the default newest-first ordering. - Sugar for ``list`` with a ``parent_id`` filter; an unknown + Equivalent to ``list`` with a ``parent_id`` filter; an unknown ``conclusion_id`` yields an empty page rather than an error. Returns: diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index d8735931..078689b3 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from .api_types import ConclusionLevel, ConclusionResponse, RepresentationResponse from .base import SessionBase -from .http import routes +from .http import NotFoundError, routes from .pagination import SyncPage from .utils import resolve_id @@ -332,18 +332,28 @@ class ConclusionScope: """ Get a single conclusion by ID. + Equivalent to ``list`` with an ``id`` filter. + Args: conclusion_id: The ID of the conclusion to retrieve Returns: The Conclusion object, including its attribution fields (`source_ids`, `times_derived`) + + Raises: + NotFoundError: If no conclusion with the given ID exists """ self._honcho._ensure_workspace() - data = self._honcho._http.get( - routes.conclusion(self.workspace_id, conclusion_id) + data = self._honcho._http.post( + routes.conclusions_list(self.workspace_id), + body={"filters": {"id": conclusion_id}}, + query={"page": 1, "size": 1}, ) - return Conclusion.from_api_response(ConclusionResponse.model_validate(data)) + items = data.get("items", []) + if not items: + raise NotFoundError("Conclusion not found") + return Conclusion.from_api_response(ConclusionResponse.model_validate(items[0])) def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: """ @@ -397,7 +407,7 @@ class ConclusionScope: size: Number of results per page. Default: 50. reverse: If True, reverses the default newest-first ordering. - Sugar for ``list`` with a ``parent_id`` filter; an unknown + Equivalent to ``list`` with a ``parent_id`` filter; an unknown ``conclusion_id`` yields an empty page rather than an error. Returns: diff --git a/sdks/typescript/__tests__/conclusions.test.ts b/sdks/typescript/__tests__/conclusions.test.ts index a2649315..c351dde9 100644 --- a/sdks/typescript/__tests__/conclusions.test.ts +++ b/sdks/typescript/__tests__/conclusions.test.ts @@ -334,10 +334,10 @@ describe('Conclusions', () => { }) // =========================================================================== - // Single Conclusion Retrieval (GET /conclusions/:id) + // Single Conclusion Retrieval (list with an id filter) // =========================================================================== - describe('GET /conclusions/:id (get)', () => { + describe('get (list with id filter)', () => { test('get returns conclusion with attribution fields', async () => { const peer = await client.peer('get-conclusion-peer', { metadata: {} }) const session = await client.session('get-conclusion-session', { metadata: {} }) diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts index 07c23e34..39fc39f3 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -1,5 +1,6 @@ import { API_VERSION } from './api-version' import type { HonchoHTTPClient } from './http/client' +import { NotFoundError } from './http/errors' import { Page } from './pagination' import type { Session } from './session' import type { @@ -208,10 +209,17 @@ export class ConclusionScope { } private async _get(conclusionId: string): Promise { - await this._ensureWorkspace() - return this._http.get( - `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/${conclusionId}` - ) + // Equivalent to list with an `id` filter; there is no single-GET endpoint. + const response = await this._list({ + filters: { id: conclusionId }, + page: 1, + size: 1, + }) + const item = response.items?.[0] + if (!item) { + throw new NotFoundError('Conclusion not found') + } + return item } private async _derived( @@ -222,7 +230,7 @@ export class ConclusionScope { reverse?: boolean } ): Promise> { - // Sugar for list with a parent_id filter; an unknown conclusionId + // Equivalent to list with a parent_id filter; an unknown conclusionId // yields an empty page rather than an error. return this._list({ filters: { parent_id: conclusionId }, diff --git a/src/routers/conclusions.py b/src/routers/conclusions.py index 19be3554..89d6207f 100644 --- a/src/routers/conclusions.py +++ b/src/routers/conclusions.py @@ -134,28 +134,6 @@ async def query_conclusions( return [schemas.Conclusion.model_validate(doc) for doc in documents] -@router.get( - "/{conclusion_id}", - response_model=schemas.Conclusion, -) -async def get_conclusion( - workspace_id: str = Path(...), - conclusion_id: str = Path(...), - db: AsyncSession = read_db, -) -> schemas.Conclusion: - """ - Get a single Conclusion by ID. - """ - documents = await crud.get_documents_by_ids( - db, - workspace_name=workspace_id, - document_ids=[conclusion_id], - ) - if not documents: - raise ResourceNotFoundException("Conclusion not found") - return schemas.Conclusion.model_validate(documents[0]) - - @router.delete( "/{conclusion_id}", status_code=204, diff --git a/tests/routes/test_conclusions.py b/tests/routes/test_conclusions.py index 940e74b9..534b1d99 100644 --- a/tests/routes/test_conclusions.py +++ b/tests/routes/test_conclusions.py @@ -667,13 +667,14 @@ class TestConclusionRoutes: assert "not found" in data["detail"].lower() @pytest.mark.asyncio - async def test_get_conclusion_success( + async def test_list_conclusion_by_id_success( self, client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer], ): - """Test getting a single conclusion by ID with attribution fields""" + """Test getting a single conclusion via list with an id filter, + including attribution fields""" test_workspace, test_peer = sample_data # Create another peer @@ -719,13 +720,16 @@ class TestConclusionRoutes: db_session.add(derived) await db_session.commit() - # Get conclusion by ID - response = client.get( - f"/v3/workspaces/{test_workspace.name}/conclusions/{derived.id}" + # Get conclusion via list with an id filter + response = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={"filters": {"id": derived.id}}, ) assert response.status_code == 200 - conclusion = response.json() + items = response.json()["items"] + assert len(items) == 1 + conclusion = items[0] assert conclusion["id"] == derived.id assert conclusion["content"] == "User is likely a night owl" assert conclusion["observer_id"] == test_peer.name @@ -739,7 +743,7 @@ class TestConclusionRoutes: assert "internal_metadata" not in conclusion @pytest.mark.asyncio - async def test_get_conclusion_legacy_source_ids( + async def test_list_conclusion_by_id_legacy_source_ids( self, client: TestClient, db_session: AsyncSession, @@ -775,39 +779,41 @@ class TestConclusionRoutes: db_session.add(doc) await db_session.commit() - response = client.get( - f"/v3/workspaces/{test_workspace.name}/conclusions/{doc.id}" + response = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={"filters": {"id": doc.id}}, ) assert response.status_code == 200 - conclusion = response.json() - assert conclusion["source_ids"] is None + items = response.json()["items"] + assert len(items) == 1 + assert items[0]["source_ids"] is None @pytest.mark.asyncio - async def test_get_conclusion_not_found( + async def test_list_conclusion_by_id_not_found( self, client: TestClient, sample_data: tuple[Workspace, Peer], ): - """Test getting a non-existent conclusion""" + """Listing with a non-existent id filter yields an empty page""" test_workspace, _test_peer = sample_data - response = client.get( - f"/v3/workspaces/{test_workspace.name}/conclusions/nonexistent_id" + response = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={"filters": {"id": "nonexistent_id"}}, ) - assert response.status_code == 404 - data = response.json() - assert "not found" in data["detail"].lower() + assert response.status_code == 200 + assert response.json()["items"] == [] @pytest.mark.asyncio - async def test_get_conclusion_soft_deleted( + async def test_list_conclusion_by_id_soft_deleted( self, client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer], ): - """Test that a soft-deleted conclusion returns 404""" + """Test that a soft-deleted conclusion is excluded from list results""" test_workspace, test_peer = sample_data # Create another peer @@ -837,10 +843,12 @@ class TestConclusionRoutes: ) assert delete_response.status_code == 204 - response = client.get( - f"/v3/workspaces/{test_workspace.name}/conclusions/{doc.id}" + response = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={"filters": {"id": doc.id}}, ) - assert response.status_code == 404 + assert response.status_code == 200 + assert response.json()["items"] == [] @pytest.mark.asyncio async def test_list_conclusions_includes_attribution_fields(