feat: restore x-ai-description entries in OpenAPI schema (#16502)

feat: inject x-ai-description from overlay file during schema generation

Many endpoints have human-readable AI descriptions that were added
downstream in aap-mcp-server (PRs #73 and #119) but never backported
as @extend_schema_if_available decorators. This causes 470 out of 631
x-ai-description entries to be lost every time the spec is regenerated.

Add a JSON overlay file (openapi_ai_descriptions.json) containing the
missing descriptions keyed by operationId, and a drf-spectacular
postprocessing hook that merges them into the generated schema for any
operation that doesn't already have x-ai-description from a decorator.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodrigo Toshiaki Horie
2026-06-17 15:26:24 -03:00
committed by GitHub
parent 34f34e058b
commit 242f008f44
4 changed files with 637 additions and 3 deletions

View File

@@ -1,6 +1,7 @@
import copy
import json
import warnings
from unittest.mock import Mock, patch
from unittest.mock import Mock, mock_open, patch
from rest_framework.permissions import IsAuthenticated
@@ -10,6 +11,7 @@ from awx.api.schema import (
AuthenticatedSpectacularSwaggerView,
AuthenticatedSpectacularRedocView,
filter_credential_type_schema,
inject_ai_descriptions,
)
@@ -422,3 +424,128 @@ class TestFilterCredentialTypeSchema:
# PATCH schema: includes None (optional field)
assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net', None]
class TestInjectAiDescriptions:
"""Unit tests for inject_ai_descriptions postprocessing hook."""
def _make_result(self, operations):
"""Build a minimal OpenAPI result dict from a list of (path, method, operationId, existing_desc) tuples."""
paths = {}
for path, method, op_id, desc in operations:
paths.setdefault(path, {})[method] = {'operationId': op_id}
if desc:
paths[path][method]['x-ai-description'] = desc
return {'paths': paths}
def test_injects_missing_descriptions(self):
"""Test that descriptions are injected for operations without x-ai-description."""
overlay = {'op_list': 'List items', 'op_create': 'Create an item'}
result = self._make_result(
[
('/api/v2/items/', 'get', 'op_list', None),
('/api/v2/items/', 'post', 'op_create', None),
]
)
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
returned = inject_ai_descriptions(result, None, None, None)
assert result['paths']['/api/v2/items/']['get']['x-ai-description'] == 'List items'
assert result['paths']['/api/v2/items/']['post']['x-ai-description'] == 'Create an item'
assert returned is result
def test_does_not_overwrite_existing_descriptions(self):
"""Test that existing x-ai-description from decorators is preserved."""
overlay = {'op_list': 'Overlay description'}
result = self._make_result(
[
('/api/v2/items/', 'get', 'op_list', 'Decorator description'),
]
)
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
inject_ai_descriptions(result, None, None, None)
assert result['paths']['/api/v2/items/']['get']['x-ai-description'] == 'Decorator description'
def test_skips_operations_not_in_overlay(self):
"""Test that operations without a matching operationId in the overlay are unchanged."""
overlay = {'op_other': 'Other description'}
result = self._make_result(
[
('/api/v2/items/', 'get', 'op_list', None),
]
)
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
inject_ai_descriptions(result, None, None, None)
assert 'x-ai-description' not in result['paths']['/api/v2/items/']['get']
def test_handles_missing_overlay_file(self):
"""Test graceful handling when the overlay file doesn't exist."""
result = self._make_result(
[
('/api/v2/items/', 'get', 'op_list', None),
]
)
original = copy.deepcopy(result)
with patch('builtins.open', side_effect=FileNotFoundError):
returned = inject_ai_descriptions(result, None, None, None)
assert result == original
assert returned is result
def test_handles_invalid_json(self):
"""Test graceful handling when the overlay file contains invalid JSON."""
result = self._make_result(
[
('/api/v2/items/', 'get', 'op_list', None),
]
)
original = copy.deepcopy(result)
with patch('builtins.open', mock_open(read_data='not valid json')):
returned = inject_ai_descriptions(result, None, None, None)
assert result == original
assert returned is result
def test_handles_empty_result(self):
"""Test graceful handling when result has no paths."""
result = {}
overlay = {'op_list': 'List items'}
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
returned = inject_ai_descriptions(result, None, None, None)
assert returned is result
def test_skips_non_dict_path_items(self):
"""Test that non-dict values in path items (e.g. parameters list) are skipped."""
overlay = {'op_list': 'List items'}
result = {
'paths': {
'/api/v2/items/': {
'parameters': [{'name': 'id', 'in': 'path'}],
'get': {'operationId': 'op_list'},
}
}
}
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
inject_ai_descriptions(result, None, None, None)
assert result['paths']['/api/v2/items/']['get']['x-ai-description'] == 'List items'
def test_handles_operation_without_operation_id(self):
"""Test that operations without operationId are skipped."""
overlay = {'op_list': 'List items'}
result = {'paths': {'/api/v2/items/': {'get': {'summary': 'List'}}}}
with patch('builtins.open', mock_open(read_data=json.dumps(overlay))):
inject_ai_descriptions(result, None, None, None)
assert 'x-ai-description' not in result['paths']['/api/v2/items/']['get']