feat(typescript): conclusion attribution parity with Python SDK
- Conclusion class: sourceIds + timesDerived fields (appended to the
constructor signature with defaults, so existing positional callers
are unaffected)
- ConclusionScope: get(), getMany() (batch fetch via the list endpoint
with an id-in filter, chunked at the 100 page-size cap), and derived()
(paginated reverse traversal via GET /conclusions/{id}/derived)
- ConclusionResponse type: optional source_ids / times_derived
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
96100839a7
commit
5f1752bd48
|
|
@ -333,6 +333,91 @@ describe('Conclusions', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Single Conclusion Retrieval (GET /conclusions/:id)
|
||||
// ===========================================================================
|
||||
|
||||
describe('GET /conclusions/:id (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: {} })
|
||||
|
||||
const [created] = await peer.conclusions.create({
|
||||
content: 'Conclusion to fetch',
|
||||
sessionId: session,
|
||||
})
|
||||
|
||||
const fetched = await peer.conclusions.get(created.id)
|
||||
|
||||
expect(fetched).toBeInstanceOf(Conclusion)
|
||||
expect(fetched.id).toBe(created.id)
|
||||
expect(fetched.content).toBe('Conclusion to fetch')
|
||||
expect(fetched.observerId).toBe(peer.id)
|
||||
expect(fetched.observedId).toBe(peer.id)
|
||||
expect(fetched.level).toBe('explicit')
|
||||
// User-created conclusions are explicit: no premises, derived once
|
||||
expect(fetched.sourceIds).toBeNull()
|
||||
expect(fetched.timesDerived).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Batch Retrieval (getMany)
|
||||
// ===========================================================================
|
||||
|
||||
describe('getMany', () => {
|
||||
test('fetches multiple conclusions by ID', async () => {
|
||||
const peer = await client.peer('get-many-conclusion-peer', { metadata: {} })
|
||||
const session = await client.session('get-many-conclusion-session', { metadata: {} })
|
||||
|
||||
const created = await peer.conclusions.create([
|
||||
{ content: 'First batch conclusion', sessionId: session },
|
||||
{ content: 'Second batch conclusion', sessionId: session },
|
||||
{ content: 'Third batch conclusion', sessionId: session },
|
||||
])
|
||||
|
||||
const fetched = await peer.conclusions.getMany(created.map((c) => c.id))
|
||||
|
||||
expect(fetched.length).toBe(3)
|
||||
for (const conclusion of fetched) {
|
||||
expect(conclusion).toBeInstanceOf(Conclusion)
|
||||
}
|
||||
expect(new Set(fetched.map((c) => c.id))).toEqual(
|
||||
new Set(created.map((c) => c.id))
|
||||
)
|
||||
})
|
||||
|
||||
test('empty input returns empty array', async () => {
|
||||
const peer = await client.peer('get-many-empty-peer', { metadata: {} })
|
||||
|
||||
const fetched = await peer.conclusions.getMany([])
|
||||
|
||||
expect(fetched).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Derived Conclusions (GET /conclusions/:id/derived)
|
||||
// ===========================================================================
|
||||
|
||||
describe('GET /conclusions/:id/derived', () => {
|
||||
test('leaf conclusion has no derived conclusions', async () => {
|
||||
const peer = await client.peer('derived-conclusion-peer', { metadata: {} })
|
||||
const session = await client.session('derived-conclusion-session', { metadata: {} })
|
||||
|
||||
// User-created (explicit) conclusions have nothing derived from them
|
||||
const [created] = await peer.conclusions.create({
|
||||
content: 'Leaf conclusion',
|
||||
sessionId: session,
|
||||
})
|
||||
|
||||
const page = await peer.conclusions.derived(created.id)
|
||||
|
||||
expect(page.items).toEqual([])
|
||||
expect(page.total).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Conclusion Deletion (DELETE /conclusions/:id)
|
||||
// ===========================================================================
|
||||
|
|
@ -429,6 +514,29 @@ describe('Conclusions', () => {
|
|||
expect(conclusion.observedId).toBe('observed')
|
||||
expect(conclusion.sessionId).toBe('session')
|
||||
expect(conclusion.createdAt).toBe('2024-01-15T10:00:00Z')
|
||||
// Attribution fields default when absent from the response
|
||||
expect(conclusion.sourceIds).toBeNull()
|
||||
expect(conclusion.timesDerived).toBe(1)
|
||||
})
|
||||
|
||||
test('fromApiResponse carries attribution fields', () => {
|
||||
const response = {
|
||||
id: 'derived-id',
|
||||
content: 'Derived conclusion',
|
||||
observer_id: 'observer',
|
||||
observed_id: 'observed',
|
||||
session_id: null,
|
||||
level: 'deductive' as const,
|
||||
source_ids: ['premise-1', 'premise-2'],
|
||||
times_derived: 3,
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
}
|
||||
|
||||
const conclusion = Conclusion.fromApiResponse(response)
|
||||
|
||||
expect(conclusion.level).toBe('deductive')
|
||||
expect(conclusion.sourceIds).toEqual(['premise-1', 'premise-2'])
|
||||
expect(conclusion.timesDerived).toBe(3)
|
||||
})
|
||||
|
||||
test('toString returns readable format', async () => {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,14 @@ export class Conclusion {
|
|||
* dreaming.
|
||||
*/
|
||||
readonly level: ConclusionLevel
|
||||
/**
|
||||
* IDs of the conclusions this one was derived from (premises for
|
||||
* 'deductive', supporting sources for 'inductive', conflicting conclusions
|
||||
* for 'contradiction'). Null for 'explicit' conclusions.
|
||||
*/
|
||||
readonly sourceIds: string[] | null
|
||||
/** Number of times this conclusion has been independently derived. */
|
||||
readonly timesDerived: number
|
||||
readonly createdAt: string
|
||||
|
||||
constructor(
|
||||
|
|
@ -85,7 +93,9 @@ export class Conclusion {
|
|||
observedId: string,
|
||||
sessionId: string | null,
|
||||
createdAt: string,
|
||||
level: ConclusionLevel = 'explicit'
|
||||
level: ConclusionLevel = 'explicit',
|
||||
sourceIds: string[] | null = null,
|
||||
timesDerived: number = 1
|
||||
) {
|
||||
this.id = id
|
||||
this.content = content
|
||||
|
|
@ -93,6 +103,8 @@ export class Conclusion {
|
|||
this.observedId = observedId
|
||||
this.sessionId = sessionId
|
||||
this.level = level
|
||||
this.sourceIds = sourceIds
|
||||
this.timesDerived = timesDerived
|
||||
this.createdAt = createdAt
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +116,9 @@ export class Conclusion {
|
|||
data.observed_id,
|
||||
data.session_id,
|
||||
data.created_at,
|
||||
data.level
|
||||
data.level,
|
||||
data.source_ids ?? null,
|
||||
data.times_derived ?? 1
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +207,34 @@ 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}`
|
||||
)
|
||||
}
|
||||
|
||||
private async _derived(
|
||||
conclusionId: string,
|
||||
params: {
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
): Promise<PageResponse<ConclusionResponse>> {
|
||||
await this._ensureWorkspace()
|
||||
return this._http.get<PageResponse<ConclusionResponse>>(
|
||||
`/${API_VERSION}/workspaces/${this.workspaceId}/conclusions/${conclusionId}/derived`,
|
||||
{
|
||||
query: {
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
reverse: params.reverse ? 'true' : undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private async _delete(conclusionId: string): Promise<void> {
|
||||
await this._ensureWorkspace()
|
||||
await this._http.delete(
|
||||
|
|
@ -233,7 +275,8 @@ export class ConclusionScope {
|
|||
* 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
|
||||
* dreaming), or `{ source_ids: { contains: '<id>' } }` to get conclusions
|
||||
* derived from a given conclusion (see also `derived()`). See
|
||||
* https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
|
||||
* @returns Promise resolving to a Page of Conclusion objects
|
||||
*/
|
||||
|
|
@ -316,6 +359,90 @@ export class ConclusionScope {
|
|||
return (response ?? []).map((item) => Conclusion.fromApiResponse(item))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single conclusion by ID.
|
||||
*
|
||||
* @param conclusionId - The ID of the conclusion to retrieve
|
||||
* @returns Promise resolving to the Conclusion object, including its
|
||||
* attribution fields (`sourceIds`, `timesDerived`)
|
||||
*/
|
||||
async get(conclusionId: string): Promise<Conclusion> {
|
||||
const response = await this._get(conclusionId)
|
||||
return Conclusion.fromApiResponse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple conclusions by ID in a single call.
|
||||
*
|
||||
* Useful for resolving a derived conclusion's premises: pass its
|
||||
* `sourceIds` to fetch all of them at once instead of one `get()` per ID.
|
||||
*
|
||||
* @param conclusionIds - The IDs of the conclusions to retrieve
|
||||
* @returns Promise resolving to the matching Conclusion objects. IDs that
|
||||
* don't exist are omitted, so the result may be shorter than the input
|
||||
* (order is not guaranteed to match the input either).
|
||||
*/
|
||||
async getMany(conclusionIds: string[]): Promise<Conclusion[]> {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conclusions derived from the given conclusion — i.e. those that
|
||||
* list it in their `sourceIds`. Traverses the reasoning tree upward
|
||||
* (source -> derived).
|
||||
*
|
||||
* @param conclusionId - The ID of the source conclusion
|
||||
* @param options - Optional configuration for the request
|
||||
* @param options.page - Page number (1-indexed, default: 1)
|
||||
* @param options.size - Number of items per page (default: 50)
|
||||
* @param options.reverse - If true, reverses the default newest-first ordering
|
||||
* @returns Promise resolving to a Page of Conclusion objects
|
||||
*/
|
||||
async derived(
|
||||
conclusionId: string,
|
||||
options?: {
|
||||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
): Promise<Page<Conclusion, ConclusionResponse>> {
|
||||
const reverse = options?.reverse
|
||||
const response = await this._derived(conclusionId, {
|
||||
page: options?.page ?? 1,
|
||||
size: options?.size ?? 50,
|
||||
reverse,
|
||||
})
|
||||
|
||||
const fetchNextPage = async (
|
||||
page: number,
|
||||
size: number
|
||||
): Promise<PageResponse<ConclusionResponse>> => {
|
||||
return this._derived(conclusionId, { page, size, reverse })
|
||||
}
|
||||
|
||||
return new Page(
|
||||
response,
|
||||
(item) => Conclusion.fromApiResponse(item),
|
||||
fetchNextPage
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conclusion by ID.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -260,6 +260,8 @@ export interface ConclusionResponse {
|
|||
observed_id: string
|
||||
session_id: string | null
|
||||
level: ConclusionLevel
|
||||
source_ids?: string[] | null
|
||||
times_derived?: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue