diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/assets.json b/sdk/contentunderstanding/azure-ai-contentunderstanding/assets.json index b5b691fd736d..7c2c0922a9cb 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/assets.json +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/contentunderstanding/azure-ai-contentunderstanding", - "Tag": "python/contentunderstanding/azure-ai-contentunderstanding_8f5aa72c31" + "Tag": "python/contentunderstanding/azure-ai-contentunderstanding_df271d5db5" } diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/_patch.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/_patch.py index 5a4862ed16e6..3fc555488ee5 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/_patch.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/_patch.py @@ -236,7 +236,7 @@ def begin_analyze_binary( analyzer_id: str, binary_input: bytes, *, - content_range: Optional[str] = None, + content_range: Optional[Union[str, _models.ContentRange]] = None, content_type: str = "application/octet-stream", processing_location: Optional[Union[str, _models.ProcessingLocation]] = None, **kwargs: Any, @@ -247,9 +247,11 @@ def begin_analyze_binary( :type analyzer_id: str :param binary_input: The binary content of the document to analyze. Required. :type binary_input: bytes - :keyword content_range: Range of the input to analyze (ex. ``1-3,5,9-``). Document content uses - 1-based page numbers, while audio visual content uses integer milliseconds. Default value is None. - :paramtype content_range: str + :keyword content_range: Range of the input to analyze. Accepts a + :class:`~azure.ai.contentunderstanding.models.ContentRange` or a raw string + (ex. ``"1-3,5,9-"``). Document content uses 1-based page numbers, + while audio visual content uses integer milliseconds. Default value is None. + :paramtype content_range: str or ~azure.ai.contentunderstanding.models.ContentRange :keyword content_type: Body Parameter content-type. Content type parameter for binary body. Default value is "application/octet-stream". :paramtype content_type: str @@ -266,13 +268,16 @@ def begin_analyze_binary( matches Python's native string indexing behavior (len() and str[i] use code points). This ensures ContentSpan offsets work correctly with Python string slicing. """ + # Convert ContentRange to string if needed + content_range_str = str(content_range) if content_range is not None else None + # Call parent implementation with string_encoding set to "codePoint" # (matches Python's string indexing) poller = super().begin_analyze_binary( analyzer_id=analyzer_id, binary_input=binary_input, string_encoding="codePoint", - content_range=content_range, + content_range=content_range_str, content_type=content_type, processing_location=processing_location, **kwargs, diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/aio/_patch.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/aio/_patch.py index 316b8f6a008a..14620146bd8b 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/aio/_patch.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/aio/_patch.py @@ -237,7 +237,7 @@ async def begin_analyze_binary( analyzer_id: str, binary_input: bytes, *, - content_range: Optional[str] = None, + content_range: Optional[Union[str, _models.ContentRange]] = None, content_type: str = "application/octet-stream", processing_location: Optional[Union[str, _models.ProcessingLocation]] = None, **kwargs: Any, @@ -248,9 +248,11 @@ async def begin_analyze_binary( :type analyzer_id: str :param binary_input: The binary content of the document to analyze. Required. :type binary_input: bytes - :keyword content_range: Range of the input to analyze (ex. ``1-3,5,9-``). Document content uses - 1-based page numbers, while audio visual content uses integer milliseconds. Default value is None. - :paramtype content_range: str + :keyword content_range: Range of the input to analyze. Accepts a + :class:`~azure.ai.contentunderstanding.models.ContentRange` or a raw string + (ex. ``"1-3,5,9-"``). Document content uses 1-based page numbers, + while audio visual content uses integer milliseconds. Default value is None. + :paramtype content_range: str or ~azure.ai.contentunderstanding.models.ContentRange :keyword content_type: Body Parameter content-type. Content type parameter for binary body. Default value is "application/octet-stream". :paramtype content_type: str @@ -267,13 +269,16 @@ async def begin_analyze_binary( matches Python's native string indexing behavior (len() and str[i] use code points). This ensures ContentSpan offsets work correctly with Python string slicing. """ + # Convert ContentRange to string if needed + content_range_str = str(content_range) if content_range is not None else None + # Call parent implementation with string_encoding set to "codePoint" # (matches Python's string indexing) poller = await super().begin_analyze_binary( analyzer_id=analyzer_id, binary_input=binary_input, string_encoding="codePoint", - content_range=content_range, + content_range=content_range_str, content_type=content_type, processing_location=processing_location, **kwargs, diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_content_range.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_content_range.py new file mode 100644 index 000000000000..ba4737210cde --- /dev/null +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_content_range.py @@ -0,0 +1,197 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------- + +"""ContentRange value type for specifying content ranges on AnalysisInput.""" + +from datetime import timedelta +from typing import Optional + + +class ContentRange: + """Represents a range of content to analyze. + + For documents, ranges use 1-based page numbers (e.g., ``"1-3"``, ``"5"``, ``"9-"``). + For audio/video, ranges use integer milliseconds (e.g., ``"0-5000"``, ``"5000-"``). + Multiple ranges can be combined with commas (e.g., ``"1-3,5,9-"``). + + Example usage:: + + # Document pages + range = ContentRange.page(5) # "5" + range = ContentRange.pages(1, 3) # "1-3" + range = ContentRange.pages_from(9) # "9-" + + # Audio/video time ranges + range = ContentRange.time_range( + timedelta(0), timedelta(seconds=5)) # "0-5000" + range = ContentRange.time_range_from( + timedelta(seconds=5)) # "5000-" + + # Combine multiple ranges + range = ContentRange.combine( + ContentRange.pages(1, 3), + ContentRange.page(5), + ContentRange.pages_from(9)) # "1-3,5,9-" + + # Or construct from a raw string + range = ContentRange("1-3,5,9-") + """ + + def __init__(self, value: str) -> None: + """Initialize a new ContentRange. + + :param value: The range string value. + :type value: str + :raises ValueError: If value is None or empty. + """ + if value is None: + raise ValueError("value cannot be None.") + self._value = value + + @classmethod + def page(cls, page_number: int) -> "ContentRange": + """Create a ContentRange for a single document page (1-based). + + :param page_number: The 1-based page number. + :type page_number: int + :return: A ContentRange representing a single page, e.g. ``"5"``. + :rtype: ~azure.ai.contentunderstanding.models.ContentRange + :raises ValueError: If page_number is less than 1. + """ + if page_number < 1: + raise ValueError("Page number must be >= 1.") + return cls(str(page_number)) + + @classmethod + def pages(cls, start: int, end: int) -> "ContentRange": + """Create a ContentRange for a contiguous range of document pages (1-based, inclusive). + + :param start: The 1-based start page number (inclusive). + :type start: int + :param end: The 1-based end page number (inclusive). + :type end: int + :return: A ContentRange representing the page range, e.g. ``"1-3"``. + :rtype: ~azure.ai.contentunderstanding.models.ContentRange + :raises ValueError: If start is less than 1, or end is less than start. + """ + if start < 1: + raise ValueError("Start page must be >= 1.") + if end < start: + raise ValueError("End page must be >= start page.") + return cls(f"{start}-{end}") + + @classmethod + def pages_from(cls, start_page: int) -> "ContentRange": + """Create a ContentRange for all pages from a starting page to the end (1-based). + + :param start_page: The 1-based start page number (inclusive). + :type start_page: int + :return: A ContentRange representing the open-ended range, e.g. ``"9-"``. + :rtype: ~azure.ai.contentunderstanding.models.ContentRange + :raises ValueError: If start_page is less than 1. + """ + if start_page < 1: + raise ValueError("Start page must be >= 1.") + return cls(f"{start_page}-") + + @classmethod + def _time_range_ms(cls, start_ms: int, end_ms: int) -> "ContentRange": + """Create a ContentRange for a time range in milliseconds (for audio/video). + + :param start_ms: The start time in milliseconds (inclusive). + :type start_ms: int + :param end_ms: The end time in milliseconds (inclusive). + :type end_ms: int + :return: A ContentRange representing the time range. + :rtype: ~azure.ai.contentunderstanding.models.ContentRange + :raises ValueError: If start_ms is negative or end_ms is less than start_ms. + """ + if start_ms < 0: + raise ValueError("Start time must be >= 0.") + if end_ms < start_ms: + raise ValueError("End time must be >= start time.") + return cls(f"{start_ms}-{end_ms}") + + @classmethod + def _time_range_from_ms(cls, start_ms: int) -> "ContentRange": + """Create a ContentRange from a starting time to the end in milliseconds. + + :param start_ms: The start time in milliseconds (inclusive). + :type start_ms: int + :return: A ContentRange representing the open-ended time range. + :rtype: ~azure.ai.contentunderstanding.models.ContentRange + :raises ValueError: If start_ms is negative. + """ + if start_ms < 0: + raise ValueError("Start time must be >= 0.") + return cls(f"{start_ms}-") + + @classmethod + def time_range(cls, start: timedelta, end: timedelta) -> "ContentRange": + """Create a ContentRange for a time range (for audio/video content). + + :param start: The start time (inclusive). + :type start: ~datetime.timedelta + :param end: The end time (inclusive). + :type end: ~datetime.timedelta + :return: A ContentRange representing the time range, e.g. ``"0-5000"``. + :rtype: ~azure.ai.contentunderstanding.models.ContentRange + :raises ValueError: If start is negative, or end is less than start. + """ + if start < timedelta(0): + raise ValueError("Start time must be non-negative.") + if end < start: + raise ValueError("End time must be >= start time.") + return cls._time_range_ms( + int(start.total_seconds() * 1000), int(end.total_seconds() * 1000) + ) + + @classmethod + def time_range_from(cls, start: timedelta) -> "ContentRange": + """Create a ContentRange from a starting time to the end (for audio/video content). + + :param start: The start time (inclusive). + :type start: ~datetime.timedelta + :return: A ContentRange representing the open-ended time range, e.g. ``"5000-"``. + :rtype: ~azure.ai.contentunderstanding.models.ContentRange + :raises ValueError: If start is negative. + """ + if start < timedelta(0): + raise ValueError("Start time must be non-negative.") + return cls._time_range_from_ms(int(start.total_seconds() * 1000)) + + @classmethod + def combine(cls, *ranges: "ContentRange") -> "ContentRange": + """Combine multiple ContentRange values into a single comma-separated range. + + :param ranges: The ranges to combine. + :type ranges: ~azure.ai.contentunderstanding.models.ContentRange + :return: A ContentRange representing the combined ranges, e.g. ``"1-3,5,9-"``. + :rtype: ~azure.ai.contentunderstanding.models.ContentRange + :raises ValueError: If no ranges are provided. + """ + if not ranges: + raise ValueError("At least one range must be provided.") + return cls(",".join(r._value for r in ranges)) + + def __str__(self) -> str: + return self._value + + def __repr__(self) -> str: + return f"ContentRange({self._value!r})" + + def __eq__(self, other: object) -> bool: + if isinstance(other, ContentRange): + return self._value == other._value + return NotImplemented + + def __ne__(self, other: object) -> bool: + if isinstance(other, ContentRange): + return self._value != other._value + return NotImplemented + + def __hash__(self) -> int: + return hash(self._value) diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_patch.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_patch.py index 9dee69387cba..594ec18145e4 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_patch.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/azure/ai/contentunderstanding/models/_patch.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeVar from azure.core import CaseInsensitiveEnumMeta from azure.core.polling import LROPoller, PollingMethod +from ._content_range import ContentRange from ._models import ( StringField, IntegerField, @@ -76,6 +77,7 @@ def value(self) -> Optional[Any]: ... PollingReturnType_co = TypeVar("PollingReturnType_co", covariant=True) __all__ = [ + "ContentRange", "RecordMergePatchUpdate", "AnalyzeLROPoller", "ProcessingLocation", diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_binary_async.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_binary_async.py index 6d5f8856a470..0f741b9a18af 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_binary_async.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_binary_async.py @@ -58,6 +58,7 @@ from azure.ai.contentunderstanding.aio import ContentUnderstandingClient from azure.ai.contentunderstanding.models import ( AnalysisResult, + ContentRange, DocumentContent, ) from azure.core.credentials import AzureKeyCredential @@ -89,6 +90,46 @@ async def main() -> None: result: AnalysisResult = await poller.result() # [END analyze_document_from_binary] + # [START analyze_binary_with_content_range] + # Analyze only pages 3 onward. + print("\nAnalyzing pages 3 onward with ContentRange...") + range_poller = await client.begin_analyze_binary( + analyzer_id="prebuilt-documentSearch", + binary_input=file_bytes, + content_range=ContentRange.pages_from(3), + ) + range_result: AnalysisResult = await range_poller.result() + + if isinstance(range_result.contents[0], DocumentContent): + range_doc = range_result.contents[0] + print( + f"ContentRange analysis returned pages" + f" {range_doc.start_page_number} - {range_doc.end_page_number}" + ) + # [END analyze_binary_with_content_range] + + # [START analyze_binary_with_combined_content_range] + # Analyze pages 1-3, page 5, and pages 9 onward. + print("\nAnalyzing combined pages (1-3, 5, 9-) with ContentRange...") + combine_range_poller = await client.begin_analyze_binary( + analyzer_id="prebuilt-documentSearch", + binary_input=file_bytes, + content_range=ContentRange.combine( + ContentRange.pages(1, 3), + ContentRange.page(5), + ContentRange.pages_from(9), + ), + ) + combine_range_result: AnalysisResult = await combine_range_poller.result() + + if isinstance(combine_range_result.contents[0], DocumentContent): + combine_doc = combine_range_result.contents[0] + print( + f"Combined ContentRange analysis returned pages" + f" {combine_doc.start_page_number} - {combine_doc.end_page_number}" + ) + # [END analyze_binary_with_combined_content_range] + # [START extract_markdown] print("\nMarkdown Content:") print("=" * 50) diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_url_async.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_url_async.py index ee2ce930f11a..7ead6dc4f56b 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_url_async.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/async_samples/sample_analyze_url_async.py @@ -42,6 +42,7 @@ import asyncio import os +from datetime import timedelta from typing import cast from dotenv import load_dotenv @@ -50,6 +51,7 @@ AnalysisInput, AnalysisResult, AudioVisualContent, + ContentRange, DocumentContent, AnalysisContent, ) @@ -72,7 +74,7 @@ async def main() -> None: print("DOCUMENT ANALYSIS FROM URL") print("=" * 60) # You can replace this URL with your own publicly accessible document URL. - document_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf" + document_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/mixed_financial_docs.pdf" print(f"Analyzing document from URL with prebuilt-documentSearch...") print(f" URL: {document_url}") @@ -104,6 +106,23 @@ async def main() -> None: print(f" Page {page.page_number}: {page.width} x {page.height} {unit}") # [END analyze_document_from_url] + # [START analyze_document_url_with_content_range] + # Restrict to specific pages with ContentRange + # Extract only page 1 of the document. + print("\nAnalyzing page 1 only with ContentRange...") + range_poller = await client.begin_analyze( + analyzer_id="prebuilt-documentSearch", + inputs=[AnalysisInput(url=document_url, content_range=str(ContentRange.page(1)))], + ) + range_result: AnalysisResult = await range_poller.result() + + range_doc_content = cast(DocumentContent, range_result.contents[0]) + print( + f"ContentRange analysis returned pages" + f" {range_doc_content.start_page_number} - {range_doc_content.end_page_number}" + ) + # [END analyze_document_url_with_content_range] + # [START analyze_video_from_url] print("\n" + "=" * 60) print("VIDEO ANALYSIS FROM URL") @@ -145,6 +164,103 @@ async def main() -> None: segment_index += 1 # [END analyze_video_from_url] + # [START analyze_video_url_with_content_range] + # Restrict to a time window with ContentRange + # Analyze only the first 5 seconds of the video. + print("\nAnalyzing first 5 seconds of video with ContentRange...") + video_range_poller = await client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=video_url, + content_range=str( + ContentRange.time_range(timedelta(0), timedelta(seconds=5)) + ), + ) + ], + ) + video_range_result = await video_range_poller.result() + + for range_media in video_range_result.contents: + range_video_content = cast(AudioVisualContent, range_media) + print( + f"ContentRange segment:" + f" {range_video_content.start_time_ms} ms - {range_video_content.end_time_ms} ms" + ) + # [END analyze_video_url_with_content_range] + + # [START analyze_video_url_with_additional_content_ranges] + # Additional ContentRange examples for video: + + # TimeRangeFrom — analyze from 10 seconds onward (wire format: "10000-") + print("\nAnalyzing video from 10 seconds onward with ContentRange...") + video_from_poller = await client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=video_url, + content_range=str( + ContentRange.time_range_from(timedelta(seconds=10)) + ), + ) + ], + ) + video_from_result = await video_from_poller.result() + for from_media in video_from_result.contents: + from_video = cast(AudioVisualContent, from_media) + print( + f"TimeRangeFrom(10s) segment:" + f" {from_video.start_time_ms} ms - {from_video.end_time_ms} ms" + ) + + # Sub-second precision — analyze from 1.2s to 3.651s (wire format: "1200-3651") + print("\nAnalyzing video with sub-second precision (1.2s to 3.651s)...") + video_subsec_poller = await client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=video_url, + content_range=str( + ContentRange.time_range( + timedelta(milliseconds=1200), timedelta(milliseconds=3651) + ) + ), + ) + ], + ) + video_subsec_result = await video_subsec_poller.result() + for subsec_media in video_subsec_result.contents: + subsec_video = cast(AudioVisualContent, subsec_media) + print( + f"TimeRange(1.2s, 3.651s) segment:" + f" {subsec_video.start_time_ms} ms - {subsec_video.end_time_ms} ms" + ) + + # Combine — multiple disjoint time ranges (wire format: "0-3000,30000-") + print("\nAnalyzing video with combined time ranges (0-3s and 30s onward)...") + video_combine_poller = await client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=video_url, + content_range=str( + ContentRange.combine( + ContentRange.time_range(timedelta(0), timedelta(seconds=3)), + ContentRange.time_range_from(timedelta(seconds=30)), + ) + ), + ) + ], + ) + video_combine_result = await video_combine_poller.result() + for combine_media in video_combine_result.contents: + combine_video = cast(AudioVisualContent, combine_media) + print( + f"Combine(0-3s, 30s-) segment:" + f" {combine_video.start_time_ms} ms - {combine_video.end_time_ms} ms" + ) + # [END analyze_video_url_with_additional_content_ranges] + # [START analyze_audio_from_url] print("\n" + "=" * 60) print("AUDIO ANALYSIS FROM URL") @@ -181,6 +297,80 @@ async def main() -> None: print(f" [{phrase.speaker}] {phrase.start_time_ms} ms: {phrase.text}") # [END analyze_audio_from_url] + # [START analyze_audio_url_with_content_range] + # Restrict to a time range with ContentRange + # Analyze audio from 5 seconds onward. + print("\nAnalyzing audio from 5 seconds onward with ContentRange...") + audio_range_poller = await client.begin_analyze( + analyzer_id="prebuilt-audioSearch", + inputs=[ + AnalysisInput( + url=audio_url, + content_range=str( + ContentRange.time_range_from(timedelta(seconds=5)) + ), + ) + ], + ) + audio_range_result = await audio_range_poller.result() + + range_audio_content = cast(AudioVisualContent, audio_range_result.contents[0]) + print(f"ContentRange audio analysis: {range_audio_content.start_time_ms} ms onward") + range_summary = ( + range_audio_content.fields.get("Summary") if range_audio_content.fields else None + ) + if range_summary and hasattr(range_summary, "value"): + print(f"Summary: {range_summary.value}") + # [END analyze_audio_url_with_content_range] + + # [START analyze_audio_url_with_additional_content_ranges] + # Additional ContentRange examples for audio: + + # TimeRange — analyze a specific time window from 2s to 8s (wire format: "2000-8000") + print("\nAnalyzing audio from 2s to 8s with ContentRange...") + audio_window_poller = await client.begin_analyze( + analyzer_id="prebuilt-audioSearch", + inputs=[ + AnalysisInput( + url=audio_url, + content_range=str( + ContentRange.time_range( + timedelta(seconds=2), timedelta(seconds=8) + ) + ), + ) + ], + ) + audio_window_result = await audio_window_poller.result() + audio_window_content = cast(AudioVisualContent, audio_window_result.contents[0]) + print( + f"TimeRange(2s, 8s):" + f" {audio_window_content.start_time_ms} ms - {audio_window_content.end_time_ms} ms" + ) + + # Sub-second precision — analyze from 1.2s to 3.651s (wire format: "1200-3651") + print("\nAnalyzing audio with sub-second precision (1.2s to 3.651s)...") + audio_subsec_poller = await client.begin_analyze( + analyzer_id="prebuilt-audioSearch", + inputs=[ + AnalysisInput( + url=audio_url, + content_range=str( + ContentRange.time_range( + timedelta(milliseconds=1200), timedelta(milliseconds=3651) + ) + ), + ) + ], + ) + audio_subsec_result = await audio_subsec_poller.result() + audio_subsec_content = cast(AudioVisualContent, audio_subsec_result.contents[0]) + print( + f"TimeRange(1.2s, 3.651s):" + f" {audio_subsec_content.start_time_ms} ms - {audio_subsec_content.end_time_ms} ms" + ) + # [END analyze_audio_url_with_additional_content_ranges] + # [START analyze_image_from_url] print("\n" + "=" * 60) print("IMAGE ANALYSIS FROM URL") diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_binary.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_binary.py index 27d73490a99f..254140c88bb1 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_binary.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_binary.py @@ -57,6 +57,7 @@ from azure.ai.contentunderstanding import ContentUnderstandingClient from azure.ai.contentunderstanding.models import ( AnalysisResult, + ContentRange, DocumentContent, ) from azure.core.credentials import AzureKeyCredential @@ -87,6 +88,40 @@ def main() -> None: result: AnalysisResult = poller.result() # [END analyze_document_from_binary] + # [START analyze_binary_with_content_range] + # Analyze only pages 3 onward. + print("\nAnalyzing pages 3 onward with ContentRange...") + range_poller = client.begin_analyze_binary( + analyzer_id="prebuilt-documentSearch", + binary_input=file_bytes, + content_range=ContentRange.pages_from(3), + ) + range_result: AnalysisResult = range_poller.result() + + if isinstance(range_result.contents[0], DocumentContent): + range_doc = range_result.contents[0] + print(f"ContentRange analysis returned pages {range_doc.start_page_number} - {range_doc.end_page_number}") + # [END analyze_binary_with_content_range] + + # [START analyze_binary_with_combined_content_range] + # Analyze pages 1-3, page 5, and pages 9 onward. + print("\nAnalyzing combined pages (1-3, 5, 9-) with ContentRange...") + combine_range_poller = client.begin_analyze_binary( + analyzer_id="prebuilt-documentSearch", + binary_input=file_bytes, + content_range=ContentRange.combine( + ContentRange.pages(1, 3), + ContentRange.page(5), + ContentRange.pages_from(9), + ), + ) + combine_range_result: AnalysisResult = combine_range_poller.result() + + if isinstance(combine_range_result.contents[0], DocumentContent): + combine_doc = combine_range_result.contents[0] + print(f"Combined ContentRange analysis returned pages {combine_doc.start_page_number} - {combine_doc.end_page_number}") + # [END analyze_binary_with_combined_content_range] + # [START extract_markdown] print("\nMarkdown Content:") print("=" * 50) diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_url.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_url.py index 114a4bcb231a..1eae4e5323e9 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_url.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/samples/sample_analyze_url.py @@ -41,6 +41,7 @@ """ import os +from datetime import timedelta from typing import cast from dotenv import load_dotenv @@ -49,6 +50,7 @@ AnalysisInput, AnalysisResult, AudioVisualContent, + ContentRange, DocumentContent, AnalysisContent, ) @@ -70,7 +72,7 @@ def main() -> None: print("DOCUMENT ANALYSIS FROM URL") print("=" * 60) # You can replace this URL with your own publicly accessible document URL. - document_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf" + document_url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/mixed_financial_docs.pdf" print(f"Analyzing document from URL with prebuilt-documentSearch...") print(f" URL: {document_url}") @@ -102,6 +104,23 @@ def main() -> None: print(f" Page {page.page_number}: {page.width} x {page.height} {unit}") # [END analyze_document_from_url] + # [START analyze_document_url_with_content_range] + # Restrict to specific pages with ContentRange + # Extract only page 1 of the document. + print("\nAnalyzing page 1 only with ContentRange...") + range_poller = client.begin_analyze( + analyzer_id="prebuilt-documentSearch", + inputs=[AnalysisInput(url=document_url, content_range=str(ContentRange.page(1)))], + ) + range_result: AnalysisResult = range_poller.result() + + range_doc_content = cast(DocumentContent, range_result.contents[0]) + print( + f"ContentRange analysis returned pages" + f" {range_doc_content.start_page_number} - {range_doc_content.end_page_number}" + ) + # [END analyze_document_url_with_content_range] + # [START analyze_video_from_url] print("\n" + "=" * 60) print("VIDEO ANALYSIS FROM URL") @@ -141,6 +160,103 @@ def main() -> None: segment_index += 1 # [END analyze_video_from_url] + # [START analyze_video_url_with_content_range] + # Restrict to a time window with ContentRange + # Analyze only the first 5 seconds of the video. + print("\nAnalyzing first 5 seconds of video with ContentRange...") + video_range_poller = client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=video_url, + content_range=str( + ContentRange.time_range(timedelta(0), timedelta(seconds=5)) + ), + ) + ], + ) + video_range_result = video_range_poller.result() + + for range_media in video_range_result.contents: + range_video_content = cast(AudioVisualContent, range_media) + print( + f"ContentRange segment:" + f" {range_video_content.start_time_ms} ms - {range_video_content.end_time_ms} ms" + ) + # [END analyze_video_url_with_content_range] + + # [START analyze_video_url_with_additional_content_ranges] + # Additional ContentRange examples for video: + + # TimeRangeFrom — analyze from 10 seconds onward (wire format: "10000-") + print("\nAnalyzing video from 10 seconds onward with ContentRange...") + video_from_poller = client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=video_url, + content_range=str( + ContentRange.time_range_from(timedelta(seconds=10)) + ), + ) + ], + ) + video_from_result = video_from_poller.result() + for from_media in video_from_result.contents: + from_video = cast(AudioVisualContent, from_media) + print( + f"TimeRangeFrom(10s) segment:" + f" {from_video.start_time_ms} ms - {from_video.end_time_ms} ms" + ) + + # Sub-second precision — analyze from 1.2s to 3.651s (wire format: "1200-3651") + print("\nAnalyzing video with sub-second precision (1.2s to 3.651s)...") + video_subsec_poller = client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=video_url, + content_range=str( + ContentRange.time_range( + timedelta(milliseconds=1200), timedelta(milliseconds=3651) + ) + ), + ) + ], + ) + video_subsec_result = video_subsec_poller.result() + for subsec_media in video_subsec_result.contents: + subsec_video = cast(AudioVisualContent, subsec_media) + print( + f"TimeRange(1.2s, 3.651s) segment:" + f" {subsec_video.start_time_ms} ms - {subsec_video.end_time_ms} ms" + ) + + # Combine — multiple disjoint time ranges (wire format: "0-3000,30000-") + print("\nAnalyzing video with combined time ranges (0-3s and 30s onward)...") + video_combine_poller = client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=video_url, + content_range=str( + ContentRange.combine( + ContentRange.time_range(timedelta(0), timedelta(seconds=3)), + ContentRange.time_range_from(timedelta(seconds=30)), + ) + ), + ) + ], + ) + video_combine_result = video_combine_poller.result() + for combine_media in video_combine_result.contents: + combine_video = cast(AudioVisualContent, combine_media) + print( + f"Combine(0-3s, 30s-) segment:" + f" {combine_video.start_time_ms} ms - {combine_video.end_time_ms} ms" + ) + # [END analyze_video_url_with_additional_content_ranges] + # [START analyze_audio_from_url] print("\n" + "=" * 60) print("AUDIO ANALYSIS FROM URL") @@ -174,6 +290,80 @@ def main() -> None: print(f" [{phrase.speaker}] {phrase.start_time_ms} ms: {phrase.text}") # [END analyze_audio_from_url] + # [START analyze_audio_url_with_content_range] + # Restrict to a time range with ContentRange + # Analyze audio from 5 seconds onward. + print("\nAnalyzing audio from 5 seconds onward with ContentRange...") + audio_range_poller = client.begin_analyze( + analyzer_id="prebuilt-audioSearch", + inputs=[ + AnalysisInput( + url=audio_url, + content_range=str( + ContentRange.time_range_from(timedelta(seconds=5)) + ), + ) + ], + ) + audio_range_result = audio_range_poller.result() + + range_audio_content = cast(AudioVisualContent, audio_range_result.contents[0]) + print(f"ContentRange audio analysis: {range_audio_content.start_time_ms} ms onward") + range_summary = ( + range_audio_content.fields.get("Summary") if range_audio_content.fields else None + ) + if range_summary and hasattr(range_summary, "value"): + print(f"Summary: {range_summary.value}") + # [END analyze_audio_url_with_content_range] + + # [START analyze_audio_url_with_additional_content_ranges] + # Additional ContentRange examples for audio: + + # TimeRange — analyze a specific time window from 2s to 8s (wire format: "2000-8000") + print("\nAnalyzing audio from 2s to 8s with ContentRange...") + audio_window_poller = client.begin_analyze( + analyzer_id="prebuilt-audioSearch", + inputs=[ + AnalysisInput( + url=audio_url, + content_range=str( + ContentRange.time_range( + timedelta(seconds=2), timedelta(seconds=8) + ) + ), + ) + ], + ) + audio_window_result = audio_window_poller.result() + audio_window_content = cast(AudioVisualContent, audio_window_result.contents[0]) + print( + f"TimeRange(2s, 8s):" + f" {audio_window_content.start_time_ms} ms - {audio_window_content.end_time_ms} ms" + ) + + # Sub-second precision — analyze from 1.2s to 3.651s (wire format: "1200-3651") + print("\nAnalyzing audio with sub-second precision (1.2s to 3.651s)...") + audio_subsec_poller = client.begin_analyze( + analyzer_id="prebuilt-audioSearch", + inputs=[ + AnalysisInput( + url=audio_url, + content_range=str( + ContentRange.time_range( + timedelta(milliseconds=1200), timedelta(milliseconds=3651) + ) + ), + ) + ], + ) + audio_subsec_result = audio_subsec_poller.result() + audio_subsec_content = cast(AudioVisualContent, audio_subsec_result.contents[0]) + print( + f"TimeRange(1.2s, 3.651s):" + f" {audio_subsec_content.start_time_ms} ms - {audio_subsec_content.end_time_ms} ms" + ) + # [END analyze_audio_url_with_additional_content_ranges] + # [START analyze_image_from_url] print("\n" + "=" * 60) print("IMAGE ANALYSIS FROM URL") diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_binary.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_binary.py index 18dd12bd0df3..2a0688429a17 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_binary.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_binary.py @@ -34,6 +34,7 @@ import pytest from devtools_testutils import recorded_by_proxy from testpreparer import ContentUnderstandingPreparer, ContentUnderstandingClientTestBase +from azure.ai.contentunderstanding.models import ContentRange, DocumentContent class TestSampleAnalyzeBinary(ContentUnderstandingClientTestBase): @@ -243,3 +244,136 @@ def _validate_tables(self, tables): ) else: print(f"[PASS] Table {i} validated: {table.row_count} rows x {table.column_count} columns") + + @ContentUnderstandingPreparer() + @recorded_by_proxy + def test_sample_analyze_binary_with_content_range(self, contentunderstanding_endpoint: str) -> None: + """Test analyzing a document from binary data with ContentRange. + + This test validates: + 1. ContentRange.pages_from(3) — analyze pages 3 onward + 2. ContentRange.combine() — analyze disjoint page ranges + 3. ContentRange.page(2) — single page + 4. ContentRange.pages(1, 3) — page range + 5. ContentRange.combine(page(1), pages(3, 4)) — combined page ranges + + 01_AnalyzeBinary.AnalyzeBinaryWithPageContentRangesAsync() + """ + client = self.create_client(endpoint=contentunderstanding_endpoint) + + # Read the sample file (use multi-page document for ContentRange testing) + tests_dir = os.path.dirname(os.path.dirname(__file__)) + file_path = os.path.join(tests_dir, "test_data", "mixed_financial_docs.pdf") + if not os.path.exists(file_path): + file_path = os.path.join(tests_dir, "test_data", "sample_invoice.pdf") + + with open(file_path, "rb") as f: + file_bytes = f.read() + + # Full analysis for comparison + full_poller = client.begin_analyze_binary( + analyzer_id="prebuilt-documentSearch", binary_input=file_bytes + ) + full_result = full_poller.result() + assert full_result.contents is not None + full_doc = full_result.contents[0] + assert isinstance(full_doc, DocumentContent) + full_page_count = len(full_doc.pages) if full_doc.pages else 0 + print(f"[PASS] Full document: {full_page_count} pages, {len(full_doc.markdown or '')} chars") + + # ContentRange.pages_from(3) — pages 3 onward (wire format: "3-") + print("\nAnalyzing pages 3 onward with ContentRange.pages_from(3)...") + range_poller = client.begin_analyze_binary( + analyzer_id="prebuilt-documentSearch", + binary_input=file_bytes, + content_range=ContentRange.pages_from(3), + ) + range_result = range_poller.result() + assert range_result.contents is not None + range_doc = range_result.contents[0] + assert isinstance(range_doc, DocumentContent) + range_page_count = len(range_doc.pages) if range_doc.pages else 0 + assert range_page_count > 0, "PagesFrom(3) should return at least one page" + assert full_page_count >= range_page_count, ( + f"Full document ({full_page_count} pages) should have >= pages than range-limited ({range_page_count})" + ) + print(f"[PASS] PagesFrom(3): {range_page_count} pages (pages {range_doc.start_page_number}-{range_doc.end_page_number})") + + # ContentRange.combine(pages(1, 3), page(5), pages_from(9)) — combined (wire format: "1-3,5,9-") + print("\nAnalyzing combined pages (1-3, 5, 9-) with ContentRange.combine()...") + combine_poller = client.begin_analyze_binary( + analyzer_id="prebuilt-documentSearch", + binary_input=file_bytes, + content_range=ContentRange.combine( + ContentRange.pages(1, 3), + ContentRange.page(5), + ContentRange.pages_from(9), + ), + ) + combine_result = combine_poller.result() + assert combine_result.contents is not None + combine_doc = combine_result.contents[0] + assert isinstance(combine_doc, DocumentContent) + combine_page_count = len(combine_doc.pages) if combine_doc.pages else 0 + assert combine_page_count > 0, "Combine should return at least one page" + assert len(full_doc.markdown or '') >= len(combine_doc.markdown or ''), ( + f"Full document ({len(full_doc.markdown or '')} chars) should be >= Combine ({len(combine_doc.markdown or '')} chars)" + ) + print(f"[PASS] Combine(Pages(1,3), Page(5), PagesFrom(9)): {combine_page_count} pages") + + # ContentRange.page(2) — single page (wire format: "2") + print("\nAnalyzing page 2 only with ContentRange.page(2)...") + page2_poller = client.begin_analyze_binary( + analyzer_id="prebuilt-documentSearch", + binary_input=file_bytes, + content_range=ContentRange.page(2), + ) + page2_result = page2_poller.result() + assert page2_result.contents is not None + page2_doc = page2_result.contents[0] + assert isinstance(page2_doc, DocumentContent) + page2_page_count = len(page2_doc.pages) if page2_doc.pages else 0 + assert page2_page_count == 1, f"Page(2) should return exactly 1 page, got {page2_page_count}" + assert page2_doc.start_page_number == 2, f"Page(2) should start at page 2, got {page2_doc.start_page_number}" + assert page2_doc.end_page_number == 2, f"Page(2) should end at page 2, got {page2_doc.end_page_number}" + print(f"[PASS] Page(2): {page2_page_count} page, {len(page2_doc.markdown or '')} chars") + + # ContentRange.pages(1, 3) — page range (wire format: "1-3") + print("\nAnalyzing pages 1-3 with ContentRange.pages(1, 3)...") + pages13_poller = client.begin_analyze_binary( + analyzer_id="prebuilt-documentSearch", + binary_input=file_bytes, + content_range=ContentRange.pages(1, 3), + ) + pages13_result = pages13_poller.result() + assert pages13_result.contents is not None + pages13_doc = pages13_result.contents[0] + assert isinstance(pages13_doc, DocumentContent) + pages13_page_count = len(pages13_doc.pages) if pages13_doc.pages else 0 + assert pages13_page_count == 3, f"Pages(1,3) should return exactly 3 pages, got {pages13_page_count}" + assert pages13_doc.start_page_number == 1, f"Pages(1,3) should start at page 1, got {pages13_doc.start_page_number}" + assert pages13_doc.end_page_number == 3, f"Pages(1,3) should end at page 3, got {pages13_doc.end_page_number}" + print(f"[PASS] Pages(1,3): {pages13_page_count} pages, {len(pages13_doc.markdown or '')} chars") + + # ContentRange.combine(page(1), pages(3, 4)) — combined (wire format: "1,3-4") + print("\nAnalyzing combined pages (1, 3-4) with ContentRange.combine()...") + combine2_poller = client.begin_analyze_binary( + analyzer_id="prebuilt-documentSearch", + binary_input=file_bytes, + content_range=ContentRange.combine( + ContentRange.page(1), + ContentRange.pages(3, 4), + ), + ) + combine2_result = combine2_poller.result() + assert combine2_result.contents is not None + combine2_doc = combine2_result.contents[0] + assert isinstance(combine2_doc, DocumentContent) + combine2_page_count = len(combine2_doc.pages) if combine2_doc.pages else 0 + assert combine2_page_count >= 2, ( + f"Combine(Page(1), Pages(3,4)) should return at least 2 pages, got {combine2_page_count}" + ) + assert combine2_doc.start_page_number == 1, f"Combine should start at page 1, got {combine2_doc.start_page_number}" + print(f"[PASS] Combine(Page(1), Pages(3,4)): {combine2_page_count} pages, {len(combine2_doc.markdown or '')} chars") + + print("\n[SUCCESS] All ContentRange binary test assertions passed") diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_url.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_url.py index 94a094e2f4fe..58f9de0f55d7 100644 --- a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_url.py +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/samples/test_sample_analyze_url.py @@ -24,7 +24,7 @@ import pytest from devtools_testutils import recorded_by_proxy from testpreparer import ContentUnderstandingPreparer, ContentUnderstandingClientTestBase -from azure.ai.contentunderstanding.models import AnalysisInput, AudioVisualContent, DocumentContent +from azure.ai.contentunderstanding.models import AnalysisInput, AudioVisualContent, ContentRange, DocumentContent class TestSampleAnalyzeUrl(ContentUnderstandingClientTestBase): @@ -441,3 +441,293 @@ def _validate_tables(self, tables): ) else: print(f"[PASS] Table {i} validated: {table.row_count} rows x {table.column_count} columns") + + @ContentUnderstandingPreparer() + @recorded_by_proxy + def test_sample_analyze_document_url_with_content_range(self, contentunderstanding_endpoint: str) -> None: + """Test analyzing a document URL with ContentRange. + + This test validates: + 1. ContentRange.page(1) — single page extraction + 2. Comparison between full document and range-limited result + + 02_AnalyzeUrl.AnalyzeUrlWithPageContentRangesAsync() + """ + from typing import cast + + client = self.create_client(endpoint=contentunderstanding_endpoint) + + url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/mixed_financial_docs.pdf" + + # Full analysis for comparison + full_poller = client.begin_analyze( + analyzer_id="prebuilt-documentSearch", inputs=[AnalysisInput(url=url)] + ) + full_result = full_poller.result() + full_doc = cast(DocumentContent, full_result.contents[0]) + full_page_count = len(full_doc.pages) if full_doc.pages else 0 + assert full_page_count == 4, f"Full document should return all 4 pages, got {full_page_count}" + print(f"[PASS] Full document: {full_page_count} pages, {len(full_doc.markdown or '')} chars") + + # ContentRange.page(1) — single page (wire format: "1") + print("\nAnalyzing page 1 only with ContentRange.page(1)...") + range_poller = client.begin_analyze( + analyzer_id="prebuilt-documentSearch", + inputs=[AnalysisInput(url=url, content_range=str(ContentRange.page(1)))], + ) + range_result = range_poller.result() + range_doc = cast(DocumentContent, range_result.contents[0]) + range_page_count = len(range_doc.pages) if range_doc.pages else 0 + assert range_page_count == 1, f"Page(1) should return only 1 page, got {range_page_count}" + assert range_doc.start_page_number == 1, f"Page(1) should start at page 1, got {range_doc.start_page_number}" + assert range_doc.end_page_number == 1, f"Page(1) should end at page 1, got {range_doc.end_page_number}" + + # Compare full vs range-limited + assert full_page_count > range_page_count, ( + f"Full document ({full_page_count} pages) should have more pages than range-limited ({range_page_count})" + ) + assert len(full_doc.markdown or '') > len(range_doc.markdown or ''), ( + f"Full document markdown ({len(full_doc.markdown or '')} chars) should exceed range-limited ({len(range_doc.markdown or '')} chars)" + ) + print(f"[PASS] Page(1): {range_page_count} page, {len(range_doc.markdown or '')} chars") + print("\n[SUCCESS] All document URL ContentRange assertions passed") + + @ContentUnderstandingPreparer() + @recorded_by_proxy + def test_sample_analyze_video_url_with_content_ranges(self, contentunderstanding_endpoint: str) -> None: + """Test analyzing a video URL with various ContentRange options. + + This test validates: + 1. ContentRange.time_range(0, 5s) — first 5 seconds + 2. ContentRange.time_range_from(10s) — from 10 seconds onward + 3. ContentRange.time_range(1200ms, 3651ms) — sub-second precision + 4. ContentRange.combine() — combined time ranges + + 02_AnalyzeUrl.AnalyzeVideoUrlWithTimeContentRangesAsync() + """ + from datetime import timedelta + from typing import cast + + client = self.create_client(endpoint=contentunderstanding_endpoint) + + url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/videos/sdk_samples/FlightSimulator.mp4" + + # Full analysis for comparison + full_poller = client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[AnalysisInput(url=url)], + polling_interval=10, + ) + full_result = full_poller.result() + assert full_result.contents is not None + assert len(full_result.contents) > 0 + full_segments = [cast(AudioVisualContent, c) for c in full_result.contents] + full_total_duration = sum( + (s.end_time_ms or 0) - (s.start_time_ms or 0) for s in full_segments + ) + print(f"[PASS] Full video: {len(full_segments)} segment(s), {full_total_duration} ms") + + # ContentRange.time_range(0, 5s) — first 5 seconds (wire format: "0-5000") + print("\nAnalyzing first 5 seconds with ContentRange.time_range(0, 5s)...") + range_poller = client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=url, + content_range=str(ContentRange.time_range(timedelta(0), timedelta(seconds=5))), + ) + ], + polling_interval=10, + ) + range_result = range_poller.result() + assert range_result.contents is not None + range_segments = [cast(AudioVisualContent, c) for c in range_result.contents] + assert len(range_segments) > 0, "TimeRange(0, 5s) should return segments" + for seg in range_segments: + assert (seg.end_time_ms or 0) > (seg.start_time_ms or 0), "Segment should have EndTime > StartTime" + print(f"[PASS] TimeRange(0, 5s): {len(range_segments)} segment(s)") + + # ContentRange.time_range_from(10s) — from 10 seconds onward (wire format: "10000-") + print("\nAnalyzing from 10 seconds onward with ContentRange.time_range_from(10s)...") + from_poller = client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=url, + content_range=str(ContentRange.time_range_from(timedelta(seconds=10))), + ) + ], + polling_interval=10, + ) + from_result = from_poller.result() + assert from_result.contents is not None + from_segments = [cast(AudioVisualContent, c) for c in from_result.contents] + assert len(from_segments) > 0, "TimeRangeFrom(10s) should return segments" + for seg in from_segments: + assert (seg.end_time_ms or 0) > (seg.start_time_ms or 0), "Segment should have EndTime > StartTime" + assert seg.markdown, "Segment should have markdown" + print(f"[PASS] TimeRangeFrom(10s): {len(from_segments)} segment(s)") + + # ContentRange.time_range(1200ms, 3651ms) — sub-second precision (wire format: "1200-3651") + print("\nAnalyzing with sub-second precision (1.2s to 3.651s)...") + subsec_poller = client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=url, + content_range=str( + ContentRange.time_range(timedelta(milliseconds=1200), timedelta(milliseconds=3651)) + ), + ) + ], + polling_interval=10, + ) + subsec_result = subsec_poller.result() + assert subsec_result.contents is not None + subsec_segments = [cast(AudioVisualContent, c) for c in subsec_result.contents] + assert len(subsec_segments) > 0, "Sub-second TimeRange should return segments" + for seg in subsec_segments: + assert (seg.end_time_ms or 0) > (seg.start_time_ms or 0), "Segment should have EndTime > StartTime" + print(f"[PASS] TimeRange(1.2s, 3.651s): {len(subsec_segments)} segment(s)") + + # ContentRange.combine() — combined time ranges (wire format: "0-3000,30000-") + print("\nAnalyzing with combined time ranges (0-3s and 30s onward)...") + combine_poller = client.begin_analyze( + analyzer_id="prebuilt-videoSearch", + inputs=[ + AnalysisInput( + url=url, + content_range=str( + ContentRange.combine( + ContentRange.time_range(timedelta(0), timedelta(seconds=3)), + ContentRange.time_range_from(timedelta(seconds=30)), + ) + ), + ) + ], + polling_interval=10, + ) + combine_result = combine_poller.result() + assert combine_result.contents is not None + combine_segments = [cast(AudioVisualContent, c) for c in combine_result.contents] + assert len(combine_segments) > 0, "Combine time range should return segments" + for seg in combine_segments: + assert (seg.end_time_ms or 0) > (seg.start_time_ms or 0), "Segment should have EndTime > StartTime" + assert seg.markdown, "Segment should have markdown" + print(f"[PASS] Combine(0-3s, 30s-): {len(combine_segments)} segment(s)") + + print("\n[SUCCESS] All video URL ContentRange assertions passed") + + @ContentUnderstandingPreparer() + @recorded_by_proxy + def test_sample_analyze_audio_url_with_content_ranges(self, contentunderstanding_endpoint: str) -> None: + """Test analyzing an audio URL with various ContentRange options. + + This test validates: + 1. ContentRange.time_range_from(5s) — from 5 seconds onward + 2. ContentRange.time_range(2s, 8s) — specific time window + 3. ContentRange.time_range(1200ms, 3651ms) — sub-second precision + + 02_AnalyzeUrl.AnalyzeAudioUrlWithTimeContentRangesAsync() + """ + from datetime import timedelta + from typing import cast + + client = self.create_client(endpoint=contentunderstanding_endpoint) + + url = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/audio/callCenterRecording.mp3" + + # Full analysis for comparison + full_poller = client.begin_analyze( + analyzer_id="prebuilt-audioSearch", + inputs=[AnalysisInput(url=url)], + polling_interval=10, + ) + full_result = full_poller.result() + assert full_result.contents is not None + full_audio = cast(AudioVisualContent, full_result.contents[0]) + full_duration = (full_audio.end_time_ms or 0) - (full_audio.start_time_ms or 0) + full_phrase_count = len(full_audio.transcript_phrases) if full_audio.transcript_phrases else 0 + print(f"[PASS] Full audio: {len(full_audio.markdown or '')} chars, {full_phrase_count} phrases, {full_duration} ms") + + # ContentRange.time_range_from(5s) — from 5 seconds onward (wire format: "5000-") + print("\nAnalyzing audio from 5 seconds onward with ContentRange.time_range_from(5s)...") + from_poller = client.begin_analyze( + analyzer_id="prebuilt-audioSearch", + inputs=[ + AnalysisInput( + url=url, + content_range=str(ContentRange.time_range_from(timedelta(seconds=5))), + ) + ], + polling_interval=10, + ) + from_result = from_poller.result() + assert from_result.contents is not None + from_audio = cast(AudioVisualContent, from_result.contents[0]) + assert len(full_audio.markdown or '') >= len(from_audio.markdown or ''), ( + f"Full audio markdown ({len(full_audio.markdown or '')} chars) should be >= range-limited ({len(from_audio.markdown or '')} chars)" + ) + from_phrase_count = len(from_audio.transcript_phrases) if from_audio.transcript_phrases else 0 + assert full_phrase_count >= from_phrase_count, ( + f"Full audio ({full_phrase_count} phrases) should have >= phrases than range-limited ({from_phrase_count})" + ) + print(f"[PASS] TimeRangeFrom(5s): {len(from_audio.markdown or '')} chars, {from_phrase_count} phrases") + + # ContentRange.time_range(2s, 8s) — specific time window (wire format: "2000-8000") + print("\nAnalyzing audio from 2s to 8s with ContentRange.time_range(2s, 8s)...") + window_poller = client.begin_analyze( + analyzer_id="prebuilt-audioSearch", + inputs=[ + AnalysisInput( + url=url, + content_range=str( + ContentRange.time_range(timedelta(seconds=2), timedelta(seconds=8)) + ), + ) + ], + polling_interval=10, + ) + window_result = window_poller.result() + assert window_result.contents is not None + window_audio = cast(AudioVisualContent, window_result.contents[0]) + assert (window_audio.end_time_ms or 0) > (window_audio.start_time_ms or 0), ( + "TimeRange(2s, 8s) should have EndTime > StartTime" + ) + assert window_audio.markdown, "TimeRange(2s, 8s) should have markdown" + assert len(window_audio.markdown) > 0, "TimeRange(2s, 8s) markdown should not be empty" + window_duration = (window_audio.end_time_ms or 0) - (window_audio.start_time_ms or 0) + assert full_duration >= window_duration, ( + f"Full audio duration ({full_duration} ms) should be >= time-windowed duration ({window_duration} ms)" + ) + print(f"[PASS] TimeRange(2s, 8s): {len(window_audio.markdown)} chars, {window_duration} ms") + + # ContentRange.time_range(1200ms, 3651ms) — sub-second precision (wire format: "1200-3651") + print("\nAnalyzing audio with sub-second precision (1.2s to 3.651s)...") + subsec_poller = client.begin_analyze( + analyzer_id="prebuilt-audioSearch", + inputs=[ + AnalysisInput( + url=url, + content_range=str( + ContentRange.time_range(timedelta(milliseconds=1200), timedelta(milliseconds=3651)) + ), + ) + ], + polling_interval=10, + ) + subsec_result = subsec_poller.result() + assert subsec_result.contents is not None + subsec_audio = cast(AudioVisualContent, subsec_result.contents[0]) + assert (subsec_audio.end_time_ms or 0) > (subsec_audio.start_time_ms or 0), ( + "TimeRange(1.2s, 3.651s) should have EndTime > StartTime" + ) + assert subsec_audio.markdown, "TimeRange(1.2s, 3.651s) should have markdown" + assert len(subsec_audio.markdown) > 0, "TimeRange(1.2s, 3.651s) markdown should not be empty" + subsec_duration = (subsec_audio.end_time_ms or 0) - (subsec_audio.start_time_ms or 0) + assert full_duration >= subsec_duration, ( + f"Full audio duration ({full_duration} ms) should be >= sub-second duration ({subsec_duration} ms)" + ) + print(f"[PASS] TimeRange(1.2s, 3.651s): {len(subsec_audio.markdown)} chars, {subsec_duration} ms") + + print("\n[SUCCESS] All audio URL ContentRange assertions passed") diff --git a/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_range.py b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_range.py new file mode 100644 index 000000000000..1768d6275a42 --- /dev/null +++ b/sdk/contentunderstanding/azure-ai-contentunderstanding/tests/test_content_range.py @@ -0,0 +1,183 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------- + +"""Unit tests for ContentRange.""" + +import pytest +from datetime import timedelta +from azure.ai.contentunderstanding.models import ContentRange, AnalysisInput + + +class TestContentRangeConstructor: + """Tests for ContentRange constructor.""" + + def test_constructor_with_value_stores_value(self): + cr = ContentRange("1-3") + assert str(cr) == "1-3" + + def test_constructor_none_value_raises(self): + with pytest.raises(ValueError): + ContentRange(None) # type: ignore + + +class TestContentRangePageMethods: + """Tests for page-related factory methods.""" + + def test_page_valid_page_number(self): + cr = ContentRange.page(5) + assert str(cr) == "5" + + def test_page_page_one(self): + cr = ContentRange.page(1) + assert str(cr) == "1" + + def test_page_zero_raises(self): + with pytest.raises(ValueError): + ContentRange.page(0) + + def test_page_negative_raises(self): + with pytest.raises(ValueError): + ContentRange.page(-1) + + def test_pages_valid_range(self): + cr = ContentRange.pages(1, 3) + assert str(cr) == "1-3" + + def test_pages_same_start_and_end(self): + cr = ContentRange.pages(5, 5) + assert str(cr) == "5-5" + + def test_pages_zero_start_raises(self): + with pytest.raises(ValueError): + ContentRange.pages(0, 3) + + def test_pages_end_before_start_raises(self): + with pytest.raises(ValueError): + ContentRange.pages(5, 3) + + def test_pages_from_valid(self): + cr = ContentRange.pages_from(9) + assert str(cr) == "9-" + + def test_pages_from_zero_raises(self): + with pytest.raises(ValueError): + ContentRange.pages_from(0) + + +class TestContentRangeTimeMethods: + """Tests for time-related factory methods.""" + + def test_time_range_valid(self): + cr = ContentRange.time_range(timedelta(0), timedelta(milliseconds=5000)) + assert str(cr) == "0-5000" + + def test_time_range_same_start_and_end(self): + cr = ContentRange.time_range( + timedelta(milliseconds=1000), timedelta(milliseconds=1000) + ) + assert str(cr) == "1000-1000" + + def test_time_range_negative_start_raises(self): + with pytest.raises(ValueError): + ContentRange.time_range( + timedelta(milliseconds=-1), timedelta(milliseconds=5000) + ) + + def test_time_range_end_before_start_raises(self): + with pytest.raises(ValueError): + ContentRange.time_range( + timedelta(milliseconds=5000), timedelta(milliseconds=1000) + ) + + def test_time_range_from_valid(self): + cr = ContentRange.time_range_from(timedelta(milliseconds=5000)) + assert str(cr) == "5000-" + + def test_time_range_from_zero(self): + cr = ContentRange.time_range_from(timedelta(0)) + assert str(cr) == "0-" + + def test_time_range_from_negative_raises(self): + with pytest.raises(ValueError): + ContentRange.time_range_from(timedelta(milliseconds=-1)) + + def test_time_range_seconds(self): + cr = ContentRange.time_range(timedelta(0), timedelta(seconds=5)) + assert str(cr) == "0-5000" + + def test_time_range_minutes(self): + cr = ContentRange.time_range(timedelta(0), timedelta(minutes=1)) + assert str(cr) == "0-60000" + + +class TestContentRangeCombine: + """Tests for combine factory method.""" + + def test_combine_multiple_ranges(self): + combined = ContentRange.combine( + ContentRange.pages(1, 3), ContentRange.page(5), ContentRange.pages_from(9) + ) + assert str(combined) == "1-3,5,9-" + + def test_combine_single_range(self): + combined = ContentRange.combine(ContentRange.page(1)) + assert str(combined) == "1" + + def test_combine_empty_raises(self): + with pytest.raises(ValueError): + ContentRange.combine() + + +class TestContentRangeEquality: + """Tests for equality operations.""" + + def test_equals_same_value(self): + r1 = ContentRange.pages(1, 3) + r2 = ContentRange("1-3") + assert r1 == r2 + + def test_equals_different_value(self): + r1 = ContentRange.pages(1, 3) + r2 = ContentRange.pages(1, 5) + assert r1 != r2 + + def test_hash_equal_for_same_values(self): + r1 = ContentRange.pages(1, 3) + r2 = ContentRange("1-3") + assert hash(r1) == hash(r2) + + def test_hash_different_for_different_values(self): + r1 = ContentRange.pages(1, 3) + r2 = ContentRange.pages(1, 5) + assert hash(r1) != hash(r2) + + def test_equals_non_content_range_returns_not_implemented(self): + r1 = ContentRange.pages(1, 3) + assert r1 != "1-3" # Different type + + +class TestContentRangeStr: + """Tests for string conversion.""" + + def test_str(self): + cr = ContentRange.pages(1, 3) + assert str(cr) == "1-3" + + def test_repr(self): + cr = ContentRange.pages(1, 3) + assert repr(cr) == "ContentRange('1-3')" + + +class TestAnalysisInputContentRange: + """Tests for ContentRange integration with AnalysisInput.""" + + def test_analysis_input_accepts_content_range_string(self): + ai = AnalysisInput(content_range="1-3") + assert ai.content_range == "1-3" + + def test_analysis_input_content_range_none_by_default(self): + ai = AnalysisInput() + assert ai.content_range is None