Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 145 additions & 2 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ click = "^8.1.7"
httpx = "^0.27.0"
tenacity = "<=9.0.0"
pyjwt = "^2.10.1"
pydantic = "^2.10.6"



Expand Down
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[pytest]
addopts = -s
asyncio_default_fixture_loop_scope = function
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get a Deprecation warning when running tests (poetry run pytest --cov=quotientai tests/ --cov-report term-missing):

PytestDeprecationWarning: The configuration option "asyncio_default_fixture_loop_scope" is unset.
The event loop scope for asynchronous fixtures will default to the fixture caching scope. Future versions of pytest-asyncio will default the loop scope for asynchronous fixtures to function scope. Set the default fixture loop scope explicitly in order to avoid unexpected behavior in the future. Valid fixture loop scopes are: "function", "class", "module", "package", "session"

Setting it explicitly makes it go away. Setting the event loop scope to function means a new event loop will be created for each test function that uses an async fixture

19 changes: 17 additions & 2 deletions quotientai/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@
import random
import json
import time
import logging
from pathlib import Path
import jwt
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union

import httpx

from quotientai import resources
from quotientai.exceptions import QuotientAIError, handle_async_errors
from quotientai.resources.prompts import Prompt
from quotientai.resources.logs import LogDocument
from quotientai.resources.models import Model
from quotientai.resources.datasets import Dataset
from quotientai.resources.runs import Run
Expand Down Expand Up @@ -218,7 +220,7 @@ async def log(
*,
user_query: str,
model_output: str,
documents: Optional[List[str]] = None,
documents: Optional[List[Union[str, LogDocument]]] = None,
message_history: Optional[List[Dict[str, Any]]] = None,
instructions: Optional[List[str]] = None,
tags: Optional[Dict[str, Any]] = {},
Expand Down Expand Up @@ -251,6 +253,19 @@ async def log(
else self.inconsistency_detection
)

# Validate documents format
if documents:
for doc in documents:
if isinstance(doc, str):
continue
elif isinstance(doc, dict):
try:
LogDocument(**doc)
except Exception as _:
raise QuotientAIError("Documents must be a list of strings or dictionaries with 'page_content' and optional 'metadata' keys. Metadata keys must be strings")
else:
raise QuotientAIError(f"Documents must be a list of strings or dictionaries with 'page_content' and optional 'metadata' keys, got {type(doc)}")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make these more actionable? Like in TS


if self._should_sample():
await self.logs_resource.create(
app_name=self.app_name,
Expand Down
20 changes: 17 additions & 3 deletions quotientai/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
import os
import random
import time

import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union

import jwt

import httpx

from quotientai import resources
from quotientai.exceptions import QuotientAIError, handle_errors
from quotientai.resources.logs import LogDocument
from quotientai.resources.prompts import Prompt
from quotientai.resources.models import Model
from quotientai.resources.datasets import Dataset
Expand Down Expand Up @@ -219,7 +220,7 @@ def log(
*,
user_query: str,
model_output: str,
documents: Optional[List[str]] = None,
documents: List[Union[str, LogDocument]] = None,
message_history: Optional[List[Dict[str, Any]]] = None,
instructions: Optional[List[str]] = None,
tags: Optional[Dict[str, Any]] = {},
Expand Down Expand Up @@ -252,6 +253,19 @@ def log(
else self.inconsistency_detection
)

# Validate documents format
if documents:
for doc in documents:
if isinstance(doc, str):
continue
elif isinstance(doc, dict):
try:
LogDocument(**doc)
except Exception as _:
raise QuotientAIError(f"Documents must be a list of strings or dictionaries with 'page_content' and optional 'metadata' keys. Metadata keys must be strings")
else:
raise QuotientAIError(f"Documents must be a list of strings or dictionaries with 'page_content' and optional 'metadata' keys, got {type(doc)}")

if self._should_sample():
self.logs_resource.create(
app_name=self.app_name,
Expand Down
14 changes: 11 additions & 3 deletions quotientai/resources/logs.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union
import asyncio
import logging
from collections import deque
from threading import Thread
from dataclasses import dataclass
from datetime import datetime
import time
from pydantic import BaseModel

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we importing pydantic here? All other classes in the sdk are instantiated without it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@waldnzwrld had a quick chat in the office: it'd be good to use pydantic for client-side validation over the dataclasses. Dataclasses are useful but don't do type validation.

@crekhari can you make a separate PR to replace stuff with pydantic entirely?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be better for us to replace everything in one go rather than doing it piece-meal

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wholeheartedly agree that if we're going to do it, we should just go for it.

If nobody has opened an issue for it, I will

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thx for opening the issue, Ill assign it to myself for now, but its a lowish priority for me rn



class LogDocument(BaseModel):
"""
Represents a log document
"""
page_content: str
metadata: Optional[Dict[str, Any]] = None

@dataclass
class Log:
"""
Expand All @@ -21,7 +29,7 @@ class Log:
inconsistency_detection: bool
user_query: str
model_output: str
documents: List[str]
documents: List[Union[str, LogDocument]]
message_history: Optional[List[Dict[str, Any]]]
instructions: Optional[List[str]]
tags: Dict[str, Any]
Expand Down Expand Up @@ -71,7 +79,7 @@ def create(
inconsistency_detection: bool,
user_query: str,
model_output: str,
documents: List[str],
documents: List[Union[str, LogDocument]],
message_history: Optional[List[Dict[str, Any]]] = None,
instructions: Optional[List[str]] = None,
tags: Optional[Dict[str, Any]] = {},
Expand Down
32 changes: 31 additions & 1 deletion tests/resources/test_logs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
from datetime import datetime
from unittest.mock import Mock, AsyncMock
from quotientai.resources.logs import Log, LogsResource, AsyncLogsResource
from quotientai.resources.logs import Log, LogsResource, AsyncLogsResource, LogDocument

# Fixtures
@pytest.fixture
Expand All @@ -25,6 +25,36 @@ def sample_log_data():
"created_at": "2024-01-01T00:00:00"
}

# LogDocument Tests
class TestLogDocument:
"""Tests for the LogDocument class"""

def test_log_document_creation(self):
"""Test basic creation of LogDocument"""
doc = LogDocument(
page_content="This is test content",
metadata={"source": "test_source", "author": "test_author"}
)
assert doc.page_content == "This is test content"
assert doc.metadata["source"] == "test_source"
assert doc.metadata["author"] == "test_author"

def test_log_document_with_no_metadata(self):
"""Test LogDocument creation without metadata"""
doc = LogDocument(page_content="Test content only")
assert doc.page_content == "Test content only"
assert doc.metadata is None

def test_log_document_from_dict(self):
"""Test creating LogDocument from dictionary"""
doc_dict = {
"page_content": "Content from dict",
"metadata": {"source": "dictionary"}
}
doc = LogDocument(**doc_dict)
assert doc.page_content == "Content from dict"
assert doc.metadata["source"] == "dictionary"

# Model Tests
class TestLog:
"""Tests for the Log dataclass"""
Expand Down
69 changes: 68 additions & 1 deletion tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,4 +507,71 @@ def test_should_sample(self):

# Should not sample when random >= sample_rate
mock_random.return_value = 0.6
assert logger._should_sample() is False
assert logger._should_sample() is False

@pytest.mark.asyncio
async def test_log_with_invalid_document_dict(self):
"""Test logging with an invalid document dictionary"""
mock_logs_resource = Mock()
logger = AsyncQuotientLogger(mock_logs_resource)
logger.init(app_name="test-app", environment="test")

# Test with a document missing 'page_content'
with pytest.raises(QuotientAIError) as excinfo:
await logger.log(
user_query="test query",
model_output="test output",
documents=[{"metadata": {"key": "value"}}]
)

# Verify the error message contains expected information
assert "page_content" in str(excinfo.value)
assert mock_logs_resource.create.call_count == 0

@pytest.mark.asyncio
async def test_log_with_invalid_document_type(self):
"""Test logging with a document of invalid type"""
mock_logs_resource = Mock()
logger = AsyncQuotientLogger(mock_logs_resource)
logger.init(app_name="test-app", environment="test")

# Test with a mix of valid string and invalid non-string/non-dict document
# The string document will hit the 'continue' branch
with pytest.raises(QuotientAIError) as excinfo:
await logger.log(
user_query="test query",
model_output="test output",
documents=["valid string document", 123]
)

# Verify the error message contains expected information
assert "123" in str(excinfo.value) or "int" in str(excinfo.value)
assert mock_logs_resource.create.call_count == 0

@pytest.mark.asyncio
async def test_log_with_valid_documents(self):
"""Test logging with valid document formats"""

mock_logs_resource = Mock()
mock_logs_resource.create = AsyncMock()
logger = AsyncQuotientLogger(mock_logs_resource)
logger.init(app_name="test-app", environment="test")

# Force sampling to True for testing
with patch.object(logger, '_should_sample', return_value=True):
await logger.log(
user_query="test query 4",
model_output="test output 4",
documents=[
"string document",
{"page_content": "dict document", "metadata": {"key": "value"}},
]
)

assert mock_logs_resource.create.call_count == 1

# Verify correct documents were passed to create
calls = mock_logs_resource.create.call_args_list
assert calls[0][1]["documents"][0] == "string document"
assert calls[0][1]["documents"][1] == {"page_content": "dict document", "metadata": {"key": "value"}}
assert len(calls[0][1]["documents"]) == 2
64 changes: 64 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,3 +499,67 @@ def test_should_sample(self):
# Should not sample when random >= sample_rate
mock_random.return_value = 0.6
assert logger._should_sample() is False

def test_log_with_invalid_document_dict(self):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a happy case test, (A test where the document data is valid) for both the sync and async client arch

"""Test logging with an invalid document dictionary"""
mock_logs_resource = Mock()
logger = QuotientLogger(mock_logs_resource)
logger.init(app_name="test-app", environment="test")

# Test with a document missing 'page_content'
with pytest.raises(QuotientAIError) as excinfo:
logger.log(
user_query="test query",
model_output="test output",
documents=[{"metadata": {"key": "value"}}]
)

# Verify the error message contains expected information
assert "page_content" in str(excinfo.value)
assert mock_logs_resource.create.call_count == 0

def test_log_with_invalid_document_type(self):
"""Test logging with a document of invalid type"""
mock_logs_resource = Mock()
logger = QuotientLogger(mock_logs_resource)
logger.init(app_name="test-app", environment="test")

# Test with a mix of valid string and invalid non-string/non-dict document
# The string document will hit the 'continue' branch
with pytest.raises(QuotientAIError) as excinfo:
logger.log(
user_query="test query",
model_output="test output",
documents=["valid string document", 123]
)

# Verify the error message contains expected information
assert "123" in str(excinfo.value) or "int" in str(excinfo.value)
assert mock_logs_resource.create.call_count == 0

def test_log_with_valid_documents(self):
"""Test logging with valid document formats"""

mock_logs_resource = Mock()
logger = QuotientLogger(mock_logs_resource)
logger.init(app_name="test-app", environment="test")

# Force sampling to True for testing
with patch.object(logger, '_should_sample', return_value=True):
# Test with mixed valid document types
logger.log(
user_query="test query 4",
model_output="test output 4",
documents=[
"string document",
{"page_content": "dict document", "metadata": {"key": "value"}},
]
)

assert mock_logs_resource.create.call_count == 1

# Verify correct documents were passed to create
calls = mock_logs_resource.create.call_args_list
assert calls[0][1]["documents"][0] == "string document"
assert calls[0][1]["documents"][1] == {"page_content": "dict document", "metadata": {"key": "value"}}
assert len(calls[0][1]["documents"]) == 2