Abigail/conclusions level filter (#851)
* feat(conclusions): expose reasoning level + allow filtering by level
The `level` of a conclusion (explicit / deductive / inductive /
contradiction) was filterable server-side but stripped from the
`Conclusion` response and not surfaced in either SDK. This adds it
end-to-end so callers can list explicit-only ("not dreamed on")
conclusions without dropping to raw HTTP.
- api: add `level` to the Conclusion response schema
- python sdk: `ConclusionLevel` type, `level` on Conclusion/response,
`level=` kwarg on ConclusionScope.list() and the async variant
- ts sdk: `ConclusionLevel` type, `level` on Conclusion/response,
`level` option on list()
- tests: assert level is exposed; add level-filter list test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(conclusions): use generic filters= on list() instead of level= kwarg
Match the documented SDK convention (peers/sessions/messages all take a
generic `filters` dict passed through to the same dynamic server-side
filter logic) instead of a one-off `level=` kwarg. `level` filtering now
works as `list(filters={"level": "explicit"})` alongside any other
supported filter/operator.
The `level` field on the Conclusion response (added in the previous
commit) is kept — it's still not otherwise returned by the API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(conclusions): allow filtering by level on query() in py + ts SDKs
The branch's level-filter work exposed `filters=` on `list()` but left
`query()` (semantic search) hardcoding `{observer, observed}`, so callers
could filter the list endpoint by reasoning level but not semantic search —
asymmetric in both SDKs.
- Python: add keyword-only `filters` to `ConclusionScope.query` and
`ConclusionScopeAio.query`, merged over the scope's observer/observed.
- TypeScript: add optional `filters` arg to `ConclusionScope.query`,
mirroring the existing `list()` change.
The server `/conclusions/query` endpoint already honors filters in the body
(verified against production), so this is purely SDK surface parity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(filters): document filtering conclusions by reasoning level
The using-filters page covered workspaces/peers/sessions/messages but not
conclusions. Add a "Filtering Conclusions" section showing level-based
filtering on both list() and query(), including the common "explicit only"
(exclude dream-derived) case and the in[deductive,inductive] inverse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(conclusions): simplify filter merge to a single dict spread
Replace the merged_filters + if-block pattern in list()/query() (py sync,
aio, ts) with a single dict spread that layers the caller's filters over the
scope's observer/observed (and session). No behavior change — same merge
order (caller wins) — just less code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(conclusions): reject scope-managed keys in SDK conclusion filters
The generic filters= argument on ConclusionScope.list()/query() spread
user-supplied filters last, so a stray observer/observed/session key
silently overrode the scope and returned data from a different peer
pair. Add a fail-loud guard in both the Python and TypeScript SDKs that
rejects scope-managed filter keys with a clear error, directing callers
to peer.conclusions / conclusions_of(target) and the session= parameter.
session_id remains a valid filter on query() (which has no dedicated
session parameter).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
This commit is contained in:
parent
2583c126f0
commit
14538cfc90
|
|
@ -614,6 +614,65 @@ messages = session.messages(filters={
|
|||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Filtering Conclusions
|
||||
|
||||
Conclusions are scoped to an observer/observed peer pair (accessed via
|
||||
`peer.conclusions` for self-conclusions or `peer.conclusions_of(target)` for
|
||||
conclusions about another peer). The observer and observed are filled in
|
||||
automatically by the scope, so the `filters` you pass add to them.
|
||||
|
||||
The most useful conclusion-specific field is `level`, the reasoning level:
|
||||
|
||||
- `explicit` — extracted directly from messages
|
||||
- `deductive` / `inductive` / `contradiction` — derived later during dreaming
|
||||
|
||||
A common request is to surface only the directly-stated facts and exclude
|
||||
anything inferred during dreaming — filter `level` to `explicit`:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
# Only conclusions extracted directly from messages (exclude dream-derived)
|
||||
explicit = peer.conclusions.list(filters={"level": "explicit"})
|
||||
|
||||
# Only dream-derived conclusions
|
||||
derived = peer.conclusions.list(filters={"level": {"in": ["deductive", "inductive"]}})
|
||||
|
||||
# Same filtering on semantic search
|
||||
results = peer.conclusions.query(
|
||||
"food preferences",
|
||||
filters={"level": "deductive"},
|
||||
)
|
||||
|
||||
# Conclusions about another peer, explicit only
|
||||
bob_explicit = peer.conclusions_of("bob").list(filters={"level": "explicit"})
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
(async () => {
|
||||
// Only conclusions extracted directly from messages (exclude dream-derived)
|
||||
const explicit = await peer.conclusions.list({ filters: { level: "explicit" } });
|
||||
|
||||
// Only dream-derived conclusions
|
||||
const derived = await peer.conclusions.list({
|
||||
filters: { level: { in: ["deductive", "inductive"] } }
|
||||
});
|
||||
|
||||
// Same filtering on semantic search (query, topK, distance, filters)
|
||||
const results = await peer.conclusions.query(
|
||||
"food preferences",
|
||||
10,
|
||||
undefined,
|
||||
{ level: "deductive" }
|
||||
);
|
||||
|
||||
// Conclusions about another peer, explicit only
|
||||
const bobExplicit = await peer.conclusionsOf("bob").list({
|
||||
filters: { level: "explicit" }
|
||||
});
|
||||
})();
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Error Handling
|
||||
|
||||
Handle filter errors gracefully:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@ from .api_types import (
|
|||
WorkspaceResponse,
|
||||
)
|
||||
from .base import PeerBase, SessionBase
|
||||
from .conclusions import Conclusion
|
||||
from .conclusions import (
|
||||
_SCOPE_RESERVED,
|
||||
Conclusion,
|
||||
_reject_reserved_filter_keys,
|
||||
)
|
||||
from .http import routes
|
||||
from .message import Message
|
||||
from .mixins import AsyncMetadataConfigMixin
|
||||
|
|
@ -1460,17 +1464,28 @@ class ConclusionScopeAio:
|
|||
size: int = 50,
|
||||
session: str | SessionBase | None = None,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
) -> AsyncPage[ConclusionResponse, Conclusion]:
|
||||
"""List conclusions in this scope asynchronously."""
|
||||
"""List conclusions in this scope asynchronously.
|
||||
|
||||
Pass ``filters`` to add criteria merged with this scope's
|
||||
observer/observed (and session, if given) — e.g.
|
||||
``{"level": "explicit"}`` to get only conclusions extracted directly
|
||||
from messages (i.e. not derived during dreaming). See
|
||||
https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
"""
|
||||
_reject_reserved_filter_keys(
|
||||
filters, _SCOPE_RESERVED + ("session", "session_id")
|
||||
)
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
resolved_session_id = resolve_id(session)
|
||||
filters: dict[str, Any] = {
|
||||
filters = {
|
||||
"observer_id": self._scope.observer,
|
||||
"observed_id": self._scope.observed,
|
||||
**({"session_id": resolved_session_id} if resolved_session_id else {}),
|
||||
**(filters or {}),
|
||||
}
|
||||
if resolved_session_id:
|
||||
filters["session_id"] = resolved_session_id
|
||||
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
|
|
@ -1504,12 +1519,24 @@ class ConclusionScopeAio:
|
|||
query: str,
|
||||
top_k: int = 10,
|
||||
distance: float | None = None,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> list[Conclusion]:
|
||||
"""Semantic search for conclusions asynchronously."""
|
||||
"""Semantic search for conclusions asynchronously.
|
||||
|
||||
Args:
|
||||
query: The search query string
|
||||
top_k: Maximum number of results to return
|
||||
distance: Maximum cosine distance threshold (0.0-1.0)
|
||||
filters: Optional dictionary of additional filter criteria, merged
|
||||
with this scope's observer/observed (e.g. ``{"level": "deductive"}``).
|
||||
"""
|
||||
_reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
|
||||
await self._scope._honcho._ensure_workspace_async()
|
||||
filters: dict[str, Any] = {
|
||||
filters = {
|
||||
"observer_id": self._scope.observer,
|
||||
"observed_id": self._scope.observed,
|
||||
**(filters or {}),
|
||||
}
|
||||
|
||||
body: dict[str, Any] = {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ from typing import Any, Literal
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# Reasoning level of a conclusion. "explicit" conclusions are extracted directly
|
||||
# from messages; the others are derived during dreaming.
|
||||
ConclusionLevel = Literal["explicit", "deductive", "inductive", "contradiction"]
|
||||
|
||||
# ==============================================================================
|
||||
# Configuration Types
|
||||
# ==============================================================================
|
||||
|
|
@ -414,6 +418,7 @@ class ConclusionResponse(BaseModel):
|
|||
observer_id: str
|
||||
observed_id: str
|
||||
session_id: str | None = None
|
||||
level: ConclusionLevel = "explicit"
|
||||
created_at: datetime.datetime
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .api_types import ConclusionResponse, RepresentationResponse
|
||||
from .api_types import ConclusionLevel, ConclusionResponse, RepresentationResponse
|
||||
from .base import SessionBase
|
||||
from .http import routes
|
||||
from .pagination import SyncPage
|
||||
|
|
@ -24,6 +24,34 @@ __all__ = [
|
|||
"ConclusionCreateParams",
|
||||
]
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
def _reject_reserved_filter_keys(
|
||||
filters: dict[str, Any] | None, reserved: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Raise if ``filters`` contains keys managed by the conclusion scope.
|
||||
|
||||
The observer/observed peer pair (and, on ``list``, the session) is fixed by
|
||||
the scope, so letting a user filter override it would silently return data
|
||||
from a different scope than requested. Fail loud instead.
|
||||
"""
|
||||
if not filters:
|
||||
return
|
||||
clash = sorted(k for k in reserved if k in filters)
|
||||
if clash:
|
||||
guidance = (
|
||||
"Choose the peer pair via peer.conclusions / peer.conclusions_of(target)"
|
||||
)
|
||||
if "session" in reserved or "session_id" in reserved:
|
||||
guidance += "; use the session= parameter to filter by session"
|
||||
raise ValueError(
|
||||
f"Filter key(s) {clash} are managed by this conclusion scope and "
|
||||
+ f"cannot be passed in filters. {guidance}."
|
||||
)
|
||||
|
||||
|
||||
class ConclusionCreateParams(BaseModel):
|
||||
content: str
|
||||
|
|
@ -43,6 +71,9 @@ class Conclusion:
|
|||
observer_id: The peer ID who made this conclusion
|
||||
observed_id: The peer ID this conclusion is about
|
||||
session_id: The session this conclusion relates to
|
||||
level: Reasoning level ("explicit", "deductive", "inductive",
|
||||
"contradiction"). "explicit" conclusions are extracted directly
|
||||
from messages; the others are derived during dreaming.
|
||||
created_at: Timestamp for when the conclusion was created
|
||||
"""
|
||||
|
||||
|
|
@ -51,6 +82,7 @@ class Conclusion:
|
|||
observer_id: str
|
||||
observed_id: str
|
||||
session_id: str | None = None
|
||||
level: ConclusionLevel = "explicit"
|
||||
created_at: datetime.datetime
|
||||
|
||||
def __init__(
|
||||
|
|
@ -61,12 +93,14 @@ class Conclusion:
|
|||
observed_id: str,
|
||||
session_id: str | None,
|
||||
created_at: datetime.datetime,
|
||||
level: ConclusionLevel = "explicit",
|
||||
) -> None:
|
||||
self.id = id
|
||||
self.content = content
|
||||
self.observer_id = observer_id
|
||||
self.observed_id = observed_id
|
||||
self.session_id = session_id
|
||||
self.level = level
|
||||
self.created_at = created_at
|
||||
|
||||
@classmethod
|
||||
|
|
@ -78,6 +112,7 @@ class Conclusion:
|
|||
observer_id=data.observer_id,
|
||||
observed_id=data.observed_id,
|
||||
session_id=data.session_id,
|
||||
level=data.level,
|
||||
created_at=data.created_at,
|
||||
)
|
||||
|
||||
|
|
@ -169,6 +204,7 @@ class ConclusionScope:
|
|||
size: int = 50,
|
||||
session: str | SessionBase | None = None,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
) -> SyncPage[ConclusionResponse, Conclusion]:
|
||||
"""
|
||||
|
|
@ -178,19 +214,28 @@ class ConclusionScope:
|
|||
page: Page number (1-indexed)
|
||||
size: Number of results per page
|
||||
session: Optional session (ID string or Session object) to filter by
|
||||
filters: Optional dictionary of additional filter criteria, merged
|
||||
with this scope's observer/observed (and session, if given).
|
||||
Supports the same operators as other list endpoints — e.g.
|
||||
``{"level": "explicit"}`` to get only conclusions extracted
|
||||
directly from messages (i.e. not derived during dreaming). See
|
||||
https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
reverse: If True, reverses the default ordering. Default: False.
|
||||
|
||||
Returns:
|
||||
Paginated response containing Conclusion objects
|
||||
"""
|
||||
_reject_reserved_filter_keys(
|
||||
filters, _SCOPE_RESERVED + ("session", "session_id")
|
||||
)
|
||||
self._honcho._ensure_workspace()
|
||||
resolved_session_id = resolve_id(session)
|
||||
filters: dict[str, Any] = {
|
||||
filters = {
|
||||
"observer_id": self.observer,
|
||||
"observed_id": self.observed,
|
||||
**({"session_id": resolved_session_id} if resolved_session_id else {}),
|
||||
**(filters or {}),
|
||||
}
|
||||
if resolved_session_id:
|
||||
filters["session_id"] = resolved_session_id
|
||||
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
|
|
@ -224,6 +269,8 @@ class ConclusionScope:
|
|||
query: str,
|
||||
top_k: int = 10,
|
||||
distance: float | None = None,
|
||||
*,
|
||||
filters: dict[str, Any] | None = None,
|
||||
) -> list[Conclusion]:
|
||||
"""
|
||||
Semantic search for conclusions in this scope.
|
||||
|
|
@ -232,14 +279,21 @@ class ConclusionScope:
|
|||
query: The search query string
|
||||
top_k: Maximum number of results to return
|
||||
distance: Maximum cosine distance threshold (0.0-1.0)
|
||||
filters: Optional dictionary of additional filter criteria, merged
|
||||
with this scope's observer/observed. Supports the same operators
|
||||
as the list endpoint — e.g. ``{"level": "deductive"}`` to search
|
||||
only conclusions derived during dreaming. See
|
||||
https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
|
||||
Returns:
|
||||
List of matching Conclusion objects
|
||||
"""
|
||||
_reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
|
||||
self._honcho._ensure_workspace()
|
||||
filters: dict[str, Any] = {
|
||||
filters = {
|
||||
"observer_id": self.observer,
|
||||
"observed_id": self.observed,
|
||||
**(filters or {}),
|
||||
}
|
||||
|
||||
body: dict[str, Any] = {
|
||||
|
|
|
|||
|
|
@ -278,6 +278,61 @@ describe('Conclusions', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Scope-reserved filter guard
|
||||
// ===========================================================================
|
||||
|
||||
describe('reserved filter keys', () => {
|
||||
test('list rejects observer/observed scope keys in filters', async () => {
|
||||
const peer = await client.peer('reserved-list-peer', { metadata: {} })
|
||||
|
||||
for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) {
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { [key]: 'someone-else' } })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
}
|
||||
})
|
||||
|
||||
test('list rejects session keys in filters (use the session option)', async () => {
|
||||
const peer = await client.peer('reserved-list-session-peer', { metadata: {} })
|
||||
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { session_id: 'sess' } })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { session: 'sess' } })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
})
|
||||
|
||||
test('query rejects observer/observed scope keys in filters', async () => {
|
||||
const peer = await client.peer('reserved-query-peer', { metadata: {} })
|
||||
|
||||
for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) {
|
||||
await expect(
|
||||
peer.conclusions.query('q', 10, undefined, { [key]: 'someone-else' })
|
||||
).rejects.toThrow(/managed by this conclusion scope/)
|
||||
}
|
||||
})
|
||||
|
||||
test('query allows session_id in filters (no dedicated session param)', async () => {
|
||||
const peer = await client.peer('reserved-query-session-peer', { metadata: {} })
|
||||
|
||||
// Should not throw the reserved-key guard; session_id is a normal filter
|
||||
// for query. The call may return no matches, which is fine.
|
||||
await expect(
|
||||
peer.conclusions.query('q', 10, undefined, { session_id: 'sess' })
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test('non-reserved filters (level) still work on list', async () => {
|
||||
const peer = await client.peer('reserved-allowed-peer', { metadata: {} })
|
||||
|
||||
await expect(
|
||||
peer.conclusions.list({ filters: { level: 'explicit' } })
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Conclusion Deletion (DELETE /conclusions/:id)
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { HonchoHTTPClient } from './http/client'
|
|||
import { Page } from './pagination'
|
||||
import type { Session } from './session'
|
||||
import type {
|
||||
ConclusionLevel,
|
||||
ConclusionResponse,
|
||||
PageResponse,
|
||||
RepresentationOptions,
|
||||
|
|
@ -10,6 +11,43 @@ import type {
|
|||
} from './types/api'
|
||||
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 SCOPE_RESERVED_KEYS = [
|
||||
'observer',
|
||||
'observed',
|
||||
'observer_id',
|
||||
'observed_id',
|
||||
]
|
||||
|
||||
/**
|
||||
* Throw if `filters` contains keys managed by the conclusion scope.
|
||||
*
|
||||
* The observer/observed peer pair (and, on `list`, the session) is fixed by the
|
||||
* scope, so letting a user filter override it would silently return data from a
|
||||
* different scope than requested. Fail loud instead.
|
||||
*/
|
||||
function rejectReservedFilterKeys(
|
||||
filters: Record<string, unknown> | undefined,
|
||||
reserved: string[]
|
||||
): void {
|
||||
if (!filters) return
|
||||
const clash = reserved.filter((k) => k in filters).sort()
|
||||
if (clash.length > 0) {
|
||||
let guidance =
|
||||
'Choose the peer pair via peer.conclusions / peer.conclusionsOf(target)'
|
||||
if (reserved.includes('session') || reserved.includes('session_id')) {
|
||||
guidance += '; use the session option to filter by session'
|
||||
}
|
||||
throw new Error(
|
||||
`Filter key(s) ${clash.join(', ')} are managed by this conclusion scope ` +
|
||||
`and cannot be passed in filters. ${guidance}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for creating a conclusion.
|
||||
*/
|
||||
|
|
@ -32,6 +70,12 @@ export class Conclusion {
|
|||
readonly observerId: string
|
||||
readonly observedId: string
|
||||
readonly sessionId: string | null
|
||||
/**
|
||||
* Reasoning level: 'explicit' conclusions are extracted directly from
|
||||
* messages; 'deductive'/'inductive'/'contradiction' are derived during
|
||||
* dreaming.
|
||||
*/
|
||||
readonly level: ConclusionLevel
|
||||
readonly createdAt: string
|
||||
|
||||
constructor(
|
||||
|
|
@ -40,13 +84,15 @@ export class Conclusion {
|
|||
observerId: string,
|
||||
observedId: string,
|
||||
sessionId: string | null,
|
||||
createdAt: string
|
||||
createdAt: string,
|
||||
level: ConclusionLevel = 'explicit'
|
||||
) {
|
||||
this.id = id
|
||||
this.content = content
|
||||
this.observerId = observerId
|
||||
this.observedId = observedId
|
||||
this.sessionId = sessionId
|
||||
this.level = level
|
||||
this.createdAt = createdAt
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +103,8 @@ export class Conclusion {
|
|||
data.observer_id,
|
||||
data.observed_id,
|
||||
data.session_id,
|
||||
data.created_at
|
||||
data.created_at,
|
||||
data.level
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -182,14 +229,26 @@ export class ConclusionScope {
|
|||
* @param options.page - Page number (1-indexed, default: 1)
|
||||
* @param options.size - Number of items per page (default: 50)
|
||||
* @param options.session - Optional session (ID string or Session object) to filter by
|
||||
* @param options.filters - Optional additional filter criteria, merged with
|
||||
* this scope's observer/observed (and session, if given). Supports the same
|
||||
* operators as other list endpoints — e.g. `{ level: 'explicit' }` to get
|
||||
* only conclusions extracted directly from messages (i.e. not derived during
|
||||
* dreaming). See
|
||||
* https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
* @returns Promise resolving to a Page of Conclusion objects
|
||||
*/
|
||||
async list(options?: {
|
||||
page?: number
|
||||
size?: number
|
||||
session?: string | Session
|
||||
filters?: Record<string, unknown>
|
||||
reverse?: boolean
|
||||
}): Promise<Page<Conclusion, ConclusionResponse>> {
|
||||
rejectReservedFilterKeys(options?.filters, [
|
||||
...SCOPE_RESERVED_KEYS,
|
||||
'session',
|
||||
'session_id',
|
||||
])
|
||||
const resolvedSessionId = options?.session
|
||||
? typeof options.session === 'string'
|
||||
? options.session
|
||||
|
|
@ -198,9 +257,8 @@ export class ConclusionScope {
|
|||
const filters: Record<string, unknown> = {
|
||||
observer_id: this.observer,
|
||||
observed_id: this.observed,
|
||||
}
|
||||
if (resolvedSessionId) {
|
||||
filters.session_id = resolvedSessionId
|
||||
...(resolvedSessionId ? { session_id: resolvedSessionId } : {}),
|
||||
...options?.filters,
|
||||
}
|
||||
const reverse = options?.reverse
|
||||
|
||||
|
|
@ -227,22 +285,32 @@ export class ConclusionScope {
|
|||
|
||||
/**
|
||||
* Semantic search for conclusions in this scope.
|
||||
*
|
||||
* @param query - The search query string
|
||||
* @param topK - Maximum number of results to return (default: 10)
|
||||
* @param distance - Maximum cosine distance threshold (0.0-1.0)
|
||||
* @param filters - Optional additional filter criteria, merged with this
|
||||
* scope's observer/observed. Supports the same operators as the list
|
||||
* endpoint — e.g. `{ level: 'deductive' }` to search only conclusions
|
||||
* derived during dreaming. See
|
||||
* https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
*/
|
||||
async query(
|
||||
query: string,
|
||||
topK: number = 10,
|
||||
distance?: number
|
||||
distance?: number,
|
||||
filters?: Record<string, unknown>
|
||||
): Promise<Conclusion[]> {
|
||||
const filters: Record<string, unknown> = {
|
||||
observer_id: this.observer,
|
||||
observed_id: this.observed,
|
||||
}
|
||||
|
||||
rejectReservedFilterKeys(filters, SCOPE_RESERVED_KEYS)
|
||||
const response = await this._query({
|
||||
query,
|
||||
top_k: topK,
|
||||
distance,
|
||||
filters,
|
||||
filters: {
|
||||
observer_id: this.observer,
|
||||
observed_id: this.observed,
|
||||
...filters,
|
||||
},
|
||||
})
|
||||
|
||||
return (response ?? []).map((item) => Conclusion.fromApiResponse(item))
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export {
|
|||
|
||||
// API types (snake_case, for advanced usage)
|
||||
export type {
|
||||
ConclusionLevel,
|
||||
ConclusionQueryParams,
|
||||
ConclusionResponse,
|
||||
MessageResponse,
|
||||
|
|
|
|||
|
|
@ -242,12 +242,23 @@ export interface MessageSearchParams {
|
|||
// Conclusion Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Reasoning level of a conclusion. "explicit" conclusions are extracted
|
||||
* directly from messages; the others are derived during dreaming.
|
||||
*/
|
||||
export type ConclusionLevel =
|
||||
| 'explicit'
|
||||
| 'deductive'
|
||||
| 'inductive'
|
||||
| 'contradiction'
|
||||
|
||||
export interface ConclusionResponse {
|
||||
id: string
|
||||
content: string
|
||||
observer_id: string
|
||||
observed_id: string
|
||||
session_id: string | null
|
||||
level: ConclusionLevel
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from src.schemas.configuration import (
|
|||
SessionPeerConfig,
|
||||
WorkspaceConfiguration,
|
||||
)
|
||||
from src.utils.types import DocumentLevel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metadata validation helpers
|
||||
|
|
@ -446,6 +447,14 @@ class Conclusion(BaseModel):
|
|||
serialization_alias="observed_id",
|
||||
)
|
||||
session_name: str | None = Field(default=None, serialization_alias="session_id")
|
||||
level: DocumentLevel = Field(
|
||||
default="explicit",
|
||||
description=(
|
||||
"Reasoning level of the conclusion: 'explicit' (directly extracted "
|
||||
"from messages) or 'deductive'/'inductive'/'contradiction' (derived "
|
||||
"during dreaming)."
|
||||
),
|
||||
)
|
||||
created_at: datetime.datetime
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
|
|
|
|||
|
|
@ -737,6 +737,7 @@ class TestConclusionRoutes:
|
|||
assert conclusion["observer_id"] == doc.observer
|
||||
assert conclusion["observed_id"] == doc.observed
|
||||
assert conclusion["session_id"] == doc.session_name
|
||||
assert conclusion["level"] == "explicit"
|
||||
assert "created_at" in conclusion
|
||||
|
||||
# Verify internal fields are NOT exposed
|
||||
|
|
@ -744,6 +745,80 @@ class TestConclusionRoutes:
|
|||
assert "internal_metadata" not in conclusion
|
||||
assert "collection" not in conclusion
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_conclusions_filter_by_level(
|
||||
self,
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""Filtering by `level` returns only conclusions at that reasoning level.
|
||||
|
||||
`level="explicit"` is the "not dreamed on" view — it excludes the
|
||||
deductive/inductive conclusions produced during dreaming.
|
||||
"""
|
||||
test_workspace, test_peer = sample_data
|
||||
|
||||
test_peer2 = models.Peer(
|
||||
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
||||
)
|
||||
db_session.add(test_peer2)
|
||||
await db_session.flush()
|
||||
|
||||
test_session = models.Session(
|
||||
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
||||
)
|
||||
db_session.add(test_session)
|
||||
await db_session.commit()
|
||||
|
||||
await self._create_collection(
|
||||
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
||||
)
|
||||
|
||||
# Two explicit, one deductive, one inductive
|
||||
levels = ["explicit", "explicit", "deductive", "inductive"]
|
||||
for i, level in enumerate(levels):
|
||||
db_session.add(
|
||||
models.Document(
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
content=f"{level} conclusion {i}",
|
||||
embedding=[0.1] * 1536,
|
||||
session_name=test_session.name,
|
||||
level=level,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
# No level filter -> all four
|
||||
all_resp = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
||||
json={"filters": {"session_id": test_session.name}},
|
||||
)
|
||||
assert all_resp.status_code == 200
|
||||
assert all_resp.json()["total"] == 4
|
||||
|
||||
# level="explicit" -> only the two non-dreamed conclusions
|
||||
explicit_resp = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
||||
json={"filters": {"session_id": test_session.name, "level": "explicit"}},
|
||||
)
|
||||
assert explicit_resp.status_code == 200
|
||||
explicit_data = explicit_resp.json()
|
||||
assert explicit_data["total"] == 2
|
||||
assert all(item["level"] == "explicit" for item in explicit_data["items"])
|
||||
|
||||
# level="deductive" -> only the one deductive conclusion
|
||||
deductive_resp = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
||||
json={"filters": {"session_id": test_session.name, "level": "deductive"}},
|
||||
)
|
||||
assert deductive_resp.status_code == 200
|
||||
deductive_data = deductive_resp.json()
|
||||
assert deductive_data["total"] == 1
|
||||
assert deductive_data["items"][0]["level"] == "deductive"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_conclusion_success(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -769,3 +769,73 @@ async def test_observation_create_mixed_session_and_sessionless(
|
|||
|
||||
assert session_obs.session_id == session.id
|
||||
assert global_obs.session_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_rejects_reserved_scope_filter_keys(
|
||||
client_fixture: tuple[Honcho, str],
|
||||
):
|
||||
"""`list` rejects observer/observed/session filter keys managed by the scope.
|
||||
|
||||
These keys are fixed by the scope (observer/observed) or by the dedicated
|
||||
``session=`` parameter, so passing them in ``filters`` would silently return
|
||||
data from a different scope. The guard raises before any HTTP call.
|
||||
"""
|
||||
honcho_client, client_type = client_fixture
|
||||
reserved = [
|
||||
"observer",
|
||||
"observed",
|
||||
"observer_id",
|
||||
"observed_id",
|
||||
"session_id",
|
||||
"session",
|
||||
]
|
||||
|
||||
if client_type == "async":
|
||||
observer = await honcho_client.aio.peer(id="test-obs-reserved-list-observer")
|
||||
target = await honcho_client.aio.peer(id="test-obs-reserved-list-target")
|
||||
obs_scope = observer.conclusions_of(target)
|
||||
for key in reserved:
|
||||
with pytest.raises(ValueError, match="managed by this conclusion scope"):
|
||||
await obs_scope.aio.list(filters={key: "someone-else"})
|
||||
# A non-reserved filter (level) is allowed through.
|
||||
await obs_scope.aio.list(filters={"level": "explicit"})
|
||||
else:
|
||||
observer = honcho_client.peer(id="test-obs-reserved-list-observer")
|
||||
target = honcho_client.peer(id="test-obs-reserved-list-target")
|
||||
obs_scope = observer.conclusions_of(target)
|
||||
for key in reserved:
|
||||
with pytest.raises(ValueError, match="managed by this conclusion scope"):
|
||||
obs_scope.list(filters={key: "someone-else"})
|
||||
obs_scope.list(filters={"level": "explicit"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_rejects_reserved_scope_filter_keys(
|
||||
client_fixture: tuple[Honcho, str],
|
||||
):
|
||||
"""`query` rejects observer/observed filter keys but allows session_id.
|
||||
|
||||
Unlike ``list``, ``query`` has no dedicated session parameter, so
|
||||
``session_id`` remains a normal filter and must NOT be rejected.
|
||||
"""
|
||||
honcho_client, client_type = client_fixture
|
||||
reserved = ["observer", "observed", "observer_id", "observed_id"]
|
||||
|
||||
if client_type == "async":
|
||||
observer = await honcho_client.aio.peer(id="test-obs-reserved-query-observer")
|
||||
target = await honcho_client.aio.peer(id="test-obs-reserved-query-target")
|
||||
obs_scope = observer.conclusions_of(target)
|
||||
for key in reserved:
|
||||
with pytest.raises(ValueError, match="managed by this conclusion scope"):
|
||||
await obs_scope.aio.query("q", filters={key: "someone-else"})
|
||||
# session_id is a normal filter for query (no dedicated param) — allowed.
|
||||
await obs_scope.aio.query("q", filters={"session_id": "some-session"})
|
||||
else:
|
||||
observer = honcho_client.peer(id="test-obs-reserved-query-observer")
|
||||
target = honcho_client.peer(id="test-obs-reserved-query-target")
|
||||
obs_scope = observer.conclusions_of(target)
|
||||
for key in reserved:
|
||||
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"})
|
||||
|
|
|
|||
Loading…
Reference in New Issue