From 5eee2eee305bf3b3919ca4af31b3e377bf79f557 Mon Sep 17 00:00:00 2001 From: ajspig Date: Wed, 29 Jul 2026 16:40:11 -0400 Subject: [PATCH] feat: exposing conclusion attribution (source & times derived) --- docs/v3/openapi.json | 53 ++++++ sdks/python/src/honcho/aio.py | 13 ++ sdks/python/src/honcho/api_types.py | 2 + sdks/python/src/honcho/conclusions.py | 30 ++++ src/models.py | 5 + src/routers/conclusions.py | 22 +++ src/schemas/api.py | 13 ++ tests/routes/test_conclusions.py | 227 ++++++++++++++++++++++++++ tests/sdk/test_conclusions.py | 69 ++++++++ 9 files changed, 434 insertions(+) diff --git a/docs/v3/openapi.json b/docs/v3/openapi.json index 85e4891a..ed9ffd42 100644 --- a/docs/v3/openapi.json +++ b/docs/v3/openapi.json @@ -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", @@ -2790,6 +2829,20 @@ "description": "Reasoning level of the conclusion: 'explicit' (directly extracted from messages) or 'deductive'/'inductive'/'contradiction' (derived during dreaming).", "default": "explicit" }, + "source_ids": { + "anyOf": [ + { "items": { "type": "string" }, "type": "array" }, + { "type": "null" } + ], + "title": "Source Ids", + "description": "IDs of the conclusions this one was derived from: premises for 'deductive', supporting sources for 'inductive', conflicting conclusions for 'contradiction'. None for 'explicit' conclusions." + }, + "times_derived": { + "type": "integer", + "title": "Times Derived", + "description": "Number of times this conclusion has been independently derived.", + "default": 1 + }, "created_at": { "type": "string", "format": "date-time", diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index 51797c72..bec0bd19 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -1600,6 +1600,19 @@ class ConclusionScopeAio: for item in data ] + async def get(self, conclusion_id: str) -> Conclusion: + """Get a single conclusion by ID asynchronously. + + Returns: + The Conclusion object, including its attribution fields + (`source_ids`, `times_derived`) + """ + await self._scope._honcho._ensure_workspace_async() + data = await self._scope._honcho._async_http_client.get( + routes.conclusion(self._scope.workspace_id, conclusion_id) + ) + return Conclusion.from_api_response(ConclusionResponse.model_validate(data)) + async def delete(self, conclusion_id: str) -> None: """Delete a conclusion by ID asynchronously.""" await self._scope._honcho._ensure_workspace_async() diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py index f626c5a6..46998118 100644 --- a/sdks/python/src/honcho/api_types.py +++ b/sdks/python/src/honcho/api_types.py @@ -419,6 +419,8 @@ class ConclusionResponse(BaseModel): observed_id: str session_id: str | None = None level: ConclusionLevel = "explicit" + source_ids: list[str] | None = None + times_derived: int = 1 created_at: datetime.datetime diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py index 708cc3ed..f38a35ee 100644 --- a/sdks/python/src/honcho/conclusions.py +++ b/sdks/python/src/honcho/conclusions.py @@ -74,6 +74,11 @@ class Conclusion: level: Reasoning level ("explicit", "deductive", "inductive", "contradiction"). "explicit" conclusions are extracted directly from messages; the others are derived during dreaming. + source_ids: IDs of the conclusions this one was derived from (premises + for "deductive", supporting sources for "inductive", conflicting + conclusions for "contradiction"). None for "explicit" conclusions. + times_derived: Number of times this conclusion has been independently + derived. created_at: Timestamp for when the conclusion was created """ @@ -83,6 +88,8 @@ class Conclusion: observed_id: str session_id: str | None = None level: ConclusionLevel = "explicit" + source_ids: list[str] | None = None + times_derived: int = 1 created_at: datetime.datetime def __init__( @@ -94,6 +101,8 @@ class Conclusion: session_id: str | None, created_at: datetime.datetime, level: ConclusionLevel = "explicit", + source_ids: list[str] | None = None, + times_derived: int = 1, ) -> None: self.id = id self.content = content @@ -101,6 +110,8 @@ class Conclusion: self.observed_id = observed_id self.session_id = session_id self.level = level + self.source_ids = source_ids + self.times_derived = times_derived self.created_at = created_at @classmethod @@ -113,6 +124,8 @@ class Conclusion: observed_id=data.observed_id, session_id=data.session_id, level=data.level, + source_ids=data.source_ids, + times_derived=data.times_derived, created_at=data.created_at, ) @@ -313,6 +326,23 @@ class ConclusionScope: for item in data ] + def get(self, conclusion_id: str) -> Conclusion: + """ + Get a single conclusion by ID. + + Args: + conclusion_id: The ID of the conclusion to retrieve + + Returns: + The Conclusion object, including its attribution fields + (`source_ids`, `times_derived`) + """ + self._honcho._ensure_workspace() + data = self._honcho._http.get( + routes.conclusion(self.workspace_id, conclusion_id) + ) + return Conclusion.from_api_response(ConclusionResponse.model_validate(data)) + def delete(self, conclusion_id: str) -> None: """ Delete a conclusion by ID. diff --git a/src/models.py b/src/models.py index 6433225a..d46f1556 100644 --- a/src/models.py +++ b/src/models.py @@ -420,6 +420,11 @@ class Document(Base): collection = relationship("Collection", back_populates="documents") + @property + def resolved_source_ids(self) -> list[str] | None: + """Source IDs, falling back to legacy internal_metadata storage.""" + return self.source_ids or (self.internal_metadata or {}).get("source_ids") + __table_args__ = ( CheckConstraint("length(id) = 21", name="id_length"), CheckConstraint("length(content) <= 65535", name="content_length"), diff --git a/src/routers/conclusions.py b/src/routers/conclusions.py index 3a25a5d7..b91fc84f 100644 --- a/src/routers/conclusions.py +++ b/src/routers/conclusions.py @@ -131,6 +131,28 @@ 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, diff --git a/src/schemas/api.py b/src/schemas/api.py index 78e5a125..ea29ff02 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -464,6 +464,19 @@ class Conclusion(BaseModel): "during dreaming)." ), ) + source_ids: list[str] | None = Field( + default=None, + validation_alias=AliasChoices("resolved_source_ids", "source_ids"), + description=( + "IDs of the conclusions this one was derived from: premises for " + "'deductive', supporting sources for 'inductive', conflicting " + "conclusions for 'contradiction'. None for 'explicit' conclusions." + ), + ) + times_derived: int = Field( + default=1, + description="Number of times this conclusion has been independently derived.", + ) created_at: datetime.datetime model_config = ConfigDict( # pyright: ignore diff --git a/tests/routes/test_conclusions.py b/tests/routes/test_conclusions.py index 77cb23fb..9bc9fb3b 100644 --- a/tests/routes/test_conclusions.py +++ b/tests/routes/test_conclusions.py @@ -660,6 +660,231 @@ class TestConclusionRoutes: data = response.json() assert "not found" in data["detail"].lower() + @pytest.mark.asyncio + async def test_get_conclusion_success( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """Test getting a single conclusion by ID with attribution fields""" + test_workspace, test_peer = sample_data + + # Create another peer + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_peer2) + await db_session.flush() + + # Create collection + await self._create_collection( + db_session, test_workspace.name, test_peer.name, test_peer2.name + ) + + # Create premise conclusions and a derived conclusion referencing them + premise1 = models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="User works late at night", + embedding=[0.1] * 1536, + ) + premise2 = models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="User prefers dark mode", + embedding=[0.1] * 1536, + ) + db_session.add_all([premise1, premise2]) + await db_session.flush() + + derived = models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="User is likely a night owl", + embedding=[0.1] * 1536, + level="deductive", + source_ids=[premise1.id, premise2.id], + times_derived=3, + ) + db_session.add(derived) + await db_session.commit() + + # Get conclusion by ID + response = client.get( + f"/v3/workspaces/{test_workspace.name}/conclusions/{derived.id}" + ) + + assert response.status_code == 200 + conclusion = response.json() + assert conclusion["id"] == derived.id + assert conclusion["content"] == "User is likely a night owl" + assert conclusion["observer_id"] == test_peer.name + assert conclusion["observed_id"] == test_peer2.name + assert conclusion["level"] == "deductive" + assert conclusion["source_ids"] == [premise1.id, premise2.id] + assert conclusion["times_derived"] == 3 + + # Verify internal fields are NOT exposed + assert "embedding" not in conclusion + assert "internal_metadata" not in conclusion + + @pytest.mark.asyncio + async def test_get_conclusion_legacy_source_ids( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """Test that legacy internal_metadata.source_ids is coalesced into source_ids""" + test_workspace, test_peer = sample_data + + # Create another peer + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_peer2) + await db_session.flush() + + # Create collection + await self._create_collection( + db_session, test_workspace.name, test_peer.name, test_peer2.name + ) + + # Legacy documents stored source_ids in internal_metadata, not the column + doc = models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="Legacy derived conclusion", + embedding=[0.1] * 1536, + level="deductive", + internal_metadata={"source_ids": ["legacy_id_1", "legacy_id_2"]}, + ) + db_session.add(doc) + await db_session.commit() + + response = client.get( + f"/v3/workspaces/{test_workspace.name}/conclusions/{doc.id}" + ) + + assert response.status_code == 200 + conclusion = response.json() + assert conclusion["source_ids"] == ["legacy_id_1", "legacy_id_2"] + + @pytest.mark.asyncio + async def test_get_conclusion_not_found( + self, + client: TestClient, + sample_data: tuple[Workspace, Peer], + ): + """Test getting a non-existent conclusion""" + test_workspace, _test_peer = sample_data + + response = client.get( + f"/v3/workspaces/{test_workspace.name}/conclusions/nonexistent_id" + ) + + assert response.status_code == 404 + data = response.json() + assert "not found" in data["detail"].lower() + + @pytest.mark.asyncio + async def test_get_conclusion_soft_deleted( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """Test that a soft-deleted conclusion returns 404""" + test_workspace, test_peer = sample_data + + # Create another peer + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_peer2) + await db_session.flush() + + # Create collection + await self._create_collection( + db_session, test_workspace.name, test_peer.name, test_peer2.name + ) + + doc = models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="Conclusion to delete", + embedding=[0.1] * 1536, + ) + db_session.add(doc) + await db_session.commit() + + delete_response = client.delete( + f"/v3/workspaces/{test_workspace.name}/conclusions/{doc.id}" + ) + assert delete_response.status_code == 204 + + response = client.get( + f"/v3/workspaces/{test_workspace.name}/conclusions/{doc.id}" + ) + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_list_conclusions_includes_attribution_fields( + self, + client: TestClient, + db_session: AsyncSession, + sample_data: tuple[Workspace, Peer], + ): + """Test that list surfaces source_ids and times_derived""" + test_workspace, test_peer = sample_data + + # Create another peer + test_peer2 = models.Peer( + name=str(generate_nanoid()), workspace_name=test_workspace.name + ) + db_session.add(test_peer2) + await db_session.flush() + + # Create collection + await self._create_collection( + db_session, test_workspace.name, test_peer.name, test_peer2.name + ) + + doc = models.Document( + workspace_name=test_workspace.name, + observer=test_peer.name, + observed=test_peer2.name, + content="Derived conclusion", + embedding=[0.1] * 1536, + level="inductive", + source_ids=["src_1", "src_2"], + times_derived=2, + ) + db_session.add(doc) + await db_session.commit() + + response = client.post( + f"/v3/workspaces/{test_workspace.name}/conclusions/list", + json={ + "filters": { + "observer_id": test_peer.name, + "observed_id": test_peer2.name, + } + }, + ) + + assert response.status_code == 200 + items = response.json()["items"] + assert len(items) == 1 + assert items[0]["source_ids"] == ["src_1", "src_2"] + assert items[0]["times_derived"] == 2 + @pytest.mark.asyncio async def test_list_conclusions_nonexistent_session( self, @@ -738,6 +963,8 @@ class TestConclusionRoutes: assert conclusion["observed_id"] == doc.observed assert conclusion["session_id"] == doc.session_name assert conclusion["level"] == "explicit" + assert conclusion["source_ids"] is None + assert conclusion["times_derived"] == 1 assert "created_at" in conclusion # Verify internal fields are NOT exposed diff --git a/tests/sdk/test_conclusions.py b/tests/sdk/test_conclusions.py index 9c2cdbc0..0c0d0d4a 100644 --- a/tests/sdk/test_conclusions.py +++ b/tests/sdk/test_conclusions.py @@ -404,6 +404,75 @@ async def test_observation_create_then_delete( assert observation_id not in listed_ids +@pytest.mark.asyncio +async def test_observation_get_by_id( + client_fixture: tuple[Honcho, str], +): + """ + Tests fetching a single conclusion by ID, including attribution fields. + """ + honcho_client, client_type = client_fixture + + if client_type == "async": + observer = await honcho_client.aio.peer(id="test-obs-get-by-id-observer") + target = await honcho_client.aio.peer(id="test-obs-get-by-id-target") + session = await honcho_client.aio.session(id="test-obs-get-by-id-session") + + # Ensure session and both peers exist + await session.aio.add_messages( + [ + observer.message("Hello from observer"), + target.message("Hello from target"), + ] + ) + + obs_scope = observer.conclusions_of(target) + created = await obs_scope.aio.create( + [{"content": "Conclusion to fetch", "session_id": session.id}] + ) + + fetched = await obs_scope.aio.get(created[0].id) + + assert isinstance(fetched, Conclusion) + assert fetched.id == created[0].id + assert fetched.content == "Conclusion to fetch" + assert fetched.observer_id == observer.id + assert fetched.observed_id == target.id + assert fetched.level == "explicit" + # User-created conclusions are explicit: no premises, derived once + assert fetched.source_ids is None + assert fetched.times_derived == 1 + else: + observer = honcho_client.peer(id="test-obs-get-by-id-observer") + target = honcho_client.peer(id="test-obs-get-by-id-target") + session = honcho_client.session(id="test-obs-get-by-id-session") + + # Ensure session and both peers exist + session.add_messages( + [ + observer.message("Hello from observer"), + target.message("Hello from target"), + ] + ) + + obs_scope = observer.conclusions_of(target) + created = obs_scope.create( + [{"content": "Conclusion to fetch", "session_id": session.id}] + ) + + fetched = obs_scope.get(created[0].id) + + assert isinstance(fetched, Conclusion) + assert fetched.id == created[0].id + assert fetched.content == "Conclusion to fetch" + assert fetched.observer_id == observer.id + assert fetched.observed_id == target.id + assert fetched.level == "explicit" + # User-created conclusions are explicit: no premises, derived once + assert fetched.source_ids is None + assert fetched.times_derived == 1 + + @pytest.mark.asyncio async def test_self_observation_create( client_fixture: tuple[Honcho, str],