feat(sdk): add workspace-wide conclusions list/get
This commit is contained in:
parent
94ace5e3f5
commit
5913c7893c
|
|
@ -41,11 +41,17 @@ from importlib.metadata import PackageNotFoundError, version
|
|||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from .aio import ConclusionScopeAio, HonchoAio, PeerAio, SessionAio
|
||||
from .aio import (
|
||||
ConclusionScopeAio,
|
||||
HonchoAio,
|
||||
PeerAio,
|
||||
SessionAio,
|
||||
WorkspaceConclusionsAio,
|
||||
)
|
||||
from .api_types import MessageCreateParams
|
||||
from .base import PeerBase, SessionBase
|
||||
from .client import Honcho
|
||||
from .conclusions import Conclusion, ConclusionScope
|
||||
from .conclusions import Conclusion, ConclusionScope, WorkspaceConclusions
|
||||
from .http.exceptions import (
|
||||
APIError,
|
||||
AuthenticationError,
|
||||
|
|
@ -96,12 +102,14 @@ __all__ = [
|
|||
# Domain classes
|
||||
"Conclusion",
|
||||
"ConclusionScope",
|
||||
"WorkspaceConclusions",
|
||||
"Message",
|
||||
"MessageCreateParams",
|
||||
"Peer",
|
||||
"Session",
|
||||
# Aio views (for type hints)
|
||||
"ConclusionScopeAio",
|
||||
"WorkspaceConclusionsAio",
|
||||
"HonchoAio",
|
||||
"PeerAio",
|
||||
"SessionAio",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ from .api_types import (
|
|||
)
|
||||
from .base import PeerBase, SessionBase
|
||||
from .conclusions import (
|
||||
_LIST_PAGE_CAP,
|
||||
_SCOPE_RESERVED,
|
||||
Conclusion,
|
||||
_reject_reserved_filter_keys,
|
||||
|
|
@ -68,7 +69,7 @@ from .utils import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from .client import Honcho
|
||||
from .conclusions import ConclusionScope
|
||||
from .conclusions import ConclusionScope, WorkspaceConclusions
|
||||
|
||||
from .conclusions import ConclusionCreateParams
|
||||
from .peer import Peer, TResponseFormat, serialize_response_format
|
||||
|
|
@ -81,6 +82,7 @@ __all__ = [
|
|||
"PeerAio",
|
||||
"SessionAio",
|
||||
"ConclusionScopeAio",
|
||||
"WorkspaceConclusionsAio",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -447,6 +449,11 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
)
|
||||
return QueueStatusResponse.model_validate(data)
|
||||
|
||||
@property
|
||||
def conclusions(self) -> "WorkspaceConclusionsAio":
|
||||
"""Workspace-wide conclusions (no observer/observed pair implied)."""
|
||||
return self._honcho.conclusions.aio
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
async def schedule_dream(
|
||||
self,
|
||||
|
|
@ -1488,6 +1495,118 @@ class SessionAio(AsyncMetadataConfigMixin):
|
|||
return Message.from_api_response(MessageResponse.model_validate(data))
|
||||
|
||||
|
||||
async def _aget_conclusion(
|
||||
honcho: "Honcho",
|
||||
*,
|
||||
filters: dict[str, Any],
|
||||
) -> 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},
|
||||
)
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
raise NotFoundError("Conclusion not found")
|
||||
return Conclusion.from_api_response(ConclusionResponse.model_validate(items[0]))
|
||||
|
||||
|
||||
async def _aget_many_conclusions(
|
||||
honcho: "Honcho",
|
||||
conclusion_ids: list[str],
|
||||
extra_filters: dict[str, Any] | None = None,
|
||||
) -> list[Conclusion]:
|
||||
if not conclusion_ids:
|
||||
return []
|
||||
await honcho._ensure_workspace_async()
|
||||
conclusions: list[Conclusion] = []
|
||||
for start in range(0, len(conclusion_ids), _LIST_PAGE_CAP):
|
||||
chunk = conclusion_ids[start : start + _LIST_PAGE_CAP]
|
||||
filters: dict[str, Any] = {"id": {"in": chunk}, **(extra_filters or {})}
|
||||
data = await honcho._async_http_client.post(
|
||||
routes.conclusions_list(honcho.workspace_id),
|
||||
body={"filters": filters},
|
||||
query={"page": 1, "size": len(chunk)},
|
||||
)
|
||||
conclusions.extend(
|
||||
Conclusion.from_api_response(ConclusionResponse.model_validate(item))
|
||||
for item in data.get("items", [])
|
||||
)
|
||||
return conclusions
|
||||
|
||||
|
||||
async def _alist_conclusions(
|
||||
honcho: "Honcho",
|
||||
filters: dict[str, Any] | None,
|
||||
*,
|
||||
page: int,
|
||||
size: int,
|
||||
reverse: bool,
|
||||
) -> AsyncPage[ConclusionResponse, Conclusion]:
|
||||
await honcho._ensure_workspace_async()
|
||||
body: dict[str, Any] | None = {"filters": filters} if filters else None
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
data = await honcho._async_http_client.post(
|
||||
routes.conclusions_list(honcho.workspace_id),
|
||||
body=body,
|
||||
query=query,
|
||||
)
|
||||
|
||||
def transform(response: ConclusionResponse) -> Conclusion:
|
||||
return Conclusion.from_api_response(response)
|
||||
|
||||
async def fetch_next(
|
||||
next_page: int,
|
||||
) -> AsyncPage[ConclusionResponse, Conclusion]:
|
||||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
next_data = await honcho._async_http_client.post(
|
||||
routes.conclusions_list(honcho.workspace_id),
|
||||
body=body,
|
||||
query=next_query,
|
||||
)
|
||||
return AsyncPage(next_data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
return AsyncPage(data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
|
||||
class WorkspaceConclusionsAio:
|
||||
"""Async view of workspace-wide conclusions. Access via ``honcho.aio.conclusions``."""
|
||||
|
||||
__slots__: ClassVar[tuple[str, ...]] = ("_workspace",)
|
||||
_workspace: "WorkspaceConclusions"
|
||||
|
||||
def __init__(self, workspace: "WorkspaceConclusions") -> None:
|
||||
self._workspace = workspace
|
||||
|
||||
async def list(
|
||||
self,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
) -> AsyncPage[ConclusionResponse, Conclusion]:
|
||||
"""List conclusions in this workspace asynchronously."""
|
||||
return await _alist_conclusions(
|
||||
self._workspace._honcho, filters, page=page, size=size, reverse=reverse
|
||||
)
|
||||
|
||||
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}
|
||||
)
|
||||
|
||||
async def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]:
|
||||
"""Get multiple conclusions by ID. Missing IDs are omitted."""
|
||||
return await _aget_many_conclusions(self._workspace._honcho, conclusion_ids)
|
||||
|
||||
|
||||
class ConclusionScopeAio:
|
||||
"""
|
||||
Async view of a ConclusionScope.
|
||||
|
|
@ -1522,7 +1641,6 @@ class ConclusionScopeAio:
|
|||
_reject_reserved_filter_keys(
|
||||
filters, _SCOPE_RESERVED + ("session", "session_id")
|
||||
)
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
resolved_session_id = resolve_id(session)
|
||||
filters = {
|
||||
"observer_id": self._scope.observer,
|
||||
|
|
@ -1530,34 +1648,10 @@ class ConclusionScopeAio:
|
|||
**({"session_id": resolved_session_id} if resolved_session_id else {}),
|
||||
**(filters or {}),
|
||||
}
|
||||
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
data = await self._scope._honcho._async_http_client.post(
|
||||
routes.conclusions_list(self._scope.workspace_id),
|
||||
body={"filters": filters},
|
||||
query=query,
|
||||
return await _alist_conclusions(
|
||||
self._scope._honcho, filters, page=page, size=size, reverse=reverse
|
||||
)
|
||||
|
||||
def transform(response: ConclusionResponse) -> Conclusion:
|
||||
return Conclusion.from_api_response(response)
|
||||
|
||||
async def fetch_next(
|
||||
next_page: int,
|
||||
) -> AsyncPage[ConclusionResponse, Conclusion]:
|
||||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
next_data = await self._scope._honcho._async_http_client.post(
|
||||
routes.conclusions_list(self._scope.workspace_id),
|
||||
body={"filters": filters},
|
||||
query=next_query,
|
||||
)
|
||||
return AsyncPage(next_data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
return AsyncPage(data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
async def query(
|
||||
self,
|
||||
query: str,
|
||||
|
|
@ -1610,18 +1704,18 @@ class ConclusionScopeAio:
|
|||
(`source_ids`, `times_derived`)
|
||||
|
||||
Raises:
|
||||
NotFoundError: If no conclusion with the given ID exists
|
||||
NotFoundError: If no conclusion with the given ID exists in this
|
||||
observer/observed pair. Use ``honcho.aio.conclusions.get`` for a
|
||||
workspace-wide lookup.
|
||||
"""
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
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 await _aget_conclusion(
|
||||
self._scope._honcho,
|
||||
filters={
|
||||
"id": conclusion_id,
|
||||
"observer_id": self._scope.observer,
|
||||
"observed_id": self._scope.observed,
|
||||
},
|
||||
)
|
||||
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.
|
||||
|
|
@ -1635,22 +1729,14 @@ class ConclusionScopeAio:
|
|||
omitted, so the result may be shorter than the input (order
|
||||
is not guaranteed to match the input either).
|
||||
"""
|
||||
if not conclusion_ids:
|
||||
return []
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
conclusions: list[Conclusion] = []
|
||||
for start in range(0, len(conclusion_ids), 100):
|
||||
chunk = conclusion_ids[start : start + 100]
|
||||
data = await self._scope._honcho._async_http_client.post(
|
||||
routes.conclusions_list(self._scope.workspace_id),
|
||||
body={"filters": {"id": {"in": chunk}}},
|
||||
query={"page": 1, "size": len(chunk)},
|
||||
)
|
||||
conclusions.extend(
|
||||
Conclusion.from_api_response(ConclusionResponse.model_validate(item))
|
||||
for item in data.get("items", [])
|
||||
)
|
||||
return conclusions
|
||||
return await _aget_many_conclusions(
|
||||
self._scope._honcho,
|
||||
conclusion_ids,
|
||||
extra_filters={
|
||||
"observer_id": self._scope.observer,
|
||||
"observed_id": self._scope.observed,
|
||||
},
|
||||
)
|
||||
|
||||
async def derived(
|
||||
self,
|
||||
|
|
@ -1672,42 +1758,25 @@ class ConclusionScopeAio:
|
|||
size: Number of results per page. Default: 50.
|
||||
reverse: If True, reverses the default newest-first ordering.
|
||||
|
||||
Equivalent to ``list`` with a ``parent_id`` filter; an unknown
|
||||
``conclusion_id`` yields an empty page rather than an error.
|
||||
Equivalent to ``list`` with a ``parent_id`` filter, restricted to this
|
||||
observer/observed pair. An unknown ``conclusion_id`` yields an empty
|
||||
page rather than an error.
|
||||
|
||||
Returns:
|
||||
Paginated response containing Conclusion objects
|
||||
"""
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
body: dict[str, Any] = {"filters": {"parent_id": conclusion_id}}
|
||||
|
||||
def build_query(page_num: int) -> dict[str, Any]:
|
||||
query: dict[str, Any] = {"page": page_num, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
return query
|
||||
|
||||
data = await self._scope._honcho._async_http_client.post(
|
||||
routes.conclusions_list(self._scope.workspace_id),
|
||||
body=body,
|
||||
query=build_query(page),
|
||||
return await _alist_conclusions(
|
||||
self._scope._honcho,
|
||||
{
|
||||
"parent_id": conclusion_id,
|
||||
"observer_id": self._scope.observer,
|
||||
"observed_id": self._scope.observed,
|
||||
},
|
||||
page=page,
|
||||
size=size,
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
def transform(response: ConclusionResponse) -> Conclusion:
|
||||
return Conclusion.from_api_response(response)
|
||||
|
||||
async def fetch_next(
|
||||
next_page: int,
|
||||
) -> AsyncPage[ConclusionResponse, Conclusion]:
|
||||
next_data = await self._scope._honcho._async_http_client.post(
|
||||
routes.conclusions_list(self._scope.workspace_id),
|
||||
body=body,
|
||||
query=build_query(next_page),
|
||||
)
|
||||
return AsyncPage(next_data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
return AsyncPage(data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
async def delete(self, conclusion_id: str) -> None:
|
||||
"""Delete a conclusion by ID asynchronously."""
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from .api_types import (
|
|||
WorkspaceResponse,
|
||||
)
|
||||
from .base import PeerBase, SessionBase
|
||||
from .conclusions import WorkspaceConclusions
|
||||
from .http import AsyncHonchoHTTPClient, HonchoHTTPClient, routes
|
||||
from .message import Message
|
||||
from .mixins import MetadataConfigMixin
|
||||
|
|
@ -689,6 +690,23 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def conclusions(self) -> WorkspaceConclusions:
|
||||
"""Workspace-wide conclusions. No observer/observed pair is implied.
|
||||
|
||||
Use this to list or look up conclusions across the workspace. Pair-
|
||||
scoped create/query/delete stay on ``peer.conclusions``.
|
||||
|
||||
Example:
|
||||
```python
|
||||
honcho.conclusions.list()
|
||||
honcho.conclusions.list(filters={"observed_id": "alice"})
|
||||
honcho.conclusions.list(filters={"session_id": session.id})
|
||||
honcho.conclusions.get(conclusion_id)
|
||||
```
|
||||
"""
|
||||
return WorkspaceConclusions(self)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a string representation of the Honcho client.
|
||||
|
|
|
|||
|
|
@ -15,15 +15,18 @@ from .pagination import SyncPage
|
|||
from .utils import resolve_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .aio import ConclusionScopeAio
|
||||
from .aio import ConclusionScopeAio, WorkspaceConclusionsAio
|
||||
from .client import Honcho
|
||||
|
||||
__all__ = [
|
||||
"Conclusion",
|
||||
"ConclusionScope",
|
||||
"ConclusionCreateParams",
|
||||
"WorkspaceConclusions",
|
||||
]
|
||||
|
||||
_LIST_PAGE_CAP = 100
|
||||
|
||||
# Filter keys that define a conclusion scope (the observer/observed peer pair).
|
||||
# They are set from the scope itself, so a caller must not pass them in `filters`.
|
||||
_SCOPE_RESERVED = ("observer", "observed", "observer_id", "observed_id")
|
||||
|
|
@ -53,6 +56,89 @@ def _reject_reserved_filter_keys(
|
|||
)
|
||||
|
||||
|
||||
def _list_query(page: int, size: int, reverse: bool) -> dict[str, Any]:
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
return query
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
raise NotFoundError("Conclusion not found")
|
||||
return _conclusion_from_item(items[0])
|
||||
|
||||
|
||||
def _get_many_conclusions(
|
||||
honcho: "Honcho",
|
||||
conclusion_ids: list[str],
|
||||
extra_filters: dict[str, Any] | None = None,
|
||||
) -> list[Conclusion]:
|
||||
if not conclusion_ids:
|
||||
return []
|
||||
honcho._ensure_workspace()
|
||||
conclusions: list[Conclusion] = []
|
||||
for start in range(0, len(conclusion_ids), _LIST_PAGE_CAP):
|
||||
chunk = conclusion_ids[start : start + _LIST_PAGE_CAP]
|
||||
filters: dict[str, Any] = {"id": {"in": chunk}, **(extra_filters or {})}
|
||||
data = honcho._http.post(
|
||||
routes.conclusions_list(honcho.workspace_id),
|
||||
body={"filters": filters},
|
||||
query={"page": 1, "size": len(chunk)},
|
||||
)
|
||||
conclusions.extend(
|
||||
_conclusion_from_item(item) for item in data.get("items", [])
|
||||
)
|
||||
return conclusions
|
||||
|
||||
|
||||
def _list_conclusions(
|
||||
honcho: "Honcho",
|
||||
filters: dict[str, Any] | None,
|
||||
*,
|
||||
page: int,
|
||||
size: int,
|
||||
reverse: bool,
|
||||
) -> SyncPage[ConclusionResponse, Conclusion]:
|
||||
honcho._ensure_workspace()
|
||||
body: dict[str, Any] | None = {"filters": filters} if filters else None
|
||||
data = honcho._http.post(
|
||||
routes.conclusions_list(honcho.workspace_id),
|
||||
body=body,
|
||||
query=_list_query(page, size, reverse),
|
||||
)
|
||||
|
||||
def transform(response: ConclusionResponse) -> Conclusion:
|
||||
return Conclusion.from_api_response(response)
|
||||
|
||||
def fetch_next(
|
||||
next_page: int,
|
||||
) -> SyncPage[ConclusionResponse, Conclusion]:
|
||||
next_data = honcho._http.post(
|
||||
routes.conclusions_list(honcho.workspace_id),
|
||||
body=body,
|
||||
query=_list_query(next_page, size, reverse),
|
||||
)
|
||||
return SyncPage(next_data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
return SyncPage(data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
|
||||
class ConclusionCreateParams(BaseModel):
|
||||
content: str
|
||||
session_id: str | None = None
|
||||
|
|
@ -139,6 +225,66 @@ class Conclusion:
|
|||
return self.content
|
||||
|
||||
|
||||
class WorkspaceConclusions:
|
||||
"""Workspace-wide conclusion access. No observer/observed pair is implied.
|
||||
|
||||
Use this to list or look up conclusions across the workspace, then filter
|
||||
down to a peer or session. Pair-scoped create/query/delete stay on
|
||||
``peer.conclusions`` / ``peer.conclusions_of(target)``.
|
||||
|
||||
Example:
|
||||
```python
|
||||
honcho.conclusions.list() # whole workspace
|
||||
honcho.conclusions.list(filters={"observed_id": "alice"})
|
||||
honcho.conclusions.list(filters={"session_id": session.id})
|
||||
honcho.conclusions.get(conclusion_id)
|
||||
honcho.conclusions.get_many(node.source_ids or [])
|
||||
```
|
||||
"""
|
||||
|
||||
_honcho: "Honcho"
|
||||
workspace_id: str
|
||||
|
||||
def __init__(self, honcho: "Honcho") -> None:
|
||||
self._honcho = honcho
|
||||
self.workspace_id = honcho.workspace_id
|
||||
|
||||
@property
|
||||
def aio(self) -> "WorkspaceConclusionsAio":
|
||||
from .aio import WorkspaceConclusionsAio
|
||||
|
||||
return WorkspaceConclusionsAio(self)
|
||||
|
||||
def list(
|
||||
self,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
) -> SyncPage[ConclusionResponse, Conclusion]:
|
||||
"""List conclusions in this workspace.
|
||||
|
||||
Unlike ``peer.conclusions.list``, no observer/observed pair is injected.
|
||||
Pass ``filters`` to narrow the view — e.g. ``{"observed_id": "alice"}``
|
||||
or ``{"session_id": "..."}``.
|
||||
"""
|
||||
return _list_conclusions(
|
||||
self._honcho, filters, page=page, size=size, reverse=reverse
|
||||
)
|
||||
|
||||
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})
|
||||
|
||||
def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]:
|
||||
"""Get multiple conclusions by ID. Missing IDs are omitted."""
|
||||
return _get_many_conclusions(self._honcho, conclusion_ids)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"WorkspaceConclusions(workspace_id={self.workspace_id!r})"
|
||||
|
||||
|
||||
class ConclusionScope:
|
||||
"""
|
||||
Scoped access to conclusions for a specific observer/observed relationship.
|
||||
|
|
@ -243,7 +389,6 @@ class ConclusionScope:
|
|||
_reject_reserved_filter_keys(
|
||||
filters, _SCOPE_RESERVED + ("session", "session_id")
|
||||
)
|
||||
self._honcho._ensure_workspace()
|
||||
resolved_session_id = resolve_id(session)
|
||||
filters = {
|
||||
"observer_id": self.observer,
|
||||
|
|
@ -251,34 +396,10 @@ class ConclusionScope:
|
|||
**({"session_id": resolved_session_id} if resolved_session_id else {}),
|
||||
**(filters or {}),
|
||||
}
|
||||
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
data = self._honcho._http.post(
|
||||
routes.conclusions_list(self.workspace_id),
|
||||
body={"filters": filters},
|
||||
query=query,
|
||||
return _list_conclusions(
|
||||
self._honcho, filters, page=page, size=size, reverse=reverse
|
||||
)
|
||||
|
||||
def transform(response: ConclusionResponse) -> Conclusion:
|
||||
return Conclusion.from_api_response(response)
|
||||
|
||||
def fetch_next(
|
||||
next_page: int,
|
||||
) -> SyncPage[ConclusionResponse, Conclusion]:
|
||||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
next_data = self._honcho._http.post(
|
||||
routes.conclusions_list(self.workspace_id),
|
||||
body={"filters": filters},
|
||||
query=next_query,
|
||||
)
|
||||
return SyncPage(next_data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
return SyncPage(data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
def query(
|
||||
self,
|
||||
query: str,
|
||||
|
|
@ -342,18 +463,18 @@ class ConclusionScope:
|
|||
(`source_ids`, `times_derived`)
|
||||
|
||||
Raises:
|
||||
NotFoundError: If no conclusion with the given ID exists
|
||||
NotFoundError: If no conclusion with the given ID exists in this
|
||||
observer/observed pair. Use ``honcho.conclusions.get`` for a
|
||||
workspace-wide lookup.
|
||||
"""
|
||||
self._honcho._ensure_workspace()
|
||||
data = self._honcho._http.post(
|
||||
routes.conclusions_list(self.workspace_id),
|
||||
body={"filters": {"id": conclusion_id}},
|
||||
query={"page": 1, "size": 1},
|
||||
return _get_conclusion(
|
||||
self._honcho,
|
||||
filters={
|
||||
"id": conclusion_id,
|
||||
"observer_id": self.observer,
|
||||
"observed_id": self.observed,
|
||||
},
|
||||
)
|
||||
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]:
|
||||
"""
|
||||
|
|
@ -371,22 +492,14 @@ class ConclusionScope:
|
|||
omitted, so the result may be shorter than the input (order
|
||||
is not guaranteed to match the input either).
|
||||
"""
|
||||
if not conclusion_ids:
|
||||
return []
|
||||
self._honcho._ensure_workspace()
|
||||
conclusions: list[Conclusion] = []
|
||||
for start in range(0, len(conclusion_ids), 100):
|
||||
chunk = conclusion_ids[start : start + 100]
|
||||
data = self._honcho._http.post(
|
||||
routes.conclusions_list(self.workspace_id),
|
||||
body={"filters": {"id": {"in": chunk}}},
|
||||
query={"page": 1, "size": len(chunk)},
|
||||
)
|
||||
conclusions.extend(
|
||||
Conclusion.from_api_response(ConclusionResponse.model_validate(item))
|
||||
for item in data.get("items", [])
|
||||
)
|
||||
return conclusions
|
||||
return _get_many_conclusions(
|
||||
self._honcho,
|
||||
conclusion_ids,
|
||||
extra_filters={
|
||||
"observer_id": self.observer,
|
||||
"observed_id": self.observed,
|
||||
},
|
||||
)
|
||||
|
||||
def derived(
|
||||
self,
|
||||
|
|
@ -407,42 +520,25 @@ class ConclusionScope:
|
|||
size: Number of results per page. Default: 50.
|
||||
reverse: If True, reverses the default newest-first ordering.
|
||||
|
||||
Equivalent to ``list`` with a ``parent_id`` filter; an unknown
|
||||
``conclusion_id`` yields an empty page rather than an error.
|
||||
Equivalent to ``list`` with a ``parent_id`` filter, restricted to this
|
||||
observer/observed pair. An unknown ``conclusion_id`` yields an empty
|
||||
page rather than an error.
|
||||
|
||||
Returns:
|
||||
Paginated response containing Conclusion objects
|
||||
"""
|
||||
self._honcho._ensure_workspace()
|
||||
body: dict[str, Any] = {"filters": {"parent_id": conclusion_id}}
|
||||
|
||||
def build_query(page_num: int) -> dict[str, Any]:
|
||||
query: dict[str, Any] = {"page": page_num, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
return query
|
||||
|
||||
data = self._honcho._http.post(
|
||||
routes.conclusions_list(self.workspace_id),
|
||||
body=body,
|
||||
query=build_query(page),
|
||||
return _list_conclusions(
|
||||
self._honcho,
|
||||
{
|
||||
"parent_id": conclusion_id,
|
||||
"observer_id": self.observer,
|
||||
"observed_id": self.observed,
|
||||
},
|
||||
page=page,
|
||||
size=size,
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
def transform(response: ConclusionResponse) -> Conclusion:
|
||||
return Conclusion.from_api_response(response)
|
||||
|
||||
def fetch_next(
|
||||
next_page: int,
|
||||
) -> SyncPage[ConclusionResponse, Conclusion]:
|
||||
next_data = self._honcho._http.post(
|
||||
routes.conclusions_list(self.workspace_id),
|
||||
body=body,
|
||||
query=build_query(next_page),
|
||||
)
|
||||
return SyncPage(next_data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
return SyncPage(data, ConclusionResponse, transform, fetch_next)
|
||||
|
||||
def delete(self, conclusion_id: str) -> None:
|
||||
"""
|
||||
Delete a conclusion by ID.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
|
||||
import { Honcho, Conclusion, ConclusionScope } from '../src'
|
||||
import { Honcho, Conclusion, ConclusionScope, WorkspaceConclusions } from '../src'
|
||||
import { NotFoundError } from '../src/http/errors'
|
||||
import { createTestClient, requireServer } from './setup'
|
||||
import { assertConclusionShape } from './helpers'
|
||||
|
||||
|
|
@ -400,6 +401,61 @@ describe('Conclusions', () => {
|
|||
// Derived Conclusions (list + parent_id filter)
|
||||
// ===========================================================================
|
||||
|
||||
describe('honcho.conclusions (workspace-wide)', () => {
|
||||
test('lists and fetches across observer/observed pairs', async () => {
|
||||
const alice = await client.peer('ws-conc-alice', { metadata: {} })
|
||||
const bob = await client.peer('ws-conc-bob', { metadata: {} })
|
||||
const session = await client.session('ws-conc-session', { metadata: {} })
|
||||
|
||||
const [aliceSelf] = await alice.conclusions.create({
|
||||
content: 'Alice self conclusion',
|
||||
sessionId: session,
|
||||
})
|
||||
const [aboutBob] = await alice.conclusionsOf(bob).create({
|
||||
content: 'Alice about Bob',
|
||||
sessionId: session,
|
||||
})
|
||||
|
||||
expect(client.conclusions).toBeInstanceOf(WorkspaceConclusions)
|
||||
|
||||
const page = await client.conclusions.list({ size: 100 })
|
||||
const ids = new Set(page.items.map((c) => c.id))
|
||||
expect(ids.has(aliceSelf.id)).toBe(true)
|
||||
expect(ids.has(aboutBob.id)).toBe(true)
|
||||
|
||||
const aboutBobOnly = await client.conclusions.list({
|
||||
filters: { observed_id: bob.id },
|
||||
size: 100,
|
||||
})
|
||||
expect(aboutBobOnly.items.every((c) => c.observedId === bob.id)).toBe(true)
|
||||
expect(new Set(aboutBobOnly.items.map((c) => c.id)).has(aboutBob.id)).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
const sessionPage = await client.conclusions.list({
|
||||
filters: { session_id: session.id },
|
||||
size: 100,
|
||||
})
|
||||
const sessionIds = new Set(sessionPage.items.map((c) => c.id))
|
||||
expect(sessionIds.has(aliceSelf.id)).toBe(true)
|
||||
expect(sessionIds.has(aboutBob.id)).toBe(true)
|
||||
|
||||
const fetched = await client.conclusions.get(aboutBob.id)
|
||||
expect(fetched.id).toBe(aboutBob.id)
|
||||
expect(fetched.observerId).toBe(alice.id)
|
||||
expect(fetched.observedId).toBe(bob.id)
|
||||
|
||||
const batch = await client.conclusions.getMany([aliceSelf.id, aboutBob.id])
|
||||
expect(new Set(batch.map((c) => c.id))).toEqual(
|
||||
new Set([aliceSelf.id, aboutBob.id])
|
||||
)
|
||||
|
||||
await expect(alice.conclusions.get(aboutBob.id)).rejects.toBeInstanceOf(
|
||||
NotFoundError
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('derived() via parent_id filter', () => {
|
||||
test('leaf conclusion has no derived conclusions', async () => {
|
||||
const peer = await client.peer('derived-conclusion-peer', { metadata: {} })
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { API_VERSION } from './api-version'
|
||||
import { WorkspaceConclusions } from './conclusions'
|
||||
import { HonchoHTTPClient } from './http/client'
|
||||
import { Message } from './message'
|
||||
import { Page } from './pagination'
|
||||
|
|
@ -121,6 +122,18 @@ export class Honcho {
|
|||
return this._http
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-wide conclusions. No observer/observed pair is implied.
|
||||
*
|
||||
* Use this to list or look up conclusions across the workspace. Pair-
|
||||
* scoped create/query/delete stay on `peer.conclusions`.
|
||||
*/
|
||||
get conclusions(): WorkspaceConclusions {
|
||||
return new WorkspaceConclusions(this._http, this.workspaceId, () =>
|
||||
this._ensureWorkspace()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URL for the API.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation'
|
|||
* Filter keys that define a conclusion scope (the observer/observed peer pair).
|
||||
* They are set from the scope itself, so a caller must not pass them in `filters`.
|
||||
*/
|
||||
const LIST_PAGE_CAP = 100
|
||||
|
||||
const SCOPE_RESERVED_KEYS = [
|
||||
'observer',
|
||||
'observed',
|
||||
|
|
@ -209,9 +211,13 @@ export class ConclusionScope {
|
|||
}
|
||||
|
||||
private async _get(conclusionId: string): Promise<ConclusionResponse> {
|
||||
// Equivalent to list with an `id` filter; there is no single-GET endpoint.
|
||||
// Equivalent to list with an `id` filter, restricted to this pair.
|
||||
const response = await this._list({
|
||||
filters: { id: conclusionId },
|
||||
filters: {
|
||||
id: conclusionId,
|
||||
observer_id: this.observer,
|
||||
observed_id: this.observed,
|
||||
},
|
||||
page: 1,
|
||||
size: 1,
|
||||
})
|
||||
|
|
@ -230,10 +236,13 @@ export class ConclusionScope {
|
|||
reverse?: boolean
|
||||
}
|
||||
): Promise<PageResponse<ConclusionResponse>> {
|
||||
// Equivalent to list with a parent_id filter; an unknown conclusionId
|
||||
// yields an empty page rather than an error.
|
||||
// Equivalent to list with a parent_id filter, restricted to this pair.
|
||||
return this._list({
|
||||
filters: { parent_id: conclusionId },
|
||||
filters: {
|
||||
parent_id: conclusionId,
|
||||
observer_id: this.observer,
|
||||
observed_id: this.observed,
|
||||
},
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
reverse: params.reverse,
|
||||
|
|
@ -391,10 +400,14 @@ export class ConclusionScope {
|
|||
if (conclusionIds.length === 0) return []
|
||||
const conclusions: Conclusion[] = []
|
||||
// The list endpoint caps page size at 100
|
||||
for (let start = 0; start < conclusionIds.length; start += 100) {
|
||||
const chunk = conclusionIds.slice(start, start + 100)
|
||||
for (let start = 0; start < conclusionIds.length; start += LIST_PAGE_CAP) {
|
||||
const chunk = conclusionIds.slice(start, start + LIST_PAGE_CAP)
|
||||
const response = await this._list({
|
||||
filters: { id: { in: chunk } },
|
||||
filters: {
|
||||
id: { in: chunk },
|
||||
observer_id: this.observer,
|
||||
observed_id: this.observed,
|
||||
},
|
||||
page: 1,
|
||||
size: chunk.length,
|
||||
})
|
||||
|
|
@ -510,3 +523,124 @@ export class ConclusionScope {
|
|||
return `ConclusionScope(workspaceId='${this.workspaceId}', observer='${this.observer}', observed='${this.observed}')`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-wide conclusion access. No observer/observed pair is implied.
|
||||
*
|
||||
* Use this to list or look up conclusions across the workspace, then filter
|
||||
* down to a peer or session. Pair-scoped create/query/delete stay on
|
||||
* `peer.conclusions` / `peer.conclusionsOf(target)`.
|
||||
*/
|
||||
export class WorkspaceConclusions {
|
||||
private _http: HonchoHTTPClient
|
||||
private _ensureWorkspace: () => Promise<void>
|
||||
readonly workspaceId: string
|
||||
|
||||
constructor(
|
||||
http: HonchoHTTPClient,
|
||||
workspaceId: string,
|
||||
ensureWorkspace: () => Promise<void> = async () => undefined
|
||||
) {
|
||||
this._http = http
|
||||
this.workspaceId = workspaceId
|
||||
this._ensureWorkspace = ensureWorkspace
|
||||
}
|
||||
|
||||
private async _list(params: {
|
||||
filters?: Record<string, unknown>
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}): Promise<PageResponse<ConclusionResponse>> {
|
||||
await this._ensureWorkspace()
|
||||
return this._http.post<PageResponse<ConclusionResponse>>(
|
||||
`/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/list`,
|
||||
{
|
||||
body: params.filters ? { filters: params.filters } : undefined,
|
||||
query: {
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
reverse: params.reverse ? 'true' : undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* List conclusions in this workspace.
|
||||
*
|
||||
* Unlike `peer.conclusions.list`, no observer/observed pair is injected.
|
||||
* Pass `filters` to narrow the view — e.g. `{ observed_id: 'alice' }` or
|
||||
* `{ session_id: '...' }`.
|
||||
*/
|
||||
async list(options?: {
|
||||
page?: number
|
||||
size?: number
|
||||
filters?: Record<string, unknown>
|
||||
reverse?: boolean
|
||||
}): Promise<Page<Conclusion, ConclusionResponse>> {
|
||||
const filters = options?.filters
|
||||
const reverse = options?.reverse
|
||||
const response = await this._list({
|
||||
filters,
|
||||
page: options?.page ?? 1,
|
||||
size: options?.size ?? 50,
|
||||
reverse,
|
||||
})
|
||||
|
||||
const fetchNextPage = async (
|
||||
page: number,
|
||||
size: number
|
||||
): Promise<PageResponse<ConclusionResponse>> => {
|
||||
return this._list({ filters, page, size, reverse })
|
||||
}
|
||||
|
||||
return new Page(
|
||||
response,
|
||||
(item) => Conclusion.fromApiResponse(item),
|
||||
fetchNextPage
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
return Conclusion.fromApiResponse(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple conclusions by ID. Missing IDs are omitted.
|
||||
*/
|
||||
async getMany(conclusionIds: string[]): Promise<Conclusion[]> {
|
||||
if (conclusionIds.length === 0) return []
|
||||
const conclusions: Conclusion[] = []
|
||||
for (let start = 0; start < conclusionIds.length; start += LIST_PAGE_CAP) {
|
||||
const chunk = conclusionIds.slice(start, start + LIST_PAGE_CAP)
|
||||
const response = await this._list({
|
||||
filters: { id: { in: chunk } },
|
||||
page: 1,
|
||||
size: chunk.length,
|
||||
})
|
||||
conclusions.push(
|
||||
...(response.items ?? []).map((item) =>
|
||||
Conclusion.fromApiResponse(item)
|
||||
)
|
||||
)
|
||||
}
|
||||
return conclusions
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `WorkspaceConclusions(workspaceId='${this.workspaceId}')`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export {
|
|||
Conclusion,
|
||||
type ConclusionCreateParams,
|
||||
ConclusionScope,
|
||||
WorkspaceConclusions,
|
||||
} from './conclusions'
|
||||
// HTTP infrastructure
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ from sdks.python.src.honcho.conclusions import (
|
|||
Conclusion,
|
||||
ConclusionCreateParams,
|
||||
ConclusionScope,
|
||||
WorkspaceConclusions,
|
||||
)
|
||||
from sdks.python.src.honcho.http import NotFoundError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1023,3 +1025,103 @@ async def test_query_rejects_reserved_scope_filter_keys(
|
|||
with pytest.raises(ValueError, match="managed by this conclusion scope"):
|
||||
obs_scope.query("q", filters={key: "someone-else"})
|
||||
obs_scope.query("q", filters={"session_id": "some-session"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_conclusions_list_and_get(
|
||||
client_fixture: tuple[Honcho, str],
|
||||
):
|
||||
"""honcho.conclusions lists and fetches across observer/observed pairs."""
|
||||
honcho_client, client_type = client_fixture
|
||||
|
||||
if client_type == "async":
|
||||
alice = await honcho_client.aio.peer(id="test-ws-conc-alice")
|
||||
bob = await honcho_client.aio.peer(id="test-ws-conc-bob")
|
||||
session = await honcho_client.aio.session(id="test-ws-conc-session")
|
||||
await session.aio.add_messages(
|
||||
[alice.message("hi from alice"), bob.message("hi from bob")]
|
||||
)
|
||||
alice_conc = (
|
||||
await alice.conclusions.aio.create(
|
||||
[{"content": "Alice self conclusion", "session_id": session.id}]
|
||||
)
|
||||
)[0]
|
||||
about_bob = (
|
||||
await alice.conclusions_of(bob).aio.create(
|
||||
[{"content": "Alice about Bob", "session_id": session.id}]
|
||||
)
|
||||
)[0]
|
||||
|
||||
assert isinstance(honcho_client.conclusions, WorkspaceConclusions)
|
||||
|
||||
page = await honcho_client.aio.conclusions.list(size=100)
|
||||
ids = {c.id for c in page.items}
|
||||
assert alice_conc.id in ids
|
||||
assert about_bob.id in ids
|
||||
|
||||
about_bob_only = await honcho_client.aio.conclusions.list(
|
||||
filters={"observed_id": bob.id}, size=100
|
||||
)
|
||||
assert {c.id for c in about_bob_only.items} >= {about_bob.id}
|
||||
assert all(c.observed_id == bob.id for c in about_bob_only.items)
|
||||
|
||||
session_page = await honcho_client.aio.conclusions.list(
|
||||
filters={"session_id": session.id}, size=100
|
||||
)
|
||||
assert {c.id for c in session_page.items} >= {alice_conc.id, about_bob.id}
|
||||
|
||||
fetched = await honcho_client.aio.conclusions.get(about_bob.id)
|
||||
assert fetched.id == about_bob.id
|
||||
assert fetched.observer_id == alice.id
|
||||
assert fetched.observed_id == bob.id
|
||||
|
||||
batch = await honcho_client.aio.conclusions.get_many(
|
||||
[alice_conc.id, about_bob.id]
|
||||
)
|
||||
assert {c.id for c in batch} == {alice_conc.id, about_bob.id}
|
||||
|
||||
# Scoped get cannot see a conclusion from a different pair.
|
||||
with pytest.raises(NotFoundError):
|
||||
await alice.conclusions.aio.get(about_bob.id)
|
||||
else:
|
||||
alice = honcho_client.peer(id="test-ws-conc-alice")
|
||||
bob = honcho_client.peer(id="test-ws-conc-bob")
|
||||
session = honcho_client.session(id="test-ws-conc-session")
|
||||
session.add_messages(
|
||||
[alice.message("hi from alice"), bob.message("hi from bob")]
|
||||
)
|
||||
alice_conc = alice.conclusions.create(
|
||||
[{"content": "Alice self conclusion", "session_id": session.id}]
|
||||
)[0]
|
||||
about_bob = alice.conclusions_of(bob).create(
|
||||
[{"content": "Alice about Bob", "session_id": session.id}]
|
||||
)[0]
|
||||
|
||||
assert isinstance(honcho_client.conclusions, WorkspaceConclusions)
|
||||
|
||||
page = honcho_client.conclusions.list(size=100)
|
||||
ids = {c.id for c in page.items}
|
||||
assert alice_conc.id in ids
|
||||
assert about_bob.id in ids
|
||||
|
||||
about_bob_only = honcho_client.conclusions.list(
|
||||
filters={"observed_id": bob.id}, size=100
|
||||
)
|
||||
assert {c.id for c in about_bob_only.items} >= {about_bob.id}
|
||||
assert all(c.observed_id == bob.id for c in about_bob_only.items)
|
||||
|
||||
session_page = honcho_client.conclusions.list(
|
||||
filters={"session_id": session.id}, size=100
|
||||
)
|
||||
assert {c.id for c in session_page.items} >= {alice_conc.id, about_bob.id}
|
||||
|
||||
fetched = honcho_client.conclusions.get(about_bob.id)
|
||||
assert fetched.id == about_bob.id
|
||||
assert fetched.observer_id == alice.id
|
||||
assert fetched.observed_id == bob.id
|
||||
|
||||
batch = honcho_client.conclusions.get_many([alice_conc.id, about_bob.id])
|
||||
assert {c.id for c in batch} == {alice_conc.id, about_bob.id}
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
alice.conclusions.get(about_bob.id)
|
||||
|
|
|
|||
Loading…
Reference in New Issue