1865 lines
62 KiB
Python
1865 lines
62 KiB
Python
import datetime
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from nanoid import generate as generate_nanoid
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src import models
|
|
from src.models import Peer, Workspace
|
|
|
|
|
|
class TestConclusionRoutes:
|
|
"""Test suite for conclusion API endpoints"""
|
|
|
|
async def _create_collection(
|
|
self,
|
|
db_session: AsyncSession,
|
|
workspace_name: str,
|
|
observer: str,
|
|
observed: str,
|
|
) -> models.Collection:
|
|
"""Helper to create collection for tests"""
|
|
collection = models.Collection(
|
|
workspace_name=workspace_name,
|
|
observer=observer,
|
|
observed=observed,
|
|
)
|
|
db_session.add(collection)
|
|
await db_session.flush()
|
|
return collection
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_conclusions_success(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test listing conclusions for a session"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create collection
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
# Create test conclusions (documents)
|
|
doc1 = models.Document(
|
|
workspace_name=test_workspace.name,
|
|
observer=test_peer.name,
|
|
observed=test_peer2.name,
|
|
content="User prefers dark mode",
|
|
session_name=test_session.name,
|
|
)
|
|
doc2 = models.Document(
|
|
workspace_name=test_workspace.name,
|
|
observer=test_peer.name,
|
|
observed=test_peer2.name,
|
|
content="User works late at night",
|
|
session_name=test_session.name,
|
|
)
|
|
db_session.add_all([doc1, doc2])
|
|
await db_session.commit()
|
|
|
|
# List conclusions
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
|
json={"filters": {"session_id": test_session.name}},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "items" in data
|
|
assert len(data["items"]) == 2
|
|
|
|
# Check conclusion structure
|
|
conclusion = data["items"][0]
|
|
assert "id" in conclusion
|
|
assert "content" in conclusion
|
|
assert "observer_id" in conclusion
|
|
assert "observed_id" in conclusion
|
|
assert "session_id" in conclusion
|
|
assert "created_at" in conclusion
|
|
|
|
# Verify content
|
|
contents = [item["content"] for item in data["items"]]
|
|
assert "User prefers dark mode" in contents
|
|
assert "User works late at night" in contents
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_conclusions_empty_session(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test listing conclusions for a session with no conclusions"""
|
|
test_workspace, _test_peer = sample_data
|
|
|
|
# Create a session without any conclusions
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# List conclusions
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
|
json={"filters": {"session_id": test_session.name}},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "items" in data
|
|
assert len(data["items"]) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_conclusions_with_filters(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test listing conclusions with observer/observed filters"""
|
|
test_workspace, test_peer = sample_data
|
|
|
|
# Create two more peers
|
|
test_peer2 = models.Peer(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
test_peer3 = models.Peer(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add_all([test_peer2, test_peer3])
|
|
await db_session.flush()
|
|
|
|
# Create a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create collections for both observer/observed pairs
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer2.name, test_peer3.name
|
|
)
|
|
|
|
# Create conclusions with different observer/observed pairs
|
|
doc1 = models.Document(
|
|
workspace_name=test_workspace.name,
|
|
observer=test_peer.name,
|
|
observed=test_peer2.name,
|
|
content="Peer1 observes Peer2",
|
|
session_name=test_session.name,
|
|
)
|
|
doc2 = models.Document(
|
|
workspace_name=test_workspace.name,
|
|
observer=test_peer2.name,
|
|
observed=test_peer3.name,
|
|
content="Peer2 observes Peer3",
|
|
session_name=test_session.name,
|
|
)
|
|
db_session.add_all([doc1, doc2])
|
|
await db_session.commit()
|
|
|
|
# List conclusions filtered by observer
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
|
json={
|
|
"filters": {"observer": test_peer.name, "session_id": test_session.name}
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 1
|
|
assert data["items"][0]["content"] == "Peer1 observes Peer2"
|
|
assert data["items"][0]["observer_id"] == test_peer.name
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_conclusions_reverse_order(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test listing conclusions in reverse chronological order"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create collection
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
# Create conclusions
|
|
doc1 = models.Document(
|
|
workspace_name=test_workspace.name,
|
|
observer=test_peer.name,
|
|
observed=test_peer2.name,
|
|
content="First conclusion",
|
|
embedding=[0.1] * 1536,
|
|
session_name=test_session.name,
|
|
)
|
|
db_session.add(doc1)
|
|
await db_session.flush()
|
|
|
|
doc2 = models.Document(
|
|
workspace_name=test_workspace.name,
|
|
observer=test_peer.name,
|
|
observed=test_peer2.name,
|
|
content="Second conclusion",
|
|
embedding=[0.2] * 1536,
|
|
session_name=test_session.name,
|
|
)
|
|
db_session.add(doc2)
|
|
await db_session.commit()
|
|
|
|
# List conclusions in reverse (oldest first)
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list?reverse=true",
|
|
json={"filters": {"session_id": test_session.name}},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 2
|
|
assert data["items"][0]["content"] == "First conclusion"
|
|
assert data["items"][1]["content"] == "Second conclusion"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_conclusions_pagination(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test pagination of conclusions list"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create collection
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
# Create multiple conclusions
|
|
for i in range(15):
|
|
doc = models.Document(
|
|
workspace_name=test_workspace.name,
|
|
observer=test_peer.name,
|
|
observed=test_peer2.name,
|
|
content=f"Conclusion {i}",
|
|
embedding=[0.1 * i] * 1536,
|
|
session_name=test_session.name,
|
|
)
|
|
db_session.add(doc)
|
|
await db_session.commit()
|
|
|
|
# Get first page (default size)
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list?page=1&size=10",
|
|
json={"filters": {"session_id": test_session.name}},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 10
|
|
assert data["total"] == 15
|
|
|
|
# Get second page
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list?page=2&size=10",
|
|
json={"filters": {"session_id": test_session.name}},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 5
|
|
assert data["total"] == 15
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_conclusions_success(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test querying conclusions with semantic search"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create test conclusions via API (ensures proper vector store integration)
|
|
create_response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "User loves pizza and pasta",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
{
|
|
"content": "User dislikes vegetables",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
]
|
|
},
|
|
)
|
|
assert create_response.status_code == 201
|
|
|
|
# Query conclusions
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/query",
|
|
json={
|
|
"query": "food preferences",
|
|
"filters": {
|
|
"observer": test_peer.name,
|
|
"observed": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) >= 1 # pyright: ignore
|
|
|
|
# Check conclusion structure
|
|
conclusion = data[0] # pyright: ignore
|
|
assert "id" in conclusion
|
|
assert "content" in conclusion
|
|
assert "observer_id" in conclusion
|
|
assert "observed_id" in conclusion
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_conclusions_with_top_k(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test querying conclusions with top_k limit"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create multiple conclusions via API (ensures proper vector store integration)
|
|
conclusions = [
|
|
{
|
|
"content": f"Conclusion about topic {i}",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
}
|
|
for i in range(5)
|
|
]
|
|
create_response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={"conclusions": conclusions},
|
|
)
|
|
assert create_response.status_code == 201
|
|
|
|
# Query with top_k=2
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/query",
|
|
json={
|
|
"query": "relevant topic",
|
|
"top_k": 2,
|
|
"filters": {
|
|
"observer": test_peer.name,
|
|
"observed": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) <= 2 # pyright: ignore
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_conclusions_with_distance_threshold(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test querying conclusions with distance threshold"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create test conclusion via API (ensures proper vector store integration)
|
|
create_response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "Test conclusion",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
assert create_response.status_code == 201
|
|
|
|
# Query with distance threshold
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/query",
|
|
json={
|
|
"query": "test",
|
|
"distance": 0.8,
|
|
"filters": {
|
|
"observer": test_peer.name,
|
|
"observed": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_conclusions_requires_observer_observed(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test query conclusions requires observer and observed in filters"""
|
|
test_workspace, _test_peer = sample_data
|
|
|
|
# Create a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Query without observer/observed filters should fail
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/query",
|
|
json={"query": "test"},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_conclusions_invalid_top_k(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test query conclusions validates top_k range"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Query with invalid top_k (too high)
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/query",
|
|
json={
|
|
"query": "test",
|
|
"top_k": 101, # Max is 100
|
|
"filters": {
|
|
"observer": test_peer.name,
|
|
"observed": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_conclusion_success(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test deleting a conclusion"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create collection
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
# Create a test conclusion
|
|
doc = models.Document(
|
|
workspace_name=test_workspace.name,
|
|
observer=test_peer.name,
|
|
observed=test_peer2.name,
|
|
content="Test conclusion to delete",
|
|
embedding=[0.1] * 1536,
|
|
session_name=test_session.name,
|
|
)
|
|
db_session.add(doc)
|
|
await db_session.commit()
|
|
|
|
conclusion_id = doc.id
|
|
|
|
# Delete conclusion
|
|
response = client.delete(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/{conclusion_id}"
|
|
)
|
|
|
|
assert response.status_code == 204
|
|
|
|
# Verify conclusion is deleted
|
|
from sqlalchemy import select
|
|
|
|
stmt = select(models.Document).where(models.Document.id == conclusion_id)
|
|
result = await db_session.execute(stmt)
|
|
doc = result.scalar_one_or_none()
|
|
assert doc is not None
|
|
assert doc.deleted_at is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_conclusion_not_found(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test deleting a non-existent conclusion"""
|
|
test_workspace, _test_peer = sample_data
|
|
|
|
# Create a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Try to delete non-existent conclusion
|
|
response = client.delete(
|
|
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_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,
|
|
client: TestClient,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test listing conclusions for non-existent session"""
|
|
test_workspace, _test_peer = sample_data
|
|
|
|
# Try to list conclusions for non-existent session
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
|
json={"filters": {"session_id": "nonexistent_session"}},
|
|
)
|
|
|
|
# Should return empty result, not error (session might exist but no conclusions)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conclusions_field_mapping(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test that conclusion fields are properly mapped from document model"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create collection
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
# Create test conclusion
|
|
doc = models.Document(
|
|
workspace_name=test_workspace.name,
|
|
observer=test_peer.name,
|
|
observed=test_peer2.name,
|
|
content="Test conclusion content",
|
|
embedding=[0.1] * 1536,
|
|
session_name=test_session.name,
|
|
)
|
|
db_session.add(doc)
|
|
await db_session.commit()
|
|
|
|
# List conclusions
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
|
json={"filters": {"session_id": test_session.name}},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
conclusion = data["items"][0]
|
|
|
|
# Verify field mappings
|
|
assert conclusion["id"] == doc.id
|
|
assert conclusion["content"] == doc.content
|
|
assert conclusion["observer_id"] == doc.observer
|
|
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
|
|
assert "embedding" not in conclusion
|
|
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,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test creating a single conclusion"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create conclusion via API
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "User prefers dark mode",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert len(data) == 1
|
|
|
|
conclusion = data[0]
|
|
assert conclusion["content"] == "User prefers dark mode"
|
|
assert conclusion["observer_id"] == test_peer.name
|
|
assert conclusion["observed_id"] == test_peer2.name
|
|
assert conclusion["session_id"] == test_session.name
|
|
assert "id" in conclusion
|
|
assert "created_at" in conclusion
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_conclusions_batch(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test creating multiple conclusions in batch"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create multiple conclusions via API
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "User prefers dark mode",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
{
|
|
"content": "User works late at night",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
{
|
|
"content": "User enjoys programming",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert len(data) == 3
|
|
|
|
contents = [obs["content"] for obs in data]
|
|
assert "User prefers dark mode" in contents
|
|
assert "User works late at night" in contents
|
|
assert "User enjoys programming" in contents
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_conclusion_nonexistent_session(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test creating conclusion with non-existent session fails"""
|
|
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.commit()
|
|
|
|
# Try to create conclusion with non-existent session
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "Test conclusion",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": "nonexistent_session",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_conclusion_nonexistent_peer(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test creating conclusion with non-existent peer fails"""
|
|
test_workspace, test_peer = sample_data
|
|
|
|
# Create a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Try to create conclusion with non-existent observer
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "Test conclusion",
|
|
"observer_id": "nonexistent_peer",
|
|
"observed_id": test_peer.name,
|
|
"session_id": test_session.name,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_conclusion_empty_content(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test creating conclusion with empty content fails validation"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Try to create conclusion with empty content
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_conclusion_empty_list(
|
|
self,
|
|
client: TestClient,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test creating conclusions with empty list fails validation"""
|
|
test_workspace, _test_peer = sample_data
|
|
|
|
# Try to create with empty conclusions list
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={"conclusions": []},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_conclusion_creates_collection(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test that creating conclusion auto-creates collection if needed"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create conclusion via API (this should auto-create collection)
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "Test conclusion",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert len(data) == 1
|
|
|
|
# The conclusion was created successfully, which means the collection
|
|
# was created (since documents require a collection)
|
|
conclusion = data[0]
|
|
assert conclusion["observer_id"] == test_peer.name
|
|
assert conclusion["observed_id"] == test_peer2.name
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_conclusion_different_observer_observed_pairs(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test creating conclusions with different observer/observed pairs in single batch"""
|
|
test_workspace, test_peer = sample_data
|
|
|
|
# Create two more peers
|
|
test_peer2 = models.Peer(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
test_peer3 = models.Peer(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add_all([test_peer2, test_peer3])
|
|
await db_session.flush()
|
|
|
|
# Create a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create conclusions with different observer/observed pairs
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "Peer1 observes Peer2",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
{
|
|
"content": "Peer2 observes Peer3",
|
|
"observer_id": test_peer2.name,
|
|
"observed_id": test_peer3.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert len(data) == 2
|
|
|
|
# Verify each conclusion has correct observer/observed
|
|
obs1 = next(o for o in data if o["content"] == "Peer1 observes Peer2")
|
|
assert obs1["observer_id"] == test_peer.name
|
|
assert obs1["observed_id"] == test_peer2.name
|
|
|
|
obs2 = next(o for o in data if o["content"] == "Peer2 observes Peer3")
|
|
assert obs2["observer_id"] == test_peer2.name
|
|
assert obs2["observed_id"] == test_peer3.name
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_created_conclusions_are_searchable(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test that created conclusions can be found via list endpoint"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create conclusion via API
|
|
create_response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "Unique test content for searchability",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
assert create_response.status_code == 201
|
|
created_id = create_response.json()[0]["id"]
|
|
|
|
# List conclusions and verify the created one is there
|
|
list_response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
|
json={
|
|
"filters": {
|
|
"observer": test_peer.name,
|
|
"observed": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
}
|
|
},
|
|
)
|
|
|
|
assert list_response.status_code == 200
|
|
data = list_response.json()
|
|
ids = [obs["id"] for obs in data["items"]]
|
|
assert created_id in ids
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_conclusion_without_session_id(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test creating a conclusion without session_id (sessionless/global conclusion)"""
|
|
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.commit()
|
|
|
|
# Create conclusion without session_id
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "User prefers dark mode (global)",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
# No session_id - this is the key test
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert len(data) == 1
|
|
|
|
conclusion = data[0]
|
|
assert conclusion["content"] == "User prefers dark mode (global)"
|
|
assert conclusion["observer_id"] == test_peer.name
|
|
assert conclusion["observed_id"] == test_peer2.name
|
|
assert conclusion["session_id"] is None # Should be null
|
|
assert "id" in conclusion
|
|
assert "created_at" in conclusion
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_conclusions_mixed_session_and_sessionless(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test creating a batch with both session-scoped and sessionless conclusions"""
|
|
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 a session
|
|
test_session = models.Session(
|
|
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
|
)
|
|
db_session.add(test_session)
|
|
await db_session.commit()
|
|
|
|
# Create mixed batch: one with session, one without
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "Session-scoped conclusion",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
"session_id": test_session.name,
|
|
},
|
|
{
|
|
"content": "Global conclusion without session",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
# No session_id
|
|
},
|
|
]
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert len(data) == 2
|
|
|
|
# Find conclusions by content
|
|
session_conclusion = next(
|
|
c for c in data if c["content"] == "Session-scoped conclusion"
|
|
)
|
|
global_conclusion = next(
|
|
c for c in data if c["content"] == "Global conclusion without session"
|
|
)
|
|
|
|
assert session_conclusion["session_id"] == test_session.name
|
|
assert global_conclusion["session_id"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sessionless_conclusions(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test that sessionless conclusions can be listed without session filter"""
|
|
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.commit()
|
|
|
|
# Create sessionless conclusion
|
|
create_response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions",
|
|
json={
|
|
"conclusions": [
|
|
{
|
|
"content": "Sessionless conclusion for list test",
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
assert create_response.status_code == 201
|
|
created_id = create_response.json()[0]["id"]
|
|
|
|
# List all conclusions (no session filter)
|
|
list_response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
|
json={
|
|
"filters": {
|
|
"observer_id": test_peer.name,
|
|
"observed_id": test_peer2.name,
|
|
}
|
|
},
|
|
)
|
|
|
|
assert list_response.status_code == 200
|
|
data = list_response.json()
|
|
ids = [obs["id"] for obs in data["items"]]
|
|
assert created_id in ids
|
|
|
|
# Verify the conclusion has null session_id
|
|
conclusion = next(c for c in data["items"] if c["id"] == created_id)
|
|
assert conclusion["session_id"] is None
|
|
|
|
async def _create_reasoning_tree(
|
|
self,
|
|
db_session: AsyncSession,
|
|
workspace_name: str,
|
|
observer: str,
|
|
observed: str,
|
|
) -> tuple[models.Document, models.Document, models.Document]:
|
|
"""Helper to create a premise with two derived conclusions referencing it.
|
|
|
|
Returns (premise, derived1, derived2), where derived2 is newer.
|
|
"""
|
|
# Explicit created_at values: rows created in one transaction all get
|
|
# the same now(), which would make recency ordering unstable.
|
|
base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
|
|
premise = models.Document(
|
|
workspace_name=workspace_name,
|
|
observer=observer,
|
|
observed=observed,
|
|
content="User works late at night",
|
|
embedding=[0.1] * 1536,
|
|
created_at=base,
|
|
)
|
|
db_session.add(premise)
|
|
await db_session.flush()
|
|
|
|
derived1 = models.Document(
|
|
workspace_name=workspace_name,
|
|
observer=observer,
|
|
observed=observed,
|
|
content="User is likely a night owl",
|
|
embedding=[0.1] * 1536,
|
|
level="deductive",
|
|
source_ids=[premise.id],
|
|
created_at=base + datetime.timedelta(minutes=1),
|
|
)
|
|
db_session.add(derived1)
|
|
await db_session.flush()
|
|
|
|
derived2 = models.Document(
|
|
workspace_name=workspace_name,
|
|
observer=observer,
|
|
observed=observed,
|
|
content="User tends to respond slowly in the mornings",
|
|
embedding=[0.1] * 1536,
|
|
level="inductive",
|
|
source_ids=[premise.id, derived1.id],
|
|
times_derived=2,
|
|
created_at=base + datetime.timedelta(minutes=2),
|
|
)
|
|
db_session.add(derived2)
|
|
await db_session.commit()
|
|
return premise, derived1, derived2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_derived_conclusions_success(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test traversing the reasoning tree upward via /derived"""
|
|
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()
|
|
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
premise, derived1, derived2 = await self._create_reasoning_tree(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
response = client.get(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/{premise.id}/derived"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 2
|
|
ids = [item["id"] for item in data["items"]]
|
|
# Newest first by default
|
|
assert ids == [derived2.id, derived1.id]
|
|
|
|
# Attribution fields are present on the children
|
|
child = next(item for item in data["items"] if item["id"] == derived2.id)
|
|
assert child["level"] == "inductive"
|
|
assert child["source_ids"] == [premise.id, derived1.id]
|
|
assert child["times_derived"] == 2
|
|
|
|
# derived1 is itself a source of derived2
|
|
response = client.get(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/{derived1.id}/derived"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert [item["id"] for item in data["items"]] == [derived2.id]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_derived_conclusions_reverse(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test that reverse=true returns oldest first"""
|
|
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()
|
|
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
premise, derived1, derived2 = await self._create_reasoning_tree(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
response = client.get(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/{premise.id}/derived",
|
|
params={"reverse": "true"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
ids = [item["id"] for item in response.json()["items"]]
|
|
assert ids == [derived1.id, derived2.id]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_derived_conclusions_leaf_is_empty(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test that a conclusion nothing was derived from returns an empty page"""
|
|
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()
|
|
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
_premise, _derived1, derived2 = await self._create_reasoning_tree(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
response = client.get(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/{derived2.id}/derived"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 0
|
|
assert data["items"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_derived_conclusions_not_found(
|
|
self,
|
|
client: TestClient,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test 404 when the source conclusion doesn't exist"""
|
|
test_workspace, _test_peer = sample_data
|
|
|
|
response = client.get(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/{generate_nanoid()}/derived"
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert "not found" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_conclusions_filter_by_source_ids_contains(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test filtering the list endpoint by source_ids membership"""
|
|
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()
|
|
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
premise, derived1, derived2 = await self._create_reasoning_tree(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
|
json={"filters": {"source_ids": {"contains": premise.id}}},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
ids = {item["id"] for item in response.json()["items"]}
|
|
assert ids == {derived1.id, derived2.id}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_conclusions_filter_by_ids(
|
|
self,
|
|
client: TestClient,
|
|
db_session: AsyncSession,
|
|
sample_data: tuple[Workspace, Peer],
|
|
):
|
|
"""Test batch-fetching conclusions by ID through the list endpoint"""
|
|
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()
|
|
|
|
await self._create_collection(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
premise, derived1, derived2 = await self._create_reasoning_tree(
|
|
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
|
)
|
|
|
|
# Fetch derived2's premises the way an SDK tree walk would
|
|
response = client.post(
|
|
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
|
json={"filters": {"id": {"in": [premise.id, derived1.id]}}},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
ids = {item["id"] for item in response.json()["items"]}
|
|
assert ids == {premise.id, derived1.id}
|
|
assert derived2.id not in ids
|