feat: restore GET /conclusions/{conclusion_id}

This commit is contained in:
ajspig 2026-08-18 14:46:12 -04:00
parent b5a5219a09
commit 7aea397f3e
9 changed files with 126 additions and 103 deletions

View File

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

View File

@ -0,0 +1,3 @@
---
openapi: get /v3/workspaces/{workspace_id}/conclusions/{conclusion_id}
---

View File

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

View File

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

View File

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

View File

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

View File

@ -211,18 +211,14 @@ export class ConclusionScope {
}
private async _get(conclusionId: string): Promise<ConclusionResponse> {
// 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<ConclusionResponse>(
`/${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<Conclusion> {
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<ConclusionResponse>(
`/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/${conclusionId}`
)
return Conclusion.fromApiResponse(item)
}

View File

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

View File

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