feat: exposing conclusion attribution (source & times derived)

This commit is contained in:
ajspig 2026-07-29 16:40:11 -04:00
parent 4d3ab1c36b
commit 5eee2eee30
9 changed files with 434 additions and 0 deletions

View File

@ -2423,6 +2423,45 @@
}
},
"/v3/workspaces/{workspace_id}/conclusions/{conclusion_id}": {
"get": {
"tags": ["conclusions"],
"summary": "Get Conclusion",
"description": "Get a single Conclusion by ID.",
"operationId": "get_conclusion_v3_workspaces__workspace_id__conclusions__conclusion_id__get",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "conclusion_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Conclusion Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Conclusion" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
},
"delete": {
"tags": ["conclusions"],
"summary": "Delete Conclusion",
@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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