From d80ee16efd36704773def3e11be9daff4d58c72d Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Thu, 9 Jul 2026 14:06:39 -0400 Subject: [PATCH 01/11] [DEV-14675] Implement initial filter-search endpoint --- usaspending_api/llm/fixtures/prompts.yaml | 2 +- .../tests/integration/test_filter_search.py | 409 ++++++++++++++++++ usaspending_api/llm/v2/urls.py | 8 +- usaspending_api/llm/v2/views/filter_search.py | 92 ++++ usaspending_api/llm/v2/views/llm_base.py | 85 +++- 5 files changed, 592 insertions(+), 4 deletions(-) create mode 100644 usaspending_api/llm/tests/integration/test_filter_search.py create mode 100644 usaspending_api/llm/v2/views/filter_search.py diff --git a/usaspending_api/llm/fixtures/prompts.yaml b/usaspending_api/llm/fixtures/prompts.yaml index be5e96e313..2cabe4b46d 100644 --- a/usaspending_api/llm/fixtures/prompts.yaml +++ b/usaspending_api/llm/fixtures/prompts.yaml @@ -5,7 +5,7 @@ description: initial system prompt for the search assistant text: > You are USAspending search assistant. You help the user search for federal spending. - - This is not a chat interface. You may not ask the user for clarification or additonal input after the initial query. + - This is not a chat interface. You may not ask the user for clarification or additional input after the initial query. - You may need to call other tools before calling the `search_federal_contracts_and_assistance` tool in order to get valid input parameters to `search_federal_contracts_and_assistance`. - You may only call the `search_federal_contracts_and_assistance` tool once. Calling `search_federal_contracts_and_assistance` terminates the conversation. Therefore, include all the relevant parameters in the first and only call to `search_federal_contracts_and_assistance`. - Only include the relevant input paramaters to `search_federal_contracts_and_assistance`. diff --git a/usaspending_api/llm/tests/integration/test_filter_search.py b/usaspending_api/llm/tests/integration/test_filter_search.py new file mode 100644 index 0000000000..25e6811bfc --- /dev/null +++ b/usaspending_api/llm/tests/integration/test_filter_search.py @@ -0,0 +1,409 @@ +import json +import os +from unittest.mock import Mock, patch + +import pytest +from model_bakery import baker +from rest_framework import status + +from usaspending_api.llm.models.db_models import AIModel, Session + + +@pytest.fixture +def ai_model_data(db): + """Create AI model test data.""" + return baker.make( + "llm.AIModel", + name="nova micro", + model_id="amazon.nova-micro-v1:0", + provider="amazon" + ) + + +@pytest.fixture +def mock_bedrock_client(): + """Mock boto3 bedrock client.""" + with patch("boto3.client") as mock_client: + mock_instance = Mock() + mock_client.return_value = mock_instance + yield mock_instance + + +@pytest.fixture +def mock_llm_api_key(): + """Mock LLM API key validation.""" + with patch("usaspending_api.common.api_request_utils.LLMAPIKeyHandler._validate_llm_request") as mock_validate: + mock_validate.return_value = None # None means validation passed. + yield mock_validate + + +class TestFilterSearchEndpoint: + """Integration tests for /api/v2/llm/filter-search/ API endpoint.""" + + url = "/api/v2/llm/filter-search/" + + def test_endpoint_requires_api_key(self, client, ai_model_data): + """Test that endpoint requires X-LLM-API-Key header.""" + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": "test query"}) + ) + # Without mocking the API key validator, it should fail. + # The actual status depends on whether the secret is configured. + assert resp.status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_500_INTERNAL_SERVER_ERROR] + + def test_endpoint_rejects_missing_query(self, client, ai_model_data, mock_llm_api_key): + """Test that endpoint rejects requests without query parameter.""" + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({}) + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + response_data = resp.json() + assert "query" in response_data["detail"].lower() + + def test_endpoint_rejects_empty_query(self, client, ai_model_data, mock_llm_api_key): + """Test that endpoint rejects empty query string.""" + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": ""}) + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_endpoint_rejects_query_too_long(self, client, ai_model_data, mock_llm_api_key): + """Test that endpoint rejects query strings longer than 1000 characters.""" + long_query = "a" * 1001 + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": long_query}) + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + # NOTE: The backend limitation on queries should be unnecessary if client-side restricts input length, + # but it's good to have in case users find ways to bypass client-side restrictions. + + def test_endpoint_accepts_valid_query(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client): + """Test that endpoint accepts valid query and returns streaming response.""" + # Mock Bedrock response. + mock_bedrock_client.converse.return_value = { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "Here are your results"}] + } + }, + "stopReason": "end_turn", + "usage": {"inputTokens": 10, "outputTokens": 20}, + "metrics": {"latencyMs": 100} + } + + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": "Find contracts in California"}) + ) + + assert resp.status_code == status.HTTP_200_OK + assert resp["Content-Type"] == "application/x-ndjson" + assert resp["Cache-Control"] == "no-cache" + assert resp["X-Accel-Buffering"] == "no" + + def test_endpoint_creates_session(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client): + """Test that endpoint creates a Session record.""" + # Mock Bedrock response. + mock_bedrock_client.converse.return_value = { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "Results"}] + } + }, + "stopReason": "end_turn", + "usage": {"inputTokens": 10, "outputTokens": 20}, + "metrics": {"latencyMs": 100} + } + + initial_session_count = Session.objects.count() + + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": "test query"}) + ) + + assert resp.status_code == status.HTTP_200_OK + assert Session.objects.count() == initial_session_count + 1 + + # Verify session has correct attributes. + session = Session.objects.latest("started_at") + assert session.ai_model == ai_model_data + assert "lookup_location" in session.tools + assert "lookup_recipient" in session.tools + + def test_endpoint_returns_ndjson_stream(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client): + """Test that endpoint returns properly formatted NDJSON stream.""" + # Mock Bedrock response. + mock_bedrock_client.converse.return_value = { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "Results"}] + } + }, + "stopReason": "end_turn", + "usage": {"inputTokens": 10, "outputTokens": 20}, + "metrics": {"latencyMs": 100} + } + + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": "test query"}) + ) + + assert resp.status_code == status.HTTP_200_OK + + # Parse NDJSON response. + content = b"".join(resp.streaming_content).decode("utf-8") + lines = [line for line in content.strip().split("\n") if line] + + # Should have at least search_start event. + assert len(lines) >= 1 + + # Parse first event. + first_event = json.loads(lines[0]) + assert "search_id" in first_event + assert "type" in first_event + assert first_event["type"] == "search_start" + + def test_endpoint_with_tool_execution(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client): + """Test endpoint with tool execution in the response.""" + # Mock Bedrock to return tool_use. + mock_bedrock_client.converse.side_effect = [ + # First call: LLM requests tool use. + { + "output": { + "message": { + "role": "assistant", + "content": [ + { + "toolUse": { + "toolUseId": "tool-123", + "name": "lookup_location", + "input": {"query": "California"} + } + } + ] + } + }, + "stopReason": "tool_use", + "usage": {"inputTokens": 10, "outputTokens": 20}, + "metrics": {"latencyMs": 100} + }, + # Second call: LLM provides final response. + { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "Found California"}] + } + }, + "stopReason": "end_turn", + "usage": {"inputTokens": 15, "outputTokens": 25}, + "metrics": {"latencyMs": 120} + } + ] + + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": "Find contracts in California"}) + ) + + assert resp.status_code == status.HTTP_200_OK + + # Parse NDJSON response. + content = b"".join(resp.streaming_content).decode("utf-8") + lines = [line for line in content.strip().split("\n") if line] + events = [json.loads(line) for line in lines] + + # Should have: search_start, tool_start, tool_complete. + event_types = [e["type"] for e in events] + assert "search_start" in event_types + assert "tool_start" in event_types + assert "tool_complete" in event_types + + # Verify tool events have tool_use_id. + tool_events = [e for e in events if e["type"] in ["tool_start", "tool_complete"]] + for tool_event in tool_events: + assert "tool_use_id" in tool_event + + def test_endpoint_handles_missing_ai_model(self, client, mock_llm_api_key): + """Test that endpoint handles missing AI model gracefully.""" + # Don't create ai_model_data fixture. + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": "test query"}) + ) + + assert resp.status_code == status.HTTP_200_OK + assert resp["Content-Type"] == "application/x-ndjson" + + # Parse response. + content = b"".join(resp.streaming_content).decode("utf-8") + lines = [line for line in content.strip().split("\n") if line] + event = json.loads(lines[0]) + + assert event["type"] == "search_error" + assert "not found" in event["message"].lower() + + def test_endpoint_uses_environment_model(self, client, mock_llm_api_key, mock_bedrock_client): + """Test that endpoint respects LLM_DEFAULT_MODEL environment variable.""" + # Create a different model. + custom_model = baker.make( + "llm.AIModel", + name="claude 4.5", + model_id="anthropic.claude-sonnet-4-5-20250929-v1:0", + provider="anthropic" + ) + + # Mock Bedrock response. + mock_bedrock_client.converse.return_value = { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "Results"}] + } + }, + "stopReason": "end_turn", + "usage": {"inputTokens": 10, "outputTokens": 20}, + "metrics": {"latencyMs": 100} + } + + # Set environment variable. + with patch.dict(os.environ, {"LLM_DEFAULT_MODEL": "claude 4.5"}): + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": "test query"}) + ) + + assert resp.status_code == status.HTTP_200_OK + + # Verify the correct model was used. + session = Session.objects.latest("started_at") + assert session.ai_model == custom_model + + def test_endpoint_handles_bedrock_error(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client): + """Test that endpoint handles Bedrock API errors gracefully.""" + # Mock Bedrock to raise an error. + mock_bedrock_client.converse.side_effect = Exception("Bedrock API error") + + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": "test query"}) + ) + + assert resp.status_code == status.HTTP_200_OK + + # Parse response. + content = b"".join(resp.streaming_content).decode("utf-8") + lines = [line for line in content.strip().split("\n") if line] + + # Should have search_start and search_error. + events = [json.loads(line) for line in lines] + event_types = [e["type"] for e in events] + assert "search_start" in event_types + assert "search_error" in event_types + + # Verify error message. + error_event = next(e for e in events if e["type"] == "search_error") + assert "error" in error_event["message"].lower() + + def test_endpoint_validates_query_length_boundaries(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client): + """Test query length validation at boundaries.""" + # Mock Bedrock response. + mock_bedrock_client.converse.return_value = { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "Results"}] + } + }, + "stopReason": "end_turn", + "usage": {"inputTokens": 10, "outputTokens": 20}, + "metrics": {"latencyMs": 100} + } + + # Test minimum length (1 character). + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": "a"}) + ) + assert resp.status_code == status.HTTP_200_OK + + # Test maximum length (1000 characters). + max_query = "a" * 1000 + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": max_query}) + ) + assert resp.status_code == status.HTTP_200_OK + + def test_endpoint_search_id_consistency(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client): + """Test that all events in a stream share the same search_id.""" + # Mock Bedrock with tool use. + mock_bedrock_client.converse.side_effect = [ + { + "output": { + "message": { + "role": "assistant", + "content": [ + { + "toolUse": { + "toolUseId": "tool-123", + "name": "lookup_location", + "input": {"query": "Texas"} + } + } + ] + } + }, + "stopReason": "tool_use", + "usage": {"inputTokens": 10, "outputTokens": 20}, + "metrics": {"latencyMs": 100} + }, + { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "Done"}] + } + }, + "stopReason": "end_turn", + "usage": {"inputTokens": 15, "outputTokens": 25}, + "metrics": {"latencyMs": 120} + } + ] + + resp = client.post( + self.url, + content_type="application/json", + data=json.dumps({"query": "test query"}) + ) + + # Parse all events. + content = b"".join(resp.streaming_content).decode("utf-8") + lines = [line for line in content.strip().split("\n") if line] + events = [json.loads(line) for line in lines] + + # All events should have the same search_id. + search_ids = [e["search_id"] for e in events] + assert len(set(search_ids)) == 1, "All events should share the same search_id" diff --git a/usaspending_api/llm/v2/urls.py b/usaspending_api/llm/v2/urls.py index a61b2783b6..8636a8e72a 100644 --- a/usaspending_api/llm/v2/urls.py +++ b/usaspending_api/llm/v2/urls.py @@ -1 +1,7 @@ -urlpatterns = [] +from django.urls import path + +from usaspending_api.llm.v2.views.filter_search import FilterSearchViewSet + +urlpatterns = [ + path("filter-search/", FilterSearchViewSet.as_view()), +] diff --git a/usaspending_api/llm/v2/views/filter_search.py b/usaspending_api/llm/v2/views/filter_search.py new file mode 100644 index 0000000000..3769c938f1 --- /dev/null +++ b/usaspending_api/llm/v2/views/filter_search.py @@ -0,0 +1,92 @@ +import logging + +from django.http import StreamingHttpResponse +from rest_framework.request import Request + +from usaspending_api.common.api_request_utils import LLMAPIKeyHandler +from usaspending_api.common.validator.tinyshield import TinyShield +from usaspending_api.llm.assistants.filter_search import FilterSearchAssistant +from usaspending_api.llm.models.db_models import Session +from usaspending_api.llm.models.py_models import AITool +from usaspending_api.llm.tools.lookup_location import lookup_location_tool +from usaspending_api.llm.tools.lookup_recipient import lookup_recipient_tool +from usaspending_api.llm.v2.views.llm_base import LLMBase + +logger = logging.getLogger(__name__) + + +class FilterSearchViewSet(LLMBase): + """ + This endpoint provides a streaming response for LLM-powered search operations with advanced filtering capabilities. + The response is delivered as a series of JSON chunks, allowing real-time updates on search progress, tool execution, and results. + """ + + endpoint_doc = "usaspending_api/api_contracts/contracts/v2/llm/filter_search.md" + + # Define a list of allowed AI tools to pass to the assistant. + tools = [ + lookup_location_tool, + lookup_recipient_tool, + ] + + @LLMAPIKeyHandler.require_api_key + def post(self, request: Request) -> StreamingHttpResponse: + + # Accept a string sanitized as search input. + models = [ + {"name": "filter_search", "key": "query", "type": "text", "text_type": "search", "min": 1, "max": 1000} + ] + # On failure, TinyShield throws status 422 with a JSON Response body containing error details. + validated_request_data = TinyShield(models).block(request.data) + query = validated_request_data["query"] + + try: + # Retrieve AI Model. + try: + ai_model = self._get_ai_model() + except ValueError as e: + return self._error_response(str(e), search_id=None) + + # Get available tools. + tools = self.tools + + # Instantiate session. + session = Session.objects.create( + ai_model=ai_model, + tools=[tool.description.name for tool in tools], + ) + + # Add the above to the filter search assistant. + assistant = FilterSearchAssistant( + model=ai_model, + tools=tools, + session=session, + ) + + def event_stream(): + try: + for event in assistant.search(query): + yield self._ndjson_format(event) + except Exception as e: + logger.error(f"Error during filter search: {str(e)}", exc_info=True) + error_event = { + "search_id": str(session.id), + "type": "search_error", + "message": f"An error occurred: {str(e)}" + } + yield self._ndjson_format(error_event) + + # Craft Response stream. + response = StreamingHttpResponse( + event_stream(), + content_type="application/x-ndjson" + ) + # Disable webserver caching/buffering to enable pass-through behavior of chunks in the stream. + response["Cache-Control"] = "no-cache" + response["X-Accel-Buffering"] = "no" + + return response + + except Exception as e: + logger.error(f"Error initializing filter search: {str(e)}", exc_info=True) + return self._error_response(f"Failed to initialize search: {str(e)}", search_id=None) diff --git a/usaspending_api/llm/v2/views/llm_base.py b/usaspending_api/llm/v2/views/llm_base.py index 1202dcd5c0..68faa517bd 100644 --- a/usaspending_api/llm/v2/views/llm_base.py +++ b/usaspending_api/llm/v2/views/llm_base.py @@ -1,10 +1,91 @@ +import json import logging +import os +import uuid + +from django.http import StreamingHttpResponse from rest_framework.views import APIView +from usaspending_api.llm.models.db_models import AIModel + logger = logging.getLogger(__name__) class LLMBase(APIView): - # TODO: Populate as we create endpoints within the LLM app following pattern in agency_base.py - ... + """ + Base class for LLM-powered endpoints. + + Provides shared functionality for streaming responses, error handling, + and AI model management following the pattern set in agency_base.py. + """ + + # Default AI model name (can be overridden by LLM_DEFAULT_MODEL env variable). + DEFAULT_MODEL_NAME = "nova micro" + + def _get_ai_model(self, model_name: str = None) -> AIModel: + """ + Get an AI model instance from the database. + + Args: + model_name: Optional model name. If not provided, uses environment variable + LLM_DEFAULT_MODEL or falls back to DEFAULT_MODEL_NAME. + + Returns: + AIModel instance. + + Raises: + ValueError: If the specified model is not found. + """ + if not model_name: + model_name = os.environ.get("LLM_DEFAULT_MODEL", self.DEFAULT_MODEL_NAME) + + ai_model = AIModel.objects.filter(name=model_name).first() + if not ai_model: + raise ValueError(f"AI model '{model_name}' not found in database") + + return ai_model + + + def _ndjson_format(self, event: dict) -> str: + """ + Format an event dictionary as a newline-delimited JSON (NDJSON) string. + + Args: + event: Dictionary containing event data. + + Returns: + JSON string with newline terminator for NDJSON streaming (application/x-ndjson). + """ + return json.dumps(event) + "\n" + + + def _error_response(self, message: str, search_id: str = None) -> StreamingHttpResponse: + """ + Generate a streaming error response in newline-delimited JSON (NDJSON) format. + + Args: + message: Error message to return to the client. + search_id: Optional session/search ID. If None, generates a temporary UUID. + + Returns: + StreamingHttpResponse with error event. + """ + error_event = { + "search_id": search_id if search_id else str(uuid.uuid4()), + "type": "search_error", + "message": message + } + + def error_stream(): + yield self._ndjson_format(error_event) + + response = StreamingHttpResponse( + error_stream(), + content_type="application/x-ndjson" + ) + # Disable webserver caching/buffering to enable pass-through behavior of chunks. + response["Cache-Control"] = "no-cache" + response["X-Accel-Buffering"] = "no" + + return response From aff942332229bab13b531f9968d131bd808bb262 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Thu, 9 Jul 2026 14:47:23 -0400 Subject: [PATCH 02/11] DEV-14675: Updating name of integration test to match filename --- usaspending_api/llm/tests/integration/test_filter_search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usaspending_api/llm/tests/integration/test_filter_search.py b/usaspending_api/llm/tests/integration/test_filter_search.py index 25e6811bfc..32d9b5ada8 100644 --- a/usaspending_api/llm/tests/integration/test_filter_search.py +++ b/usaspending_api/llm/tests/integration/test_filter_search.py @@ -37,7 +37,7 @@ def mock_llm_api_key(): yield mock_validate -class TestFilterSearchEndpoint: +class TestFilterSearch: """Integration tests for /api/v2/llm/filter-search/ API endpoint.""" url = "/api/v2/llm/filter-search/" From c79439e8f9f6e2816891fa12a82461639a143c1b Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Fri, 10 Jul 2026 12:16:11 -0400 Subject: [PATCH 03/11] DEV-14675 Update filter_search_assistant to handle errors, log searches, and meet DRY reqs --- .../llm/assistants/filter_search.py | 132 +++++++++++++----- 1 file changed, 100 insertions(+), 32 deletions(-) diff --git a/usaspending_api/llm/assistants/filter_search.py b/usaspending_api/llm/assistants/filter_search.py index d8e5f483d4..f75f03346f 100644 --- a/usaspending_api/llm/assistants/filter_search.py +++ b/usaspending_api/llm/assistants/filter_search.py @@ -1,3 +1,5 @@ +import logging + from functools import cached_property from typing import Generator @@ -6,10 +8,13 @@ from usaspending_api.llm.models.db_models import AIModel, Message, Session, ToolUse from usaspending_api.llm.models.py_models import AITool +logger = logging.getLogger(__name__) + class FilterSearchAssistant: MAX_TOOL_ITERATIONS = 15 + COMPLETION_TOOL_NAME = "execute_filter" def __init__( self, @@ -24,7 +29,6 @@ def __init__( self.tools = tools self.tools_by_name = {tool.description.name: tool for tool in tools} self.session = session - self.client = boto3.client("bedrock-runtime") self.system_message = system_message self.message_order = 0 @@ -37,6 +41,60 @@ def tool_config(self) -> dict[str, list[dict]]: specs = [tool.description.model_dump() for tool in self.tools] return {"tools": [{"toolSpec": {"inputSchema": {"json": spec.pop("input_schema")}, **spec}} for spec in specs]} + @cached_property + def client(self): + """ + Lazy-load the Bedrock client so instantiation is deferred to first access and cached thereafter. + This prevents the client from being created and never used (e.g., if __init__ fails). + + Returns: + boto3 Bedrock Runtime client. + """ + return boto3.client("bedrock-runtime") + + def _extract_text_from_content(self, content: list[dict]) -> str: + """ + Safely extract text content from Bedrock message's "content" array. + + The "content" array can contain multiple block types (e.g., text, toolUse, image, etc.). + This method finds and concatenates all text blocks, handling cases where: + - No text block exists (e.g., tool-only response -> returns empty string); + - Multiple text blocks exist (-> concatenates them together); and, + - Text blocks are in any position in the array (not just content[0]) -> (collects/concatenates them). + + Args: + content: List of content blocks from Bedrock response. + + Returns: + Concatenated text from all text blocks, or an empty string if none are found. + + References: + AWS Bedrock ContentBlock documentation: + https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ContentBlock.html + """ + text_blocks = [block.get("text", "") for block in content if "text" in block] + return " ".join(text_blocks).strip() + + def _create_message_from_response(self, response: dict) -> Message: + """Create a Message record from Bedrock's response.""" + output_message = response["output"]["message"] + + # Safely extract text content. + message_text = self._extract_text_from_content(output_message) + + message = Message.objects.create( + session=self.session, + role=output_message["role"], + message=message_text, + order=self.message_order, + input_tokens=response["usage"]["inputTokens"], + output_tokens=response["usage"]["outputTokens"], + latency=response["metrics"]["latencyMs"], + ) + self.message_order += 1 + self.messages.append(output_message) + return message + def search(self, query: str) -> Generator[dict[str, str], None, None]: yield {"search_id": self.session.id, "type": "search_start", "message": "Thinking..."} @@ -50,18 +108,7 @@ def search(self, query: str) -> Generator[dict[str, str], None, None]: toolConfig=self.tool_config, system=[{"text": self.system_message}], ) - output_message = response["output"]["message"] - self.messages.append(output_message) - m = Message.objects.create( - session=self.session, - role=output_message["role"], - message=output_message["content"][0]["text"], - order=self.message_order, - input_tokens=response["usage"]["inputTokens"], - output_tokens=response["usage"]["outputTokens"], - latency=response["metrics"]["latencyMs"], - ) - self.message_order += 1 + m = self._create_message_from_response(response) stop_reason = response["stopReason"] search_complete = False while stop_reason == "tool_use" and not search_complete and self.tool_iterations < self.MAX_TOOL_ITERATIONS: @@ -82,20 +129,24 @@ def search(self, query: str) -> Generator[dict[str, str], None, None]: toolConfig=self.tool_config, system=[{"text": self.system_message}], ) - output_message = response["output"]["message"] - m = Message.objects.create( - session=self.session, - role=output_message["role"], - message=output_message["content"][0]["text"], - order=self.message_order, - input_tokens=response["usage"]["inputTokens"], - output_tokens=response["usage"]["outputTokens"], - latency=response["metrics"]["latencyMs"], - ) - self.message_order += 1 - self.messages.append(output_message) + m = self._create_message_from_response(response) stop_reason = response["stopReason"] + # Communicate if tool iteration limit reached. + if self.tool_iterations >= self.MAX_TOOL_ITERATIONS and not search_complete: + yield { + "search_id": self.session.id, + "type": "search_error", + "message": f"Maximum tool iterations ({self.MAX_TOOL_ITERATIONS}) reached without completing search.", + } + + # Log each search. + logger.info(f"Search completed for session {self.session.id}", extra={ + "session_id": self.session.id, + "tool_iterations": self.tool_iterations, + "search_complete": search_complete, + }) + def handle_tool_use(self, tool_requests: list[dict], message: Message) -> Generator[dict, None, None]: tool_result_message = {"role": "user", "content": []} for tool_request in tool_requests[::-1]: @@ -110,14 +161,31 @@ def handle_tool_use(self, tool_requests: list[dict], message: Message) -> Genera "message": tool.logging(tool_use["input"]) + "\n", } - result = tool.function(**tool_use["input"]) - t.result = result - t.save() - - yield {"search_id": self.session.id, "type": "tool_complete", "tool_use_id": t.id} - + try: + result = tool.function(**tool_use["input"]) + t.result = result + t.save() + + yield {"search_id": self.session.id, "type": "tool_complete", "tool_use_id": t.id} + + tool_result = {"toolUseId": tool_use["toolUseId"], "content": [{"json": result}]} + tool_result_message["content"].append({"toolResult": tool_result}) + except Exception as e: + error_result = {"error": str(e)} + t.result = error_result + t.save() + + yield { + "search_id": self.session.id, + "type": "tool_error", + "tool_use_id": t.id, + "message": f"Tool execution failed: {str(e)}" + } + + # Still send results to LLM so it can handle the error. tool_result = {"toolUseId": tool_use["toolUseId"], "content": [{"json": result}]} tool_result_message["content"].append({"toolResult": tool_result}) - if tool.description.name == "execute_filter" and "error" not in result: + + if tool.description.name == self.COMPLETION_TOOL_NAME and "error" not in result: yield {"search_id": self.session.id, "type": "search_complete", "result": result["hash"]} self.messages.append(tool_result_message) From 3346e53b2e1edf17e52f3adc9e8ffabd5d3f2e06 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Fri, 10 Jul 2026 13:39:04 -0400 Subject: [PATCH 04/11] DEV-14675 Ruff Linter fixes to contributed code --- .../llm/assistants/filter_search.py | 13 ++++--- .../tests/integration/test_filter_search.py | 6 ++-- usaspending_api/llm/v2/views/filter_search.py | 9 ++--- usaspending_api/llm/v2/views/llm_base.py | 36 +++++++++---------- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/usaspending_api/llm/assistants/filter_search.py b/usaspending_api/llm/assistants/filter_search.py index f75f03346f..1237a668b7 100644 --- a/usaspending_api/llm/assistants/filter_search.py +++ b/usaspending_api/llm/assistants/filter_search.py @@ -1,7 +1,6 @@ import logging - from functools import cached_property -from typing import Generator +from typing import Any, Generator import boto3 @@ -42,11 +41,11 @@ def tool_config(self) -> dict[str, list[dict]]: return {"tools": [{"toolSpec": {"inputSchema": {"json": spec.pop("input_schema")}, **spec}} for spec in specs]} @cached_property - def client(self): + def client(self) -> Any: """ Lazy-load the Bedrock client so instantiation is deferred to first access and cached thereafter. This prevents the client from being created and never used (e.g., if __init__ fails). - + Returns: boto3 Bedrock Runtime client. """ @@ -67,7 +66,7 @@ def _extract_text_from_content(self, content: list[dict]) -> str: Returns: Concatenated text from all text blocks, or an empty string if none are found. - + References: AWS Bedrock ContentBlock documentation: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ContentBlock.html @@ -174,14 +173,14 @@ def handle_tool_use(self, tool_requests: list[dict], message: Message) -> Genera error_result = {"error": str(e)} t.result = error_result t.save() - + yield { "search_id": self.session.id, "type": "tool_error", "tool_use_id": t.id, "message": f"Tool execution failed: {str(e)}" } - + # Still send results to LLM so it can handle the error. tool_result = {"toolUseId": tool_use["toolUseId"], "content": [{"json": result}]} tool_result_message["content"].append({"toolResult": tool_result}) diff --git a/usaspending_api/llm/tests/integration/test_filter_search.py b/usaspending_api/llm/tests/integration/test_filter_search.py index 32d9b5ada8..c28f89fef5 100644 --- a/usaspending_api/llm/tests/integration/test_filter_search.py +++ b/usaspending_api/llm/tests/integration/test_filter_search.py @@ -6,7 +6,7 @@ from model_bakery import baker from rest_framework import status -from usaspending_api.llm.models.db_models import AIModel, Session +from usaspending_api.llm.models.db_models import Session @pytest.fixture @@ -325,7 +325,9 @@ def test_endpoint_handles_bedrock_error(self, client, ai_model_data, mock_llm_ap error_event = next(e for e in events if e["type"] == "search_error") assert "error" in error_event["message"].lower() - def test_endpoint_validates_query_length_boundaries(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client): + def test_endpoint_validates_query_length_boundaries( + self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client + ): """Test query length validation at boundaries.""" # Mock Bedrock response. mock_bedrock_client.converse.return_value = { diff --git a/usaspending_api/llm/v2/views/filter_search.py b/usaspending_api/llm/v2/views/filter_search.py index 3769c938f1..a7dbcd2b0d 100644 --- a/usaspending_api/llm/v2/views/filter_search.py +++ b/usaspending_api/llm/v2/views/filter_search.py @@ -1,4 +1,5 @@ import logging +from typing import Generator from django.http import StreamingHttpResponse from rest_framework.request import Request @@ -7,7 +8,6 @@ from usaspending_api.common.validator.tinyshield import TinyShield from usaspending_api.llm.assistants.filter_search import FilterSearchAssistant from usaspending_api.llm.models.db_models import Session -from usaspending_api.llm.models.py_models import AITool from usaspending_api.llm.tools.lookup_location import lookup_location_tool from usaspending_api.llm.tools.lookup_recipient import lookup_recipient_tool from usaspending_api.llm.v2.views.llm_base import LLMBase @@ -18,11 +18,12 @@ class FilterSearchViewSet(LLMBase): """ This endpoint provides a streaming response for LLM-powered search operations with advanced filtering capabilities. - The response is delivered as a series of JSON chunks, allowing real-time updates on search progress, tool execution, and results. + The response is delivered as a series of JSON chunks, allowing real-time updates on search progress, + tool execution, and results. """ endpoint_doc = "usaspending_api/api_contracts/contracts/v2/llm/filter_search.md" - + # Define a list of allowed AI tools to pass to the assistant. tools = [ lookup_location_tool, @@ -63,7 +64,7 @@ def post(self, request: Request) -> StreamingHttpResponse: session=session, ) - def event_stream(): + def event_stream() -> Generator[str, None, None]: try: for event in assistant.search(query): yield self._ndjson_format(event) diff --git a/usaspending_api/llm/v2/views/llm_base.py b/usaspending_api/llm/v2/views/llm_base.py index 68faa517bd..3cc5ac1175 100644 --- a/usaspending_api/llm/v2/views/llm_base.py +++ b/usaspending_api/llm/v2/views/llm_base.py @@ -2,9 +2,9 @@ import logging import os import uuid +from typing import Generator from django.http import StreamingHttpResponse - from rest_framework.views import APIView from usaspending_api.llm.models.db_models import AIModel @@ -15,59 +15,57 @@ class LLMBase(APIView): """ Base class for LLM-powered endpoints. - + Provides shared functionality for streaming responses, error handling, and AI model management following the pattern set in agency_base.py. """ - + # Default AI model name (can be overridden by LLM_DEFAULT_MODEL env variable). DEFAULT_MODEL_NAME = "nova micro" - + def _get_ai_model(self, model_name: str = None) -> AIModel: """ Get an AI model instance from the database. - + Args: model_name: Optional model name. If not provided, uses environment variable LLM_DEFAULT_MODEL or falls back to DEFAULT_MODEL_NAME. - + Returns: AIModel instance. - + Raises: ValueError: If the specified model is not found. """ if not model_name: model_name = os.environ.get("LLM_DEFAULT_MODEL", self.DEFAULT_MODEL_NAME) - + ai_model = AIModel.objects.filter(name=model_name).first() if not ai_model: raise ValueError(f"AI model '{model_name}' not found in database") - + return ai_model - def _ndjson_format(self, event: dict) -> str: """ Format an event dictionary as a newline-delimited JSON (NDJSON) string. - + Args: event: Dictionary containing event data. - + Returns: JSON string with newline terminator for NDJSON streaming (application/x-ndjson). """ return json.dumps(event) + "\n" - def _error_response(self, message: str, search_id: str = None) -> StreamingHttpResponse: """ Generate a streaming error response in newline-delimited JSON (NDJSON) format. - + Args: message: Error message to return to the client. search_id: Optional session/search ID. If None, generates a temporary UUID. - + Returns: StreamingHttpResponse with error event. """ @@ -76,10 +74,10 @@ def _error_response(self, message: str, search_id: str = None) -> StreamingHttpR "type": "search_error", "message": message } - - def error_stream(): + + def error_stream() -> Generator[str, None, None]: yield self._ndjson_format(error_event) - + response = StreamingHttpResponse( error_stream(), content_type="application/x-ndjson" @@ -87,5 +85,5 @@ def error_stream(): # Disable webserver caching/buffering to enable pass-through behavior of chunks. response["Cache-Control"] = "no-cache" response["X-Accel-Buffering"] = "no" - + return response From c03cc376ca18cc1212279cb68d03817b3fc49310 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Fri, 10 Jul 2026 13:42:58 -0400 Subject: [PATCH 05/11] DEV-14675 quick lint fix of trailing whitespace --- usaspending_api/llm/v2/views/filter_search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usaspending_api/llm/v2/views/filter_search.py b/usaspending_api/llm/v2/views/filter_search.py index a7dbcd2b0d..0d5251a16e 100644 --- a/usaspending_api/llm/v2/views/filter_search.py +++ b/usaspending_api/llm/v2/views/filter_search.py @@ -18,7 +18,7 @@ class FilterSearchViewSet(LLMBase): """ This endpoint provides a streaming response for LLM-powered search operations with advanced filtering capabilities. - The response is delivered as a series of JSON chunks, allowing real-time updates on search progress, + The response is delivered as a series of JSON chunks, allowing real-time updates on search progress, tool execution, and results. """ From 0d6ab2fc47fda625effc9169822a661fcfa3264b Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Mon, 13 Jul 2026 16:53:03 -0400 Subject: [PATCH 06/11] DEV-14675 - Added endpoint markdown. Updated search view to add system_message to assistant and ended_at prop to Session. Updated LLMBase to use None for ID when error occurs. --- .../api_docs/markdown/endpoints.md | 1 + .../llm/assistants/filter_search.py | 12 +++---- usaspending_api/llm/v2/views/filter_search.py | 32 +++++++++++++++---- usaspending_api/llm/v2/views/llm_base.py | 9 +++--- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/usaspending_api/api_docs/markdown/endpoints.md b/usaspending_api/api_docs/markdown/endpoints.md index 5fb400d0d9..1484ac99d0 100644 --- a/usaspending_api/api_docs/markdown/endpoints.md +++ b/usaspending_api/api_docs/markdown/endpoints.md @@ -133,6 +133,7 @@ The currently available endpoints are listed in the following table. |[/api/v2/idvs/count/federal_account//](/api/v2/idvs/count/federal_account/CONT_IDV_NNK14MA74C_8000/)|GET| Returns the number of federal accounts associated with children and grandchild awards of an IDV. | |[/api/v2/idvs/funding/](/api/v2/idvs/funding/)|POST| Returns File C funding records associated with an IDV | |[/api/v2/idvs/funding_rollup/](/api/v2/idvs/funding_rollup/)|POST| Returns aggregated count of awarding agencies, federal accounts, and total transaction obligated amount for all contracts under an IDV | +|[/api/v2/llm/filter-search/](/api/v2/llm/filter-search/)|POST| Returns a streaming response for LLM-powered search operations with advanced filtering capabilities | |[/api/v2/recipient/](/api/v2/recipient/)|POST| Returns a list of recipients in USAspending DB | |[/api/v2/recipient/children//](/api/v2/recipient/children/006928857/)|GET| Returns recipient details based on DUNS or UEI number | |[/api/v2/recipient/count/](/api/v2/recipient/count/)|POST| Returns the count of recipents for the given filters | diff --git a/usaspending_api/llm/assistants/filter_search.py b/usaspending_api/llm/assistants/filter_search.py index 1237a668b7..be3b2830bc 100644 --- a/usaspending_api/llm/assistants/filter_search.py +++ b/usaspending_api/llm/assistants/filter_search.py @@ -96,7 +96,7 @@ def _create_message_from_response(self, response: dict) -> Message: def search(self, query: str) -> Generator[dict[str, str], None, None]: - yield {"search_id": self.session.id, "type": "search_start", "message": "Thinking..."} + yield {"search_id": str(self.session.id), "type": "search_start", "message": "Thinking..."} Message.objects.create(session=self.session, role="user", message=query, order=self.message_order) self.message_order += 1 @@ -134,7 +134,7 @@ def search(self, query: str) -> Generator[dict[str, str], None, None]: # Communicate if tool iteration limit reached. if self.tool_iterations >= self.MAX_TOOL_ITERATIONS and not search_complete: yield { - "search_id": self.session.id, + "search_id": str(self.session.id), "type": "search_error", "message": f"Maximum tool iterations ({self.MAX_TOOL_ITERATIONS}) reached without completing search.", } @@ -154,7 +154,7 @@ def handle_tool_use(self, tool_requests: list[dict], message: Message) -> Genera tool = self.tools_by_name[tool_use["name"]] yield { - "search_id": self.session.id, + "search_id": str(self.session.id), "type": "tool_start", "tool_use_id": t.id, "message": tool.logging(tool_use["input"]) + "\n", @@ -165,7 +165,7 @@ def handle_tool_use(self, tool_requests: list[dict], message: Message) -> Genera t.result = result t.save() - yield {"search_id": self.session.id, "type": "tool_complete", "tool_use_id": t.id} + yield {"search_id": str(self.session.id), "type": "tool_complete", "tool_use_id": t.id} tool_result = {"toolUseId": tool_use["toolUseId"], "content": [{"json": result}]} tool_result_message["content"].append({"toolResult": tool_result}) @@ -175,7 +175,7 @@ def handle_tool_use(self, tool_requests: list[dict], message: Message) -> Genera t.save() yield { - "search_id": self.session.id, + "search_id": str(self.session.id), "type": "tool_error", "tool_use_id": t.id, "message": f"Tool execution failed: {str(e)}" @@ -186,5 +186,5 @@ def handle_tool_use(self, tool_requests: list[dict], message: Message) -> Genera tool_result_message["content"].append({"toolResult": tool_result}) if tool.description.name == self.COMPLETION_TOOL_NAME and "error" not in result: - yield {"search_id": self.session.id, "type": "search_complete", "result": result["hash"]} + yield {"search_id": str(self.session.id), "type": "search_complete", "result": result["hash"]} self.messages.append(tool_result_message) diff --git a/usaspending_api/llm/v2/views/filter_search.py b/usaspending_api/llm/v2/views/filter_search.py index 0d5251a16e..f8a31d5f02 100644 --- a/usaspending_api/llm/v2/views/filter_search.py +++ b/usaspending_api/llm/v2/views/filter_search.py @@ -2,12 +2,13 @@ from typing import Generator from django.http import StreamingHttpResponse +from django.utils import timezone from rest_framework.request import Request from usaspending_api.common.api_request_utils import LLMAPIKeyHandler from usaspending_api.common.validator.tinyshield import TinyShield from usaspending_api.llm.assistants.filter_search import FilterSearchAssistant -from usaspending_api.llm.models.db_models import Session +from usaspending_api.llm.models.db_models import Prompts, Session from usaspending_api.llm.tools.lookup_location import lookup_location_tool from usaspending_api.llm.tools.lookup_recipient import lookup_recipient_tool from usaspending_api.llm.v2.views.llm_base import LLMBase @@ -51,18 +52,31 @@ def post(self, request: Request) -> StreamingHttpResponse: # Get available tools. tools = self.tools + # Retrieve system prompt from database (fall back to Assistant's default if not found). + system_prompt = None + try: + system_prompt = Prompts.objects.get(name="initial") + except Prompts.DoesNotExist: + logger.warning("System prompt not found in database, using Assistant's default.") + # Instantiate session. session = Session.objects.create( ai_model=ai_model, tools=[tool.description.name for tool in tools], + system_prompt=system_prompt, ) - # Add the above to the filter search assistant. - assistant = FilterSearchAssistant( - model=ai_model, - tools=tools, - session=session, - ) + # Create assistant with appropriate arguments. + assistant_kwargs = { + "model": ai_model, + "tools": tools, + "session": session, + } + # If system_prompt is set, override the Assistant's default prompt. + if system_prompt: + assistant_kwargs["system_message"] = system_prompt.text + + assistant = FilterSearchAssistant(**assistant_kwargs) def event_stream() -> Generator[str, None, None]: try: @@ -76,6 +90,10 @@ def event_stream() -> Generator[str, None, None]: "message": f"An error occurred: {str(e)}" } yield self._ndjson_format(error_event) + finally: + # Update session end time when stream completes (success or error). + session.ended_at = timezone.now() + session.save(update_fields=["ended_at"]) # Craft Response stream. response = StreamingHttpResponse( diff --git a/usaspending_api/llm/v2/views/llm_base.py b/usaspending_api/llm/v2/views/llm_base.py index 3cc5ac1175..10ef7eda92 100644 --- a/usaspending_api/llm/v2/views/llm_base.py +++ b/usaspending_api/llm/v2/views/llm_base.py @@ -1,7 +1,6 @@ import json import logging import os -import uuid from typing import Generator from django.http import StreamingHttpResponse @@ -58,19 +57,19 @@ def _ndjson_format(self, event: dict) -> str: """ return json.dumps(event) + "\n" - def _error_response(self, message: str, search_id: str = None) -> StreamingHttpResponse: + def _error_response(self, message: str, search_id: str | int = None) -> StreamingHttpResponse: """ Generate a streaming error response in newline-delimited JSON (NDJSON) format. Args: message: Error message to return to the client. - search_id: Optional session/search ID. If None, generates a temporary UUID. + search_id: Optional session/search ID (ints will be converted to strings). Returns: - StreamingHttpResponse with error event. + StreamingHttpResponse with error event in NDJSON format. """ error_event = { - "search_id": search_id if search_id else str(uuid.uuid4()), + "search_id": str(search_id) if search_id is not None else None, "type": "search_error", "message": message } From 34d6218acbcb254be0dabf346ae713b381d8e318 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Tue, 14 Jul 2026 14:57:46 -0400 Subject: [PATCH 07/11] DEV-14675 Updating filter search integration tests to account for DB interactions. --- .../tests/integration/test_filter_search.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/usaspending_api/llm/tests/integration/test_filter_search.py b/usaspending_api/llm/tests/integration/test_filter_search.py index c28f89fef5..432c8ccdf1 100644 --- a/usaspending_api/llm/tests/integration/test_filter_search.py +++ b/usaspending_api/llm/tests/integration/test_filter_search.py @@ -37,6 +37,17 @@ def mock_llm_api_key(): yield mock_validate +@pytest.fixture +def system_prompt_data(db): + """Create system prompt test data.""" + return baker.make( + "llm.Prompts", + name="initial", + description="initial system prompt for the search assistant", + text="You are USAspending search assistant. You help the user search for federal spending." + ) + + class TestFilterSearch: """Integration tests for /api/v2/llm/filter-search/ API endpoint.""" @@ -53,6 +64,7 @@ def test_endpoint_requires_api_key(self, client, ai_model_data): # The actual status depends on whether the secret is configured. assert resp.status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_500_INTERNAL_SERVER_ERROR] + @pytest.mark.django_db def test_endpoint_rejects_missing_query(self, client, ai_model_data, mock_llm_api_key): """Test that endpoint rejects requests without query parameter.""" resp = client.post( @@ -241,6 +253,7 @@ def test_endpoint_with_tool_execution(self, client, ai_model_data, mock_llm_api_ for tool_event in tool_events: assert "tool_use_id" in tool_event + @pytest.mark.django_db def test_endpoint_handles_missing_ai_model(self, client, mock_llm_api_key): """Test that endpoint handles missing AI model gracefully.""" # Don't create ai_model_data fixture. @@ -261,7 +274,8 @@ def test_endpoint_handles_missing_ai_model(self, client, mock_llm_api_key): assert event["type"] == "search_error" assert "not found" in event["message"].lower() - def test_endpoint_uses_environment_model(self, client, mock_llm_api_key, mock_bedrock_client): + @pytest.mark.django_db + def test_endpoint_uses_environment_model(self, client, mock_llm_api_key, mock_bedrock_client, system_prompt_data): """Test that endpoint respects LLM_DEFAULT_MODEL environment variable.""" # Create a different model. custom_model = baker.make( @@ -359,8 +373,9 @@ def test_endpoint_validates_query_length_boundaries( ) assert resp.status_code == status.HTTP_200_OK - def test_endpoint_search_id_consistency(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client): - """Test that all events in a stream share the same search_id.""" + @pytest.mark.django_db + def test_endpoint_search_id_consistency(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client, system_prompt_data): + """Test that all events in a stream share the same search_id (as strings).""" # Mock Bedrock with tool use. mock_bedrock_client.converse.side_effect = [ { @@ -406,6 +421,8 @@ def test_endpoint_search_id_consistency(self, client, ai_model_data, mock_llm_ap lines = [line for line in content.strip().split("\n") if line] events = [json.loads(line) for line in lines] - # All events should have the same search_id. + # All events should have the same search_id (as strings). search_ids = [e["search_id"] for e in events] assert len(set(search_ids)) == 1, "All events should share the same search_id" + # Verify all search_ids are strings (not mixed types). + assert all(isinstance(sid, str) for sid in search_ids), "All search_ids should be strings" From 33edb129a6097f13b3418db6cb6c0a0fe5b05e51 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Tue, 14 Jul 2026 15:01:32 -0400 Subject: [PATCH 08/11] DEV-14675 Fixed lint error --- usaspending_api/llm/tests/integration/test_filter_search.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/usaspending_api/llm/tests/integration/test_filter_search.py b/usaspending_api/llm/tests/integration/test_filter_search.py index 432c8ccdf1..8d73583b56 100644 --- a/usaspending_api/llm/tests/integration/test_filter_search.py +++ b/usaspending_api/llm/tests/integration/test_filter_search.py @@ -374,7 +374,9 @@ def test_endpoint_validates_query_length_boundaries( assert resp.status_code == status.HTTP_200_OK @pytest.mark.django_db - def test_endpoint_search_id_consistency(self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client, system_prompt_data): + def test_endpoint_search_id_consistency( + self, client, ai_model_data, mock_llm_api_key, mock_bedrock_client, system_prompt_data + ): """Test that all events in a stream share the same search_id (as strings).""" # Mock Bedrock with tool use. mock_bedrock_client.converse.side_effect = [ From 4ea6ce06e7d488fd712a28567ac3729975c95f3c Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Tue, 14 Jul 2026 15:39:42 -0400 Subject: [PATCH 09/11] DEV-14675 Adding further error handling for test coverage --- .../llm/assistants/filter_search.py | 2 +- .../tests/integration/test_filter_search.py | 40 ++++++++++++++++--- usaspending_api/llm/v2/views/filter_search.py | 10 +++-- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/usaspending_api/llm/assistants/filter_search.py b/usaspending_api/llm/assistants/filter_search.py index 5962132681..03e9a3d9cd 100644 --- a/usaspending_api/llm/assistants/filter_search.py +++ b/usaspending_api/llm/assistants/filter_search.py @@ -158,7 +158,7 @@ def handle_tool_use(self, tool_requests: list[dict], message: Message) -> Genera tool = self.tools_by_name[tool_use["name"]] yield { - "search_id": self.session.id, + "search_id": str(self.session.id), "type": "tool_start", "tool_use_id": t.id, "message": tool.logging(tool_use["input"]) + "\n", diff --git a/usaspending_api/llm/tests/integration/test_filter_search.py b/usaspending_api/llm/tests/integration/test_filter_search.py index 8d73583b56..68b0ce1e66 100644 --- a/usaspending_api/llm/tests/integration/test_filter_search.py +++ b/usaspending_api/llm/tests/integration/test_filter_search.py @@ -72,10 +72,19 @@ def test_endpoint_rejects_missing_query(self, client, ai_model_data, mock_llm_ap content_type="application/json", data=json.dumps({}) ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - response_data = resp.json() - assert "query" in response_data["detail"].lower() + # Endpoint returns streaming response even for validation errors. + assert resp.status_code == status.HTTP_200_OK + assert resp["Content-Type"] == "application/x-ndjson" + # Parse streaming response. + content = b"".join(resp.streaming_content).decode("utf-8") + lines = [line for line in content.strip().split("\n") if line] + event = json.loads(lines[0]) + + assert event["type"] == "search_error" + assert "query" in event["message"].lower() + + @pytest.mark.django_db def test_endpoint_rejects_empty_query(self, client, ai_model_data, mock_llm_api_key): """Test that endpoint rejects empty query string.""" resp = client.post( @@ -83,8 +92,19 @@ def test_endpoint_rejects_empty_query(self, client, ai_model_data, mock_llm_api_ content_type="application/json", data=json.dumps({"query": ""}) ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + # Endpoint returns streaming response even for validation errors. + assert resp.status_code == status.HTTP_200_OK + assert resp["Content-Type"] == "application/x-ndjson" + + # Parse streaming response. + content = b"".join(resp.streaming_content).decode("utf-8") + lines = [line for line in content.strip().split("\n") if line] + event = json.loads(lines[0]) + + assert event["type"] == "search_error" + assert "query" in event["message"].lower() or "min" in event["message"].lower() + @pytest.mark.django_db def test_endpoint_rejects_query_too_long(self, client, ai_model_data, mock_llm_api_key): """Test that endpoint rejects query strings longer than 1000 characters.""" long_query = "a" * 1001 @@ -93,7 +113,17 @@ def test_endpoint_rejects_query_too_long(self, client, ai_model_data, mock_llm_a content_type="application/json", data=json.dumps({"query": long_query}) ) - assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + # Endpoint returns streaming response even for validation errors. + assert resp.status_code == status.HTTP_200_OK + assert resp["Content-Type"] == "application/x-ndjson" + + # Parse streaming response. + content = b"".join(resp.streaming_content).decode("utf-8") + lines = [line for line in content.strip().split("\n") if line] + event = json.loads(lines[0]) + + assert event["type"] == "search_error" + assert "max" in event["message"].lower() or "1000" in event["message"] # NOTE: The backend limitation on queries should be unnecessary if client-side restricts input length, # but it's good to have in case users find ways to bypass client-side restrictions. diff --git a/usaspending_api/llm/v2/views/filter_search.py b/usaspending_api/llm/v2/views/filter_search.py index f8a31d5f02..2810c218e9 100644 --- a/usaspending_api/llm/v2/views/filter_search.py +++ b/usaspending_api/llm/v2/views/filter_search.py @@ -38,9 +38,13 @@ def post(self, request: Request) -> StreamingHttpResponse: models = [ {"name": "filter_search", "key": "query", "type": "text", "text_type": "search", "min": 1, "max": 1000} ] - # On failure, TinyShield throws status 422 with a JSON Response body containing error details. - validated_request_data = TinyShield(models).block(request.data) - query = validated_request_data["query"] + # On failure, TinyShield raises UnprocessableEntityException. + try: + validated_request_data = TinyShield(models).block(request.data) + query = validated_request_data["query"] + except Exception as e: + # TinyShield validation failed - return error as streaming response. + return self._error_response(str(e), search_id=None) try: # Retrieve AI Model. From 6fcdf7b08715658c00342d4608435f53bde76fd9 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Tue, 14 Jul 2026 15:45:23 -0400 Subject: [PATCH 10/11] DEV-14675 Fixing 'too many return statements' error from lint check by consolidating 2 try blocks --- usaspending_api/llm/v2/views/filter_search.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/usaspending_api/llm/v2/views/filter_search.py b/usaspending_api/llm/v2/views/filter_search.py index 2810c218e9..7fef8d0d9c 100644 --- a/usaspending_api/llm/v2/views/filter_search.py +++ b/usaspending_api/llm/v2/views/filter_search.py @@ -38,20 +38,12 @@ def post(self, request: Request) -> StreamingHttpResponse: models = [ {"name": "filter_search", "key": "query", "type": "text", "text_type": "search", "min": 1, "max": 1000} ] - # On failure, TinyShield raises UnprocessableEntityException. + try: + # Validate request and retrieve AI model. validated_request_data = TinyShield(models).block(request.data) query = validated_request_data["query"] - except Exception as e: - # TinyShield validation failed - return error as streaming response. - return self._error_response(str(e), search_id=None) - - try: - # Retrieve AI Model. - try: - ai_model = self._get_ai_model() - except ValueError as e: - return self._error_response(str(e), search_id=None) + ai_model = self._get_ai_model() # Get available tools. tools = self.tools From 1d65cfb62e2e024df6dd6869d31e694c124ae1fe Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Tue, 14 Jul 2026 15:48:53 -0400 Subject: [PATCH 11/11] Removing excess whitespace on newline --- usaspending_api/llm/v2/views/filter_search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usaspending_api/llm/v2/views/filter_search.py b/usaspending_api/llm/v2/views/filter_search.py index 7fef8d0d9c..166d420385 100644 --- a/usaspending_api/llm/v2/views/filter_search.py +++ b/usaspending_api/llm/v2/views/filter_search.py @@ -38,7 +38,7 @@ def post(self, request: Request) -> StreamingHttpResponse: models = [ {"name": "filter_search", "key": "query", "type": "text", "text_type": "search", "min": 1, "max": 1000} ] - + try: # Validate request and retrieve AI model. validated_request_data = TinyShield(models).block(request.data)