feat: exposing conclusion attribution (source & times derived)
This commit is contained in:
parent
4d3ab1c36b
commit
5eee2eee30
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in New Issue