From 7aea397f3e99daaecd4b37b941c2e3c999fb3b18 Mon Sep 17 00:00:00 2001 From: ajspig Date: Tue, 18 Aug 2026 14:46:12 -0400 Subject: [PATCH] feat: restore GET /conclusions/{conclusion_id} --- docs/docs.json | 1 + .../endpoint/conclusions/get-conclusion.mdx | 3 ++ docs/v3/openapi.json | 39 ++++++++++++++ sdks/python/src/honcho/aio.py | 37 ++++--------- sdks/python/src/honcho/conclusions.py | 38 ++++++------- sdks/typescript/__tests__/conclusions.test.ts | 4 +- sdks/typescript/src/conclusions.ts | 33 +++++------- src/routers/conclusions.py | 20 +++++++ tests/routes/test_conclusions.py | 54 ++++++++----------- 9 files changed, 126 insertions(+), 103 deletions(-) create mode 100644 docs/v3/api-reference/endpoint/conclusions/get-conclusion.mdx diff --git a/docs/docs.json b/docs/docs.json index 36fef957..8afbcd77 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -224,6 +224,7 @@ "v3/api-reference/endpoint/conclusions/create-conclusions", "v3/api-reference/endpoint/conclusions/list-conclusions", "v3/api-reference/endpoint/conclusions/query-conclusions", + "v3/api-reference/endpoint/conclusions/get-conclusion", "v3/api-reference/endpoint/conclusions/delete-conclusion" ] }, diff --git a/docs/v3/api-reference/endpoint/conclusions/get-conclusion.mdx b/docs/v3/api-reference/endpoint/conclusions/get-conclusion.mdx new file mode 100644 index 00000000..a3ff0315 --- /dev/null +++ b/docs/v3/api-reference/endpoint/conclusions/get-conclusion.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v3/workspaces/{workspace_id}/conclusions/{conclusion_id} +--- diff --git a/docs/v3/openapi.json b/docs/v3/openapi.json index c3bc4fff..56fafac4 100644 --- a/docs/v3/openapi.json +++ b/docs/v3/openapi.json @@ -2423,6 +2423,45 @@ } }, "/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 76cdb6ce..ad88bd6c 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -52,8 +52,9 @@ from .conclusions import ( _SCOPE_RESERVED, Conclusion, _reject_reserved_filter_keys, + _require_scope, ) -from .http import NotFoundError, routes +from .http import routes from .message import Message from .mixins import AsyncMetadataConfigMixin from .pagination import AsyncPage @@ -1495,21 +1496,12 @@ class SessionAio(AsyncMetadataConfigMixin): return Message.from_api_response(MessageResponse.model_validate(data)) -async def _aget_conclusion( - honcho: "Honcho", - *, - filters: dict[str, Any], -) -> Conclusion: +async def _aget_conclusion(honcho: "Honcho", conclusion_id: str) -> Conclusion: await honcho._ensure_workspace_async() - data = await honcho._async_http_client.post( - routes.conclusions_list(honcho.workspace_id), - body={"filters": filters}, - query={"page": 1, "size": 1}, + data = await honcho._async_http_client.get( + routes.conclusion(honcho.workspace_id, conclusion_id) ) - items = data.get("items", []) - if not items: - raise NotFoundError("Conclusion not found") - return Conclusion.from_api_response(ConclusionResponse.model_validate(items[0])) + return Conclusion.from_api_response(ConclusionResponse.model_validate(data)) async def _aget_many_conclusions( @@ -1598,9 +1590,7 @@ class WorkspaceConclusionsAio: async def get(self, conclusion_id: str) -> Conclusion: """Get a single conclusion by ID, anywhere in the workspace.""" - return await _aget_conclusion( - self._workspace._honcho, filters={"id": conclusion_id} - ) + return await _aget_conclusion(self._workspace._honcho, conclusion_id) async def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: """Get multiple conclusions by ID. Missing IDs are omitted.""" @@ -1697,8 +1687,6 @@ 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`) @@ -1708,13 +1696,10 @@ class ConclusionScopeAio: observer/observed pair. Use ``honcho.aio.conclusions.get`` for a workspace-wide lookup. """ - return await _aget_conclusion( - self._scope._honcho, - filters={ - "id": conclusion_id, - "observer_id": self._scope.observer, - "observed_id": self._scope.observed, - }, + return _require_scope( + await _aget_conclusion(self._scope._honcho, conclusion_id), + self._scope.observer, + self._scope.observed, ) async def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index 23870c8e..f9c37b28 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -67,21 +67,18 @@ def _conclusion_from_item(item: Any) -> Conclusion: return Conclusion.from_api_response(ConclusionResponse.model_validate(item)) -def _get_conclusion( - honcho: "Honcho", - *, - filters: dict[str, Any], -) -> Conclusion: +def _get_conclusion(honcho: "Honcho", conclusion_id: str) -> Conclusion: honcho._ensure_workspace() - data = honcho._http.post( - routes.conclusions_list(honcho.workspace_id), - body={"filters": filters}, - query={"page": 1, "size": 1}, - ) - items = data.get("items", []) - if not items: + data = honcho._http.get(routes.conclusion(honcho.workspace_id, conclusion_id)) + return _conclusion_from_item(data) + + +def _require_scope( + conclusion: Conclusion, observer_id: str, observed_id: str +) -> Conclusion: + if conclusion.observer_id != observer_id or conclusion.observed_id != observed_id: raise NotFoundError("Conclusion not found") - return _conclusion_from_item(items[0]) + return conclusion def _get_many_conclusions( @@ -275,7 +272,7 @@ class WorkspaceConclusions: def get(self, conclusion_id: str) -> Conclusion: """Get a single conclusion by ID, anywhere in the workspace.""" - return _get_conclusion(self._honcho, filters={"id": conclusion_id}) + return _get_conclusion(self._honcho, conclusion_id) def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: """Get multiple conclusions by ID. Missing IDs are omitted.""" @@ -453,8 +450,6 @@ 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 @@ -467,13 +462,10 @@ class ConclusionScope: observer/observed pair. Use ``honcho.conclusions.get`` for a workspace-wide lookup. """ - return _get_conclusion( - self._honcho, - filters={ - "id": conclusion_id, - "observer_id": self.observer, - "observed_id": self.observed, - }, + return _require_scope( + _get_conclusion(self._honcho, conclusion_id), + self.observer, + self.observed, ) def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]: diff --git a/sdks/typescript/__tests__/conclusions.test.ts b/sdks/typescript/__tests__/conclusions.test.ts index 1dc7694b..dcb9819f 100644 --- a/sdks/typescript/__tests__/conclusions.test.ts +++ b/sdks/typescript/__tests__/conclusions.test.ts @@ -335,10 +335,10 @@ describe('Conclusions', () => { }) // =========================================================================== - // Single Conclusion Retrieval (list with an id filter) + // Single Conclusion Retrieval // =========================================================================== - describe('get (list with id filter)', () => { + describe('get', () => { 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 7194635b..328bb2b3 100644 --- a/sdks/typescript/src/conclusions.ts +++ b/sdks/typescript/src/conclusions.ts @@ -211,18 +211,14 @@ export class ConclusionScope { } private async _get(conclusionId: string): Promise { - // Equivalent to list with an `id` filter, restricted to this pair. - const response = await this._list({ - filters: { - id: conclusionId, - observer_id: this.observer, - observed_id: this.observed, - }, - page: 1, - size: 1, - }) - const item = response.items?.[0] - if (!item) { + await this._ensureWorkspace() + const item = await this._http.get( + `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/${conclusionId}` + ) + if ( + item.observer_id !== this.observer || + item.observed_id !== this.observed + ) { throw new NotFoundError('Conclusion not found') } return item @@ -607,15 +603,10 @@ export class WorkspaceConclusions { * Get a single conclusion by ID, anywhere in the workspace. */ async get(conclusionId: string): Promise { - 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') - } + await this._ensureWorkspace() + const item = await this._http.get( + `/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/${conclusionId}` + ) return Conclusion.fromApiResponse(item) } diff --git a/src/routers/conclusions.py b/src/routers/conclusions.py index 89d6207f..3b977995 100644 --- a/src/routers/conclusions.py +++ b/src/routers/conclusions.py @@ -134,6 +134,26 @@ 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 21eb940d..9c8b15eb 100644 --- a/tests/routes/test_conclusions.py +++ b/tests/routes/test_conclusions.py @@ -667,14 +667,13 @@ class TestConclusionRoutes: assert "not found" in data["detail"].lower() @pytest.mark.asyncio - async def test_list_conclusion_by_id_success( + async def test_get_conclusion_success( self, client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer], ): - """Test getting a single conclusion via list with an id filter, - including attribution fields""" + """Test getting a single conclusion by ID with attribution fields""" test_workspace, test_peer = sample_data # Create another peer @@ -720,16 +719,13 @@ class TestConclusionRoutes: db_session.add(derived) await db_session.commit() - # Get conclusion via list with an id filter - response = client.post( - f"/v3/workspaces/{test_workspace.name}/conclusions/list", - json={"filters": {"id": derived.id}}, + # Get conclusion by ID + response = client.get( + f"/v3/workspaces/{test_workspace.name}/conclusions/{derived.id}" ) assert response.status_code == 200 - items = response.json()["items"] - assert len(items) == 1 - conclusion = items[0] + conclusion = response.json() assert conclusion["id"] == derived.id assert conclusion["content"] == "User is likely a night owl" assert conclusion["observer_id"] == test_peer.name @@ -743,7 +739,7 @@ class TestConclusionRoutes: assert "internal_metadata" not in conclusion @pytest.mark.asyncio - async def test_list_conclusion_by_id_legacy_source_ids( + async def test_get_conclusion_legacy_source_ids( self, client: TestClient, db_session: AsyncSession, @@ -779,41 +775,39 @@ class TestConclusionRoutes: db_session.add(doc) await db_session.commit() - response = client.post( - f"/v3/workspaces/{test_workspace.name}/conclusions/list", - json={"filters": {"id": doc.id}}, + response = client.get( + f"/v3/workspaces/{test_workspace.name}/conclusions/{doc.id}" ) assert response.status_code == 200 - items = response.json()["items"] - assert len(items) == 1 - assert items[0]["source_ids"] is None + conclusion = response.json() + assert conclusion["source_ids"] is None @pytest.mark.asyncio - async def test_list_conclusion_by_id_not_found( + async def test_get_conclusion_not_found( self, client: TestClient, sample_data: tuple[Workspace, Peer], ): - """Listing with a non-existent id filter yields an empty page""" + """Test getting a non-existent conclusion""" test_workspace, _test_peer = sample_data - response = client.post( - f"/v3/workspaces/{test_workspace.name}/conclusions/list", - json={"filters": {"id": "nonexistent_id"}}, + response = client.get( + f"/v3/workspaces/{test_workspace.name}/conclusions/nonexistent_id" ) - assert response.status_code == 200 - assert response.json()["items"] == [] + assert response.status_code == 404 + data = response.json() + assert "not found" in data["detail"].lower() @pytest.mark.asyncio - async def test_list_conclusion_by_id_soft_deleted( + async def test_get_conclusion_soft_deleted( self, client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer], ): - """Test that a soft-deleted conclusion is excluded from list results""" + """Test that a soft-deleted conclusion returns 404""" test_workspace, test_peer = sample_data # Create another peer @@ -843,12 +837,10 @@ class TestConclusionRoutes: ) assert delete_response.status_code == 204 - response = client.post( - f"/v3/workspaces/{test_workspace.name}/conclusions/list", - json={"filters": {"id": doc.id}}, + response = client.get( + f"/v3/workspaces/{test_workspace.name}/conclusions/{doc.id}" ) - assert response.status_code == 200 - assert response.json()["items"] == [] + assert response.status_code == 404 @pytest.mark.asyncio async def test_list_conclusions_includes_attribution_fields(