From 8996ff72de9195d362c65723988b679502d3f24a Mon Sep 17 00:00:00 2001 From: Zach Flanders Date: Thu, 8 Oct 2026 08:06:01 -0500 Subject: [PATCH 1/5] Fixes for async filter search endpoint --- usaspending_api/async_api.py | 1 + .../llm/assistants/filter_search.py | 26 +++++---- usaspending_api/llm/models/py_models.py | 55 +++++++++++++++---- .../test_filter_search_assistant.py | 19 ++++--- .../llm/tests/integration/test_py_models.py | 8 +-- usaspending_api/llm/v2/views/__init__.py | 1 + usaspending_api/references/helpers.py | 10 ++++ 7 files changed, 85 insertions(+), 35 deletions(-) diff --git a/usaspending_api/async_api.py b/usaspending_api/async_api.py index 5f5a763256..3c938f1fb2 100644 --- a/usaspending_api/async_api.py +++ b/usaspending_api/async_api.py @@ -1,6 +1,7 @@ from ninja import NinjaAPI from usaspending_api.config import CONFIG +from usaspending_api.llm.v2 import views as llm_views # noqa: F401 (registers routes on llm_router) from usaspending_api.llm.v2.router import router as llm_router api = NinjaAPI( diff --git a/usaspending_api/llm/assistants/filter_search.py b/usaspending_api/llm/assistants/filter_search.py index b70c462aac..6338713417 100644 --- a/usaspending_api/llm/assistants/filter_search.py +++ b/usaspending_api/llm/assistants/filter_search.py @@ -9,7 +9,7 @@ from usaspending_api.common.helpers.aws_helpers import async_aws_client from usaspending_api.llm.models.db_models import Assistant, Message, Session, ToolUse -from usaspending_api.llm.models.py_models import AITool, build_defc_description +from usaspending_api.llm.models.py_models import AITool, abuild_defc_description logger = logging.getLogger(__name__) @@ -29,6 +29,7 @@ def __init__(self, assistant: Assistant, tools: list[AITool], session: Session) self.message_order = 0 self.messages = [] self.tool_iterations = 0 + self._tool_config: dict[str, list[dict]] | None = None @staticmethod def _extract_text_from_content(content: list[dict]) -> str: @@ -111,14 +112,17 @@ async def _create_message_from_response(self, response: dict) -> Message: self.messages.append(output_message) return message - @cached_property - def tool_config(self) -> dict[str, list[dict]]: - specs = [tool.description.model_dump() for tool in self.tools] - for spec in specs: - def_codes_property = spec["input_schema"].get("properties", {}).get("defCodes") - if def_codes_property is not None: - def_codes_property["description"] = build_defc_description() - return {"tools": [{"toolSpec": {"inputSchema": {"json": spec.pop("input_schema")}, **spec}} for spec in specs]} + async def aget_tool_config(self) -> dict[str, list[dict]]: + if self._tool_config is None: + specs = [tool.description.model_dump() for tool in self.tools] + for spec in specs: + def_codes_property = spec["input_schema"].get("properties", {}).get("defCodes") + if def_codes_property is not None: + def_codes_property["description"] = await abuild_defc_description() + self._tool_config = { + "tools": [{"toolSpec": {"inputSchema": {"json": spec.pop("input_schema")}, **spec}} for spec in specs] + } + return self._tool_config @staticmethod def _fiscal_year_date_context() -> str: @@ -182,7 +186,7 @@ async def search(self, query: str) -> AsyncGenerator[dict[str, str], None]: response = await client.converse( modelId=self.assistant.ai_model.model_id, messages=self.messages, - toolConfig=self.tool_config, + toolConfig=await self.aget_tool_config(), system=[{"text": self.system_message}], inferenceConfig=self.inference_config, ) @@ -222,7 +226,7 @@ async def search(self, query: str) -> AsyncGenerator[dict[str, str], None]: response = await client.converse( modelId=self.assistant.ai_model.model_id, messages=self.messages, - toolConfig=self.tool_config, + toolConfig=await self.aget_tool_config(), system=[{"text": self.system_message}], inferenceConfig=self.inference_config, ) diff --git a/usaspending_api/llm/models/py_models.py b/usaspending_api/llm/models/py_models.py index cb9bb1acea..d534d54b60 100644 --- a/usaspending_api/llm/models/py_models.py +++ b/usaspending_api/llm/models/py_models.py @@ -1,11 +1,10 @@ -from functools import lru_cache from typing import Annotated, Any, Callable, Literal from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator from usaspending_api.awards.v2.lookups.lookups import all_awards_types_to_category from usaspending_api.common.helpers.orm_helpers import award_types_are_valid_groups -from usaspending_api.references.helpers import get_defc_code_details +from usaspending_api.references.helpers import aget_defc_code_details, get_defc_code_details class InferenceConfig(BaseModel): @@ -154,19 +153,43 @@ class CodeLists(BaseModel): DEFC_GROUPS = ("covid_19", "infrastructure") +# Shared by get_defc_rows()/aget_defc_rows() -- hits the DB once per process (whichever of the sync/async +# getters is called first), then every subsequent call (sync or async) just reads this cache. A plain +# module-level cache is used instead of @lru_cache since lru_cache can't safely work with async +_defc_rows_cache: tuple[dict, ...] | None = None + + +def clear_defc_rows_cache() -> None: + """Test helper: reset the process-wide DEFC rows cache shared by get_defc_rows()/aget_defc_rows().""" + global _defc_rows_cache + _defc_rows_cache = None + -@lru_cache(maxsize=1) def get_defc_rows() -> tuple[dict, ...]: - """DEFC rows for the frontend-supported groups; hits the DB once per process, then cached.""" - return tuple(get_defc_code_details(list(DEFC_GROUPS))) + """DEFC rows for the frontend-supported groups; hits the DB once per process, then cached. + + Sync entry point -- used by the (necessarily synchronous) Pydantic validator in DEFCodeLists. + See aget_defc_rows() for the async-safe counterpart. + """ + global _defc_rows_cache + if _defc_rows_cache is None: + _defc_rows_cache = tuple(get_defc_code_details(list(DEFC_GROUPS))) + return _defc_rows_cache + + +async def aget_defc_rows() -> tuple[dict, ...]: + """Async counterpart to get_defc_rows(); safe to call from an async context.""" + global _defc_rows_cache + if _defc_rows_cache is None: + _defc_rows_cache = tuple(await aget_defc_code_details(list(DEFC_GROUPS))) + return _defc_rows_cache def get_valid_defc_codes() -> frozenset[str]: return frozenset(row["code"] for row in get_defc_rows()) -def build_defc_description() -> str: - """LLM-facing description for the defCodes field, built from the DB rows above.""" +def _format_defc_description(rows: tuple[dict, ...]) -> str: labels = {"covid_19": "COVID-19", "infrastructure": "Infrastructure"} lines = [ "Disaster/Emergency Fund Codes (DEFC) filter with 'require'/'exclude' lists. Only COVID-19 and " @@ -174,17 +197,27 @@ def build_defc_description() -> str: "from the matching group:", ] rows_by_group: dict[str, list[dict]] = {} - for row in get_defc_rows(): + for row in rows: rows_by_group.setdefault(row["group_name"], []).append(row) for group_name in DEFC_GROUPS: - rows = rows_by_group.get(group_name, []) - codes = ", ".join(f"'{row['code']}'" for row in rows) + group_rows = rows_by_group.get(group_name, []) + codes = ", ".join(f"'{row['code']}'" for row in group_rows) lines.append(f" {labels.get(group_name, group_name)}: [{codes}]") - for row in rows: + for row in group_rows: lines.append(f" {row['code']} = {row['title']} ({row['public_law']})") return "\n".join(lines) +def build_defc_description() -> str: + """LLM-facing description for the defCodes field, built from the DB rows above.""" + return _format_defc_description(get_defc_rows()) + + +async def abuild_defc_description() -> str: + """Async counterpart to build_defc_description(); safe to call from an async context.""" + return _format_defc_description(await aget_defc_rows()) + + RecipientType = Literal[ "business", "small_business", diff --git a/usaspending_api/llm/tests/integration/test_filter_search_assistant.py b/usaspending_api/llm/tests/integration/test_filter_search_assistant.py index 86b29dc21d..6c5da716d3 100644 --- a/usaspending_api/llm/tests/integration/test_filter_search_assistant.py +++ b/usaspending_api/llm/tests/integration/test_filter_search_assistant.py @@ -2,11 +2,12 @@ from unittest.mock import AsyncMock, Mock, patch import pytest +from asgiref.sync import sync_to_async from model_bakery import baker from usaspending_api.llm.assistants.filter_search import FilterSearchAssistant from usaspending_api.llm.models.db_models import AIModel, Assistant, Session -from usaspending_api.llm.models.py_models import AITool, get_defc_rows +from usaspending_api.llm.models.py_models import AITool, clear_defc_rows_cache from usaspending_api.llm.tools.execute_filter import execute_filter_tool @@ -338,9 +339,9 @@ def create_message(**kwargs): assert assistant.tool_iterations == assistant.MAX_TOOL_ITERATIONS assert assistant.client.converse.call_count == assistant.MAX_TOOL_ITERATIONS + 1 - def test_tool_config_property(self, assistant, mock_tool): + async def test_tool_config_property(self, assistant, mock_tool): """Test that tool_config is properly formatted.""" - config = assistant.tool_config + config = await assistant.aget_tool_config() assert "tools" in config assert len(config["tools"]) == 2 @@ -348,14 +349,14 @@ def test_tool_config_property(self, assistant, mock_tool): assert "inputSchema" in config["tools"][0]["toolSpec"] @pytest.mark.django_db - def test_tool_config_patches_defcodes_description_from_db(self, mock_assistant, mock_session): + async def test_tool_config_patches_defcodes_description_from_db(self, mock_assistant, mock_session): """tool_config injects a DB-sourced defCodes description into execute_filter's schema. The description is patched lazily here (not at execute_filter_tool's module-import time) - so the DB query only ever runs once real DB access is available -- see get_defc_rows. + so the DB query only ever runs once real DB access is available -- see get_defc_rows/aget_defc_rows. """ - get_defc_rows.cache_clear() - baker.make( + clear_defc_rows_cache() + await sync_to_async(baker.make)( "references.DisasterEmergencyFundCode", code="L", public_law="PUBLIC LAW FOR CODE L", @@ -364,12 +365,12 @@ def test_tool_config_patches_defcodes_description_from_db(self, mock_assistant, ) assistant = FilterSearchAssistant(assistant=mock_assistant, tools=[execute_filter_tool], session=mock_session) - config = assistant.tool_config + config = await assistant.aget_tool_config() description = config["tools"][0]["toolSpec"]["inputSchema"]["json"]["properties"]["defCodes"]["description"] assert "TITLE FOR CODE L" in description - get_defc_rows.cache_clear() + clear_defc_rows_cache() @patch("usaspending_api.llm.models.db_models.Message.objects.acreate", new_callable=AsyncMock) async def test_message_ordering(self, mock_message_create, assistant): diff --git a/usaspending_api/llm/tests/integration/test_py_models.py b/usaspending_api/llm/tests/integration/test_py_models.py index 1f2e902391..36f7b87db9 100644 --- a/usaspending_api/llm/tests/integration/test_py_models.py +++ b/usaspending_api/llm/tests/integration/test_py_models.py @@ -4,7 +4,7 @@ from model_bakery import baker from pydantic import ValidationError -from usaspending_api.llm.models.py_models import AwardAmounts, Filters, InferenceConfig, get_defc_rows +from usaspending_api.llm.models.py_models import AwardAmounts, Filters, InferenceConfig, clear_defc_rows_cache class TestInferenceConfig: @@ -615,13 +615,13 @@ class TestDefCodes: """Tests for the DEFC code restriction (COVID-19 + Infrastructure only) on the Filters model. DEFC validity is sourced from the DisasterEmergencyFundCode table (see get_defc_rows), cached - for the life of the process via lru_cache -- cache_clear() must run before each test so one test's + for the life of the process -- clear_defc_rows_cache() must run before each test so one test's DB-seeded codes don't leak into another's (django_db rolls back the DB, but not the Python-level cache). """ @pytest.fixture(autouse=True) def seed_defc_codes(self): - get_defc_rows.cache_clear() + clear_defc_rows_cache() for code in ["L", "M", "N", "O", "P", "U", "V"]: baker.make( "references.DisasterEmergencyFundCode", @@ -646,7 +646,7 @@ def seed_defc_codes(self): group_name="other", ) yield - get_defc_rows.cache_clear() + clear_defc_rows_cache() def test_def_codes_default_empty(self): filters = Filters() diff --git a/usaspending_api/llm/v2/views/__init__.py b/usaspending_api/llm/v2/views/__init__.py index e69de29bb2..07fc2efc38 100644 --- a/usaspending_api/llm/v2/views/__init__.py +++ b/usaspending_api/llm/v2/views/__init__.py @@ -0,0 +1 @@ +from usaspending_api.llm.v2.views import filter_search # noqa: F401 diff --git a/usaspending_api/references/helpers.py b/usaspending_api/references/helpers.py index 0df3de54d6..b23a81109d 100644 --- a/usaspending_api/references/helpers.py +++ b/usaspending_api/references/helpers.py @@ -63,6 +63,16 @@ def get_defc_code_details(group_names: list[str]) -> list[dict]: ) +async def aget_defc_code_details(group_names: list[str]) -> list[dict]: + """Async counterpart to get_defc_code_details(); safe to call from an async context.""" + return [ + row + async for row in DisasterEmergencyFundCode.objects.filter(group_name__in=group_names) + .values("code", "group_name", "title", "public_law") + .order_by("group_name", "code") + ] + + def create_hash(payload: bytes) -> str: """ Create a MD5 hash From 7e9e629df80ae6d5425236684b852b04d45320ae Mon Sep 17 00:00:00 2001 From: Zach Flanders Date: Thu, 8 Oct 2026 12:50:33 -0500 Subject: [PATCH 2/5] Update import strategy for view to connect to router --- usaspending_api/async_api.py | 1 - usaspending_api/llm/v2/router.py | 3 +++ usaspending_api/llm/v2/views/__init__.py | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/usaspending_api/async_api.py b/usaspending_api/async_api.py index 3c938f1fb2..5f5a763256 100644 --- a/usaspending_api/async_api.py +++ b/usaspending_api/async_api.py @@ -1,7 +1,6 @@ from ninja import NinjaAPI from usaspending_api.config import CONFIG -from usaspending_api.llm.v2 import views as llm_views # noqa: F401 (registers routes on llm_router) from usaspending_api.llm.v2.router import router as llm_router api = NinjaAPI( diff --git a/usaspending_api/llm/v2/router.py b/usaspending_api/llm/v2/router.py index 6fd67a0668..69ebbce10d 100644 --- a/usaspending_api/llm/v2/router.py +++ b/usaspending_api/llm/v2/router.py @@ -3,3 +3,6 @@ from usaspending_api.llm.v2.auth import LLMApiKeyAuth router = Router(auth=LLMApiKeyAuth(), tags=["SmartAssist"]) + +# The router must be initialized before importing the view to avoid circular import +from usaspending_api.llm.v2.views import filter_search # noqa: E402, F401 diff --git a/usaspending_api/llm/v2/views/__init__.py b/usaspending_api/llm/v2/views/__init__.py index 07fc2efc38..e69de29bb2 100644 --- a/usaspending_api/llm/v2/views/__init__.py +++ b/usaspending_api/llm/v2/views/__init__.py @@ -1 +0,0 @@ -from usaspending_api.llm.v2.views import filter_search # noqa: F401 From 8d9e04bc242663697fbcd9ae563c250cee0b8e67 Mon Sep 17 00:00:00 2001 From: Seth Stoudenmier Date: Fri, 9 Oct 2026 13:27:31 -0400 Subject: [PATCH 3/5] Handle django-ninja endpoint in DRF UI; add test case to validate endpoint is reachable --- usaspending_api/common/helpers/decorators.py | 50 ++++++++++++-- .../common/helpers/endpoint_documentation.py | 67 ++++++++++++++++--- .../check_for_endpoint_documentation.py | 28 +++++--- usaspending_api/llm/tests/conftest.py | 10 +++ .../test_filter_search_endpoint.py | 11 +++ usaspending_api/llm/v2/views/filter_search.py | 5 +- 6 files changed, 145 insertions(+), 26 deletions(-) create mode 100644 usaspending_api/llm/tests/conftest.py create mode 100644 usaspending_api/llm/tests/integration/test_filter_search_endpoint.py diff --git a/usaspending_api/common/helpers/decorators.py b/usaspending_api/common/helpers/decorators.py index cf0202ac5c..82a3b9a304 100644 --- a/usaspending_api/common/helpers/decorators.py +++ b/usaspending_api/common/helpers/decorators.py @@ -1,14 +1,19 @@ import logging +from typing import Callable from django.db import connection from django.db.utils import OperationalError +from ninja import Router +from rest_framework.request import Request +from rest_framework.views import APIView from usaspending_api.common.exceptions import EndpointTimeoutException +from usaspending_api.common.helpers.endpoint_documentation import DJANGO_NINJA_TEMP_NAME_FOR_DRF logger = logging.getLogger(__name__) -def set_db_timeout(timeout_in_seconds): +def set_db_timeout(timeout_in_seconds: int) -> Callable: """Decorator used to set the database statement timeout within the Django app scope Args: @@ -32,8 +37,8 @@ def func_running_db_call(...): """ timeout_in_ms = int(timeout_in_seconds * 1000) - def wrap(func): - def wrapper(*args, **kwargs): + def wrap(func: Callable) -> Callable: + def wrapper(*args, **kwargs) -> Callable: with connection.cursor() as cursor: cursor.execute("show statement_timeout") prev_timeout = cursor.fetchall()[0][0] @@ -57,8 +62,10 @@ def wrapper(*args, **kwargs): try: func_response = func(*args, **kwargs) - except OperationalError: - raise EndpointTimeoutException("Django ORM exceeded the specified timeout of %ds" % timeout_in_seconds) + except OperationalError as exc: + raise EndpointTimeoutException( + "Django ORM exceeded the specified timeout of %ds" % timeout_in_seconds + ) from exc finally: with connection.cursor() as cursor: cursor.execute("show statement_timeout") @@ -85,3 +92,36 @@ def wrapper(*args, **kwargs): return wrapper return wrap + + +def _served_by_ninja(self: APIView, request: Request, *args, **kwargs) -> NotImplementedError: + """Real traffic is handled by the Django Ninja operation; this exists only so + DRF advertises the method and renders the browsable page for a browser GET.""" + raise NotImplementedError + + +def browsable( + router: Router, path: str, *, endpoint_doc: str, methods: tuple[str] = ("POST",), **ninja_kwargs +) -> Callable: + """ + This decorator is a temporary solution while we have the DRF API UI that needs to display the endpoints + defined with Django Ninja. + """ + + def decorator(view_func: Callable) -> Callable: + doc_view = type( + f"{view_func.__name__.title().replace('_', '')}", + (APIView,), + { + "__doc__": view_func.__doc__, + "endpoint_doc": endpoint_doc, + **{m.lower(): _served_by_ninja for m in methods}, + }, + ) + rendered = doc_view.as_view() + router.get(path, auth=None, include_in_schema=False, url_name=DJANGO_NINJA_TEMP_NAME_FOR_DRF)( + lambda request: rendered(request) + ) + return router.api_operation(list(methods), path, **ninja_kwargs)(view_func) + + return decorator diff --git a/usaspending_api/common/helpers/endpoint_documentation.py b/usaspending_api/common/helpers/endpoint_documentation.py index 04e32c1dce..c5d2632e7a 100644 --- a/usaspending_api/common/helpers/endpoint_documentation.py +++ b/usaspending_api/common/helpers/endpoint_documentation.py @@ -1,5 +1,9 @@ +import inspect import os import re +from typing import Callable + +from django.urls import URLPattern from usaspending_api.settings import REPO_DIR @@ -20,8 +24,12 @@ "/api/v2/bulk_download/list_database_download_files", ] +# A constant value used to distinguish the views created to satisfy display Django Ninja views +# in the current Django REST Framework API UI +DJANGO_NINJA_TEMP_NAME_FOR_DRF = "for_django_ninja_in_drf" + -def case_sensitive_file_exists(file_path): +def case_sensitive_file_exists(file_path: str) -> bool: """ File names are case insensitive on Macs and Windows and case sensitive on Linux. This is one way to perform case sensitive file checking on a case @@ -31,7 +39,7 @@ def case_sensitive_file_exists(file_path): return os.path.isfile(file_path) and filename in os.listdir(directory) -def get_endpoint_urls_doc_paths_and_docstrings(endpoint_prefixes=None): +def get_endpoint_urls_doc_paths_and_docstrings(endpoint_prefixes: str | None = None) -> list[tuple[str, URLPattern]]: """ Compiles the list of endpoint URLs and associated RegexURLPattern objects serviced by Django. If path_prefixes is supplied, returned URLS will be @@ -44,11 +52,15 @@ def get_endpoint_urls_doc_paths_and_docstrings(endpoint_prefixes=None): results = [] - def _traverse_urls(base, url_patterns): + def _traverse_urls(base: str, url_patterns: list[URLPattern], resolver_name: str | None = None) -> None: for url in url_patterns: + if resolver_name == "ninja" and getattr(url, "name", None) != DJANGO_NINJA_TEMP_NAME_FOR_DRF: + # For the purpose of the DRF docs on Django Ninja endpoints we only want the views that are created + # by the "browsable" decorator (see usaspending_api.common.helpers.decorators.browsable) + continue cleaned = base + url.pattern.regex.pattern.lstrip("^").rstrip("$") if hasattr(url, "url_patterns"): - _traverse_urls(cleaned, url.url_patterns) + _traverse_urls(cleaned, url.url_patterns, url.app_name) elif not endpoint_prefixes or cleaned.startswith(endpoint_prefixes): results.append((cleaned, url)) @@ -56,7 +68,7 @@ def _traverse_urls(base, url_patterns): return results -def get_endpoints_from_endpoints_markdown(): +def get_endpoints_from_endpoints_markdown() -> list[str]: """ Looks for and extracts URLs for patterns like |[display](url)|method|description| from the master endpoints.md markdown file. @@ -66,7 +78,7 @@ def get_endpoints_from_endpoints_markdown(): return [e.split("?")[0] for e in ENDPOINT_PATTERN.findall(contents) if e] -def get_fully_qualified_name(obj): +def get_fully_qualified_name(obj: object) -> str: """ Fully qualifies an object name. For example, would return "usaspending_api.common.helpers.endpoint_documentation.get_fully_qualified_name" @@ -75,7 +87,7 @@ def get_fully_qualified_name(obj): return "{}.{}".format(obj.__module__, obj.__qualname__) -def validate_docs(url, url_object, master_endpoint_list): +def validate_docs(url: str, url_object: URLPattern, master_endpoint_list: list[str]) -> list[str]: # noqa: PLR0912 """ Ensures that an endpoint_doc property and a docstring is provided for the view associated with the provided url and checks that the URL is mentioned @@ -88,7 +100,7 @@ def validate_docs(url, url_object, master_endpoint_list): return [] # Handles class and function based views. - view = url_object.callback.cls if getattr(url_object.callback, "cls", None) else url_object.callback + view = get_view_for_endpoint(url, url_object) messages = [] @@ -96,7 +108,7 @@ def validate_docs(url, url_object, master_endpoint_list): if url not in _EXCLUDED_URLS: messages.append("{} ({}) missing endpoint_doc property".format(qualified_name, url)) else: - endpoint_doc = getattr(view, "endpoint_doc") + endpoint_doc = view.endpoint_doc if not endpoint_doc: messages.append("{}.endpoint_doc ({}) is invalid".format(qualified_name, url)) else: @@ -127,3 +139,40 @@ def validate_docs(url, url_object, master_endpoint_list): messages.append("No URL found in {} that matches {} ({})".format(ENDPOINTS_MD, url, qualified_name)) return messages + + +def get_view_for_endpoint(url: str, url_object: URLPattern) -> type: + view = url_object.callback.cls if getattr(url_object.callback, "cls", None) else url_object.callback + + # Handle the possibility that "endpoint_doc" doesn't exist in the expected location because it was + # implemented with django-ninja + if not hasattr(view, "endpoint_doc") and url not in _EXCLUDED_URLS: + view = unwrap_ninja_view(view) + + return view + + +def unwrap_ninja_view(callback: type | Callable) -> type: + """ + In order for Django Ninja endpoints to appear in the DRF API UI a new view is created that renders onto the DRF UI. + To handle this new view in a similar way as the current DRF views we need to step through the decorators and + retrieve the view class that has the docstring and "endpoint_doc" property added dynamically. + """ + try: + path_view = inspect.getclosurevars(callback).nonlocals.get("self") + except TypeError: + return callback + + operations = getattr(path_view, "operations", None) + if not operations: + return callback + + view_func = operations[0].view_func + + if not hasattr(view_func, "cls"): + try: + closure_vars = inspect.getclosurevars(view_func).nonlocals + except TypeError: + closure_vars = {} + view_func = next((v for v in closure_vars.values() if hasattr(v, "cls")), view_func) + return getattr(view_func, "cls", view_func) diff --git a/usaspending_api/common/management/commands/check_for_endpoint_documentation.py b/usaspending_api/common/management/commands/check_for_endpoint_documentation.py index d991de3c43..07a5124002 100644 --- a/usaspending_api/common/management/commands/check_for_endpoint_documentation.py +++ b/usaspending_api/common/management/commands/check_for_endpoint_documentation.py @@ -1,17 +1,23 @@ +import logging + from django.core.management.base import BaseCommand + from usaspending_api.common.helpers.endpoint_documentation import ( + _EXCLUDED_URLS, CURRENT_ENDPOINT_PREFIXES, get_endpoint_urls_doc_paths_and_docstrings, get_endpoints_from_endpoints_markdown, + get_view_for_endpoint, validate_docs, ) +logger = logging.getLogger(__name__) -class Command(BaseCommand): +class Command(BaseCommand): help = "Checks endpoints to ensure they point to documentation and are contained in the master endpoint list." - def handle(self, *args, **options): + def handle(self, *args, **options) -> None: master_endpoint_list = get_endpoints_from_endpoints_markdown() endpoints = get_endpoint_urls_doc_paths_and_docstrings(CURRENT_ENDPOINT_PREFIXES) @@ -22,20 +28,20 @@ def handle(self, *args, **options): if messages: for message in messages: - print(message) + logger.error(message) exit(1) # By request, let's count how much documentation falls in the contracts directory vs not-contracts. contract_count = sum( - getattr( - e[1].callback.cls if getattr(e[1].callback, "cls", None) else e[1].callback, "endpoint_doc", "" - ).startswith("usaspending_api/api_contracts/contracts/") + getattr(get_view_for_endpoint(*e), "endpoint_doc", "").startswith( + "usaspending_api/api_contracts/contracts/" + ) for e in endpoints ) - print("Looks like endpoint documentation is happy, healthy, wealthy, and wise. Current tally:") - print(" contract count: {:,}".format(contract_count)) - print(" non-contract count: {:,}".format(len(endpoints) - contract_count)) + logger.info("Looks like endpoint documentation is happy, healthy, wealthy, and wise. Current tally:") + logger.info(f" contract count: {contract_count:,}") + logger.info(f" non-contract count: {len(endpoints) - (contract_count + len(_EXCLUDED_URLS)):,}") + logger.info(f" excluded count: {len(_EXCLUDED_URLS):,}") if (len(endpoints) - contract_count) == 0: - print() - print("*** GOOD JOB TEAM! ***") + logger.info("\n*** GOOD JOB TEAM! ***") diff --git a/usaspending_api/llm/tests/conftest.py b/usaspending_api/llm/tests/conftest.py new file mode 100644 index 0000000000..4371ae0828 --- /dev/null +++ b/usaspending_api/llm/tests/conftest.py @@ -0,0 +1,10 @@ +from unittest.mock import patch + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def patch_llm_api_header(): + with patch("usaspending_api.llm.v2.auth.LLMApiKeyAuth.authenticate") as mock_llm_api_auth: + mock_llm_api_auth.return_value = True + yield mock_llm_api_auth diff --git a/usaspending_api/llm/tests/integration/test_filter_search_endpoint.py b/usaspending_api/llm/tests/integration/test_filter_search_endpoint.py new file mode 100644 index 0000000000..321c19a3fa --- /dev/null +++ b/usaspending_api/llm/tests/integration/test_filter_search_endpoint.py @@ -0,0 +1,11 @@ +import pytest + +pytestmark = pytest.mark.django_db + + +def test_filter_search_endpoint_reachable(client): + """ + The filter-search endpoint uses django-ninja, and we want to make sure that it is reachable + """ + resp = client.post("/api/v2/llm/filter-search/", content_type="application/json", data={"query": "sample data"}) + assert resp.status_code == 200 diff --git a/usaspending_api/llm/v2/views/filter_search.py b/usaspending_api/llm/v2/views/filter_search.py index fdd7699b24..b54d4ad1e8 100644 --- a/usaspending_api/llm/v2/views/filter_search.py +++ b/usaspending_api/llm/v2/views/filter_search.py @@ -5,6 +5,7 @@ from django.http import HttpRequest, StreamingHttpResponse from django.utils import timezone +from usaspending_api.common.helpers.decorators import browsable from usaspending_api.llm.assistants.filter_search import FilterSearchAssistant from usaspending_api.llm.models.db_models import Assistant, Session, ToolUse from usaspending_api.llm.models.py_models import FilterSearchEvent, FilterSearchInput @@ -39,9 +40,11 @@ def _stream_response(event_source: AsyncGenerator[str, None]) -> StreamingHttpRe return response -@router.post( +@browsable( + router, "/filter-search/", url_name="filter_search", + endpoint_doc="usaspending_api/api_contracts/contracts/v2/llm/filter_search.md", ) async def filter_search(request: HttpRequest, payload: FilterSearchInput) -> StreamingHttpResponse: """ From 9f1a1f1c9b380556615164ab9318009f9bc0a9ed Mon Sep 17 00:00:00 2001 From: aguest-kc Date: Fri, 9 Oct 2026 15:12:53 -0500 Subject: [PATCH 4/5] Fix POST button in DRF generated API docs --- usaspending_api/common/helpers/decorators.py | 49 +++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/usaspending_api/common/helpers/decorators.py b/usaspending_api/common/helpers/decorators.py index 82a3b9a304..c039d8a230 100644 --- a/usaspending_api/common/helpers/decorators.py +++ b/usaspending_api/common/helpers/decorators.py @@ -1,10 +1,14 @@ import logging -from typing import Callable +from typing import Callable, Coroutine +from asgiref.sync import sync_to_async from django.db import connection from django.db.utils import OperationalError +from django.http import HttpResponseBase, StreamingHttpResponse from ninja import Router +from ninja.decorators import decorate_view from rest_framework.request import Request +from rest_framework.response import Response as DRFResponse from rest_framework.views import APIView from usaspending_api.common.exceptions import EndpointTimeoutException @@ -94,10 +98,22 @@ def wrapper(*args, **kwargs) -> Callable: return wrap -def _served_by_ninja(self: APIView, request: Request, *args, **kwargs) -> NotImplementedError: - """Real traffic is handled by the Django Ninja operation; this exists only so - DRF advertises the method and renders the browsable page for a browser GET.""" - raise NotImplementedError +def _show_browsable_result(self: APIView, request: Request, *args, **kwargs) -> DRFResponse: + """Renders the real Ninja response that the browsable()-installed run() wrapper + already buffered onto the request, through DRF's normal browsable rendering pipeline.""" + try: + status_code, body_text = request._browsable_result + except AttributeError as exc: + raise NotImplementedError("browsable() did not buffer a result for this request.") from exc + return DRFResponse(body_text, status=status_code) + + +async def _buffer_streaming_response(response: HttpResponseBase) -> tuple[int, str]: + if isinstance(response, StreamingHttpResponse): + body = b"".join([part async for part in response]) + else: + body = response.content + return response.status_code, body.decode() def browsable( @@ -115,13 +131,32 @@ def decorator(view_func: Callable) -> Callable: { "__doc__": view_func.__doc__, "endpoint_doc": endpoint_doc, - **{m.lower(): _served_by_ninja for m in methods}, + **{m.lower(): _show_browsable_result for m in methods}, }, ) rendered = doc_view.as_view() router.get(path, auth=None, include_in_schema=False, url_name=DJANGO_NINJA_TEMP_NAME_FOR_DRF)( lambda request: rendered(request) ) - return router.api_operation(list(methods), path, **ninja_kwargs)(view_func) + + registered_view_func = router.api_operation(list(methods), path, **ninja_kwargs)(view_func) + operation = registered_view_func._ninja_operation + if not operation.is_async: + raise RuntimeError("@browsable only supports async Ninja operations.") + + def _html_aware_run(run_func: Callable) -> Callable: + async def wrapper(request: Request, *args, **kwargs) -> Coroutine: + result = await run_func(request, *args, **kwargs) + if "text/html" not in request.headers.get("Accept", ""): + return result + status_code, body_text = await _buffer_streaming_response(result) + request._browsable_result = (status_code, body_text) + return await sync_to_async(rendered, thread_sensitive=True)(request) + + return wrapper + + decorate_view(_html_aware_run)(registered_view_func) + + return registered_view_func return decorator From ac19f54d8b77b158bcb572846c4e6b8ab69660a7 Mon Sep 17 00:00:00 2001 From: aguest-kc Date: Fri, 9 Oct 2026 15:24:46 -0500 Subject: [PATCH 5/5] Update supervisor to 4.3.* --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aa5d3374b3..1b018c4d2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,7 +129,7 @@ server = [ "packaging==22.0", "pyparsing==3.2.1", "setuptools<81", - "supervisor==4.1.0", + "supervisor==4.3.*", "uwsgi==2.0.23", ] ansible = ["ansible==14.4.*"] diff --git a/uv.lock b/uv.lock index c3cbf0ef7f..cdc306a08e 100644 --- a/uv.lock +++ b/uv.lock @@ -2122,11 +2122,11 @@ wheels = [ [[package]] name = "supervisor" -version = "4.1.0" +version = "4.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/87/ee1ad8fa533a4b5f2c7623f4a2b585d3c1947af7bed8e65bc7772274320e/supervisor-4.1.0.tar.gz", hash = "sha256:2dc86fe0476e945e61483d614ceb2cf4f93b95282eb243bdf792621994360383", size = 459020, upload-time = "2019-10-19T17:58:28.94Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/b5/37e7a3706de436a8a2d75334711dad1afb4ddffab09f25e31d89e467542f/supervisor-4.3.0.tar.gz", hash = "sha256:4a2bf149adf42997e1bb44b70c43b613275ec9852c3edacca86a9166b27e945e", size = 468912, upload-time = "2025-08-23T18:25:02.418Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/1f/07713b0e1e34c312450878801d496bce8b9eff5ea9e70d41ff4e299b2df5/supervisor-4.1.0-py2.py3-none-any.whl", hash = "sha256:a76b2f77a560f2dc411c0254a4eb15f555e99faac48621b0f1fc9ab013944f47", size = 318422, upload-time = "2019-10-19T17:58:26.052Z" }, + { url = "https://files.pythonhosted.org/packages/0e/65/5e726c372da8a5e35022a94388b12252710aad0c2351699c3d76ae8dba78/supervisor-4.3.0-py2.py3-none-any.whl", hash = "sha256:0bcb763fddafba410f35cbde226aa7f8514b9fb82eb05a0c85f6588d1c13f8db", size = 320736, upload-time = "2025-08-23T18:25:00.767Z" }, ] [[package]] @@ -2436,7 +2436,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.14" }, { name = "setuptools", marker = "extra == 'server'", specifier = "<81" }, { name = "sqlparse", specifier = "==0.6.*" }, - { name = "supervisor", marker = "extra == 'server'", specifier = "==4.1.0" }, + { name = "supervisor", marker = "extra == 'server'", specifier = "==4.3.*" }, { name = "urllib3", specifier = "==2.8.*" }, { name = "uwsgi", marker = "extra == 'server'", specifier = "==2.0.23" }, { name = "xlrd3", specifier = "==1.1.0" },