feat: improving single conclusion retrieval

This commit is contained in:
ajspig 2026-08-17 17:43:05 -04:00
parent 45d56758d4
commit 94ace5e3f5
7 changed files with 76 additions and 101 deletions

View File

@ -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",

View File

@ -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:

View File

@ -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:

View File

@ -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: {} })

View File

@ -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<ConclusionResponse> {
await this._ensureWorkspace()
return this._http.get<ConclusionResponse>(
`/${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<PageResponse<ConclusionResponse>> {
// 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 },

View File

@ -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,

View File

@ -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(