Add test coverage
This commit is contained in:
@@ -34,19 +34,50 @@ build-backend = "uv_build"
|
|||||||
dev = [
|
dev = [
|
||||||
"inline-snapshot>=0.32.1",
|
"inline-snapshot>=0.32.1",
|
||||||
"pytest>=9.0.2",
|
"pytest>=9.0.2",
|
||||||
|
"pytest-asyncio>=1.3.0",
|
||||||
"pytest-cov>=7.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
|
"pytest-env>=1.5.0",
|
||||||
"pytest-recording>=0.13.4",
|
"pytest-recording>=0.13.4",
|
||||||
"ruff>=0.15.1",
|
"ruff>=0.15.1",
|
||||||
"ty>=0.0.17",
|
"ty>=0.0.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
exclude = ["tests"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ['ALL']
|
select = ['ALL']
|
||||||
ignore = ['D203', 'D213']
|
ignore = ['D203', 'D213']
|
||||||
|
|
||||||
|
[tool.ty.src]
|
||||||
|
include = ["src", "packages"]
|
||||||
|
exclude = ["tests"]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
rag-client = { workspace = true }
|
rag-client = { workspace = true }
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = [
|
||||||
|
"--cov=capa_de_integracion",
|
||||||
|
"--cov-report=term-missing",
|
||||||
|
"--cov-report=html",
|
||||||
|
"--cov-branch",
|
||||||
|
]
|
||||||
|
|
||||||
|
env = [
|
||||||
|
"FIRESTORE_EMULATOR_HOST=[::1]:8911",
|
||||||
|
"GCP_PROJECT_ID=test-project",
|
||||||
|
"GCP_LOCATION=us-central1",
|
||||||
|
"GCP_FIRESTORE_DATABASE_ID=(default)",
|
||||||
|
"RAG_ENDPOINT_URL=http://localhost:8000/rag",
|
||||||
|
"REDIS_HOST=localhost",
|
||||||
|
"REDIS_PORT=6379",
|
||||||
|
"DLP_TEMPLATE_COMPLETE_FLOW=projects/test/dlpJobTriggers/test",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.uv.workspace]
|
[tool.uv.workspace]
|
||||||
members = [
|
members = [
|
||||||
"packages/rag-client",
|
"packages/rag-client",
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
"""RAG service for calling RAG endpoints with high concurrency."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from types import TracebackType
|
|
||||||
from typing import Self
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from capa_de_integracion.config import Settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Message(BaseModel):
|
|
||||||
"""OpenAI-style message format."""
|
|
||||||
|
|
||||||
role: str = Field(..., description="Role: system, user, or assistant")
|
|
||||||
content: str = Field(..., description="Message content")
|
|
||||||
|
|
||||||
|
|
||||||
class RAGRequest(BaseModel):
|
|
||||||
"""Request model for RAG endpoint."""
|
|
||||||
|
|
||||||
messages: list[Message] = Field(..., description="Conversation history")
|
|
||||||
|
|
||||||
|
|
||||||
class RAGResponse(BaseModel):
|
|
||||||
"""Response model from RAG endpoint."""
|
|
||||||
|
|
||||||
response: str = Field(..., description="Generated response from RAG")
|
|
||||||
|
|
||||||
|
|
||||||
class RAGService:
|
|
||||||
"""Highly concurrent HTTP client for calling RAG endpoints.
|
|
||||||
|
|
||||||
Uses httpx AsyncClient with connection pooling for optimal performance
|
|
||||||
when handling multiple concurrent requests.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
settings: Settings,
|
|
||||||
max_connections: int = 100,
|
|
||||||
max_keepalive_connections: int = 20,
|
|
||||||
timeout: float = 30.0,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize RAG service with connection pooling.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
settings: Application settings
|
|
||||||
max_connections: Maximum number of concurrent connections
|
|
||||||
max_keepalive_connections: Maximum number of idle connections to keep alive
|
|
||||||
timeout: Request timeout in seconds
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.settings = settings
|
|
||||||
self.rag_endpoint_url = settings.rag_endpoint_url
|
|
||||||
self.timeout = timeout
|
|
||||||
|
|
||||||
# Configure connection limits for high concurrency
|
|
||||||
limits = httpx.Limits(
|
|
||||||
max_connections=max_connections,
|
|
||||||
max_keepalive_connections=max_keepalive_connections,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create async client with connection pooling
|
|
||||||
self._client = httpx.AsyncClient(
|
|
||||||
limits=limits,
|
|
||||||
timeout=httpx.Timeout(timeout),
|
|
||||||
http2=True, # Enable HTTP/2 for better performance
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"RAGService initialized with endpoint: %s, "
|
|
||||||
"max_connections: %s, timeout: %ss",
|
|
||||||
self.rag_endpoint_url,
|
|
||||||
max_connections,
|
|
||||||
timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def query(self, messages: list[dict[str, str]]) -> str:
|
|
||||||
"""Send conversation history to RAG endpoint and get response.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
messages: OpenAI-style conversation history
|
|
||||||
e.g., [{"role": "user", "content": "Hello"}, ...]
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response string from RAG endpoint
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
httpx.HTTPError: If HTTP request fails
|
|
||||||
ValueError: If response format is invalid
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Validate and construct request
|
|
||||||
message_objects = [Message(**msg) for msg in messages]
|
|
||||||
request = RAGRequest(messages=message_objects)
|
|
||||||
|
|
||||||
# Make async HTTP POST request
|
|
||||||
logger.debug("Sending RAG request with %s messages", len(messages))
|
|
||||||
|
|
||||||
response = await self._client.post(
|
|
||||||
self.rag_endpoint_url,
|
|
||||||
json=request.model_dump(),
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Raise exception for HTTP errors
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# Parse response
|
|
||||||
response_data = response.json()
|
|
||||||
rag_response = RAGResponse(**response_data)
|
|
||||||
|
|
||||||
logger.debug("RAG response received: %s chars", len(rag_response.response))
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.exception(
|
|
||||||
"HTTP error calling RAG endpoint: %s - %s",
|
|
||||||
e.response.status_code,
|
|
||||||
e.response.text,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
except httpx.RequestError:
|
|
||||||
logger.exception("Request error calling RAG endpoint:")
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Unexpected error calling RAG endpoint")
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
return rag_response.response
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
"""Close the HTTP client and release connections."""
|
|
||||||
await self._client.aclose()
|
|
||||||
logger.info("RAGService client closed")
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Self:
|
|
||||||
"""Async context manager entry."""
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(
|
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
exc_val: BaseException | None,
|
|
||||||
exc_tb: TracebackType | None,
|
|
||||||
) -> None:
|
|
||||||
"""Async context manager exit."""
|
|
||||||
await self.close()
|
|
||||||
30
uv.lock
generated
30
uv.lock
generated
@@ -193,7 +193,9 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "inline-snapshot" },
|
{ name = "inline-snapshot" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-env" },
|
||||||
{ name = "pytest-recording" },
|
{ name = "pytest-recording" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "ty" },
|
{ name = "ty" },
|
||||||
@@ -220,7 +222,9 @@ requires-dist = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "inline-snapshot", specifier = ">=0.32.1" },
|
{ name = "inline-snapshot", specifier = ">=0.32.1" },
|
||||||
{ name = "pytest", specifier = ">=9.0.2" },
|
{ name = "pytest", specifier = ">=9.0.2" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
||||||
|
{ name = "pytest-env", specifier = ">=1.5.0" },
|
||||||
{ name = "pytest-recording", specifier = ">=0.13.4" },
|
{ name = "pytest-recording", specifier = ">=0.13.4" },
|
||||||
{ name = "ruff", specifier = ">=0.15.1" },
|
{ name = "ruff", specifier = ">=0.15.1" },
|
||||||
{ name = "ty", specifier = ">=0.0.17" },
|
{ name = "ty", specifier = ">=0.0.17" },
|
||||||
@@ -1605,6 +1609,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest-cov"
|
name = "pytest-cov"
|
||||||
version = "7.0.0"
|
version = "7.0.0"
|
||||||
@@ -1619,6 +1636,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-env"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e6/56/a931c6f6194917ff44be41b8586e2ffd13a18fa70fb28d9800a4695befa5/pytest_env-1.5.0.tar.gz", hash = "sha256:db8994b9ce170f135a37acc09ac753a6fc697d15e691b576ed8d8ca261c40246", size = 15271, upload-time = "2026-02-17T18:31:39.095Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/af/99b52a8524983bfece35e51e65a0b517b22920c023e57855c95e744e19e4/pytest_env-1.5.0-py3-none-any.whl", hash = "sha256:89a15686ac837c9cd009a8a2d52bd55865e2f23c82094247915dae4540c87161", size = 10122, upload-time = "2026-02-17T18:31:37.496Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest-recording"
|
name = "pytest-recording"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user