chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,3 @@
# Local-only files (not committed)
_local_only/
*_local_only*
@@ -0,0 +1,71 @@
# AGENTS.md — azure-contentunderstanding
## Package Overview
`agent-framework-azure-contentunderstanding` integrates Azure Content Understanding (CU)
into the Agent Framework as a context provider. It automatically analyzes file attachments
(documents, images, audio, video) and injects structured results into the LLM context.
## Public API
| Symbol | Type | Description |
|--------|------|-------------|
| `ContentUnderstandingContextProvider` | class | Main context provider — extends `ContextProvider` |
| `AnalysisSection` | enum | Output section selector (MARKDOWN, FIELDS, etc.) |
| `DocumentStatus` | enum | Document lifecycle state (ANALYZING, UPLOADING, READY, FAILED) |
| `FileSearchBackend` | ABC | Abstract vector store file operations interface |
| `FileSearchConfig` | dataclass | Configuration for CU + vector store RAG mode |
## Architecture
- **`_context_provider.py`** — Main provider implementation. Overrides `before_run()` to detect
file attachments, call the CU API, manage session state with multi-document tracking,
and auto-register retrieval tools for follow-up turns.
- **Analyzer auto-detection** — When `analyzer_id=None` (default), `_resolve_analyzer_id()`
selects the CU analyzer based on media type prefix: `audio/``prebuilt-audioSearch`,
`video/``prebuilt-videoSearch`, everything else → `prebuilt-documentSearch`.
- **Multi-segment output** — CU splits long video/audio into multiple scene segments
(each a separate `contents[]` entry with its own `startTimeMs`, `endTimeMs`, `markdown`,
and `fields`). `_extract_sections()` produces:
- `segments`: list of per-segment dicts, each with `markdown`, `fields`, `start_time_s`, `end_time_s`
- `markdown`: concatenated at top level with `---` separators (for file_search uploads)
- `duration_seconds`: computed from global `min(startTimeMs)``max(endTimeMs)`
- Metadata (`kind`, `resolution`): taken from the first segment
- **Speaker diarization (not identification)** — CU transcripts label speakers as
`<Speaker 1>`, `<Speaker 2>`, etc. CU does **not** identify speakers by name.
- **file_search RAG** — When `FileSearchConfig` is provided, CU-extracted markdown is
uploaded to an OpenAI vector store and a `file_search` tool is registered on the context
instead of injecting the full document content. This enables token-efficient retrieval
for large documents.
- **`_models.py`** — `AnalysisSection` enum, `DocumentStatus` enum, `DocumentEntry` TypedDict,
`FileSearchConfig` dataclass.
- **`_file_search.py`** — `FileSearchBackend` ABC, `OpenAIFileSearchBackend`,
`FoundryFileSearchBackend`.
## Key Patterns
- Follows the Azure AI Search context provider pattern (same lifecycle, config style).
- Uses provider-scoped `state` dict for multi-document tracking across turns.
- Auto-registers `list_documents()` tool via `context.extend_tools()`.
- Configurable timeout (`max_wait`) with `asyncio.create_task()` background fallback.
- Strips supported binary attachments from `input_messages` to prevent LLM API errors.
- Explicit `analyzer_id` always overrides auto-detection (user preference wins).
- Vector store resources are cleaned up in `close()` / `__aexit__`.
## Samples
| Sample | Description |
|--------|-------------|
| `01_document_qa.py` | Upload a PDF via URL, ask questions about it |
| `02_multi_turn_session.py` | AgentSession persistence across turns |
| `03_multimodal_chat.py` | PDF + audio + video parallel analysis |
| `04_invoice_processing.py` | Structured field extraction with `prebuilt-invoice` analyzer |
| `05_large_doc_file_search.py` | CU extraction + OpenAI vector store RAG |
| `02-devui/01-multimodal_agent/` | DevUI web UI for CU-powered chat |
| `02-devui/02-file_search_agent/` | DevUI web UI combining CU + file_search RAG |
## Running Tests
```bash
uv run poe test -P azure-contentunderstanding
```
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,127 @@
# Get Started with Azure Content Understanding in Microsoft Agent Framework
Please install this package via pip:
```bash
pip install agent-framework-azure-contentunderstanding --pre
```
## Azure Content Understanding Integration
### Prerequisites
Before using this package, you need an Azure Content Understanding resource:
1. An active **Azure subscription** ([create one for free](https://azure.microsoft.com/pricing/purchase-options/azure-account))
2. A **Microsoft Foundry resource** created in a [supported region](https://learn.microsoft.com/azure/ai-services/content-understanding/language-region-support)
3. **Default model deployments** configured for your resource (GPT-4.1, GPT-4.1-mini, text-embedding-3-large)
Follow the [prerequisites section](https://learn.microsoft.com/azure/ai-services/content-understanding/quickstart/use-rest-api?tabs=portal%2Cdocument&pivots=programming-language-rest#prerequisites) in the Azure Content Understanding quickstart for setup instructions.
### Introduction
The Azure Content Understanding integration provides a context provider that automatically analyzes file attachments (documents, images, audio, video) using [Azure Content Understanding](https://learn.microsoft.com/azure/ai-services/content-understanding/) and injects structured results into the LLM context.
- **Document & image analysis**: State-of-the-art OCR with markdown extraction, table preservation, and structured field extraction — handles scanned PDFs, handwritten content, and complex layouts
- **Audio & video analysis**: Transcription, speaker diarization, and per-segment summaries
- **Background processing**: Configurable timeout with async background fallback for large files
- **file_search integration**: Optional vector store upload for token-efficient RAG on large documents
> Learn more about Azure Content Understanding capabilities at [https://learn.microsoft.com/azure/ai-services/content-understanding/](https://learn.microsoft.com/azure/ai-services/content-understanding/)
### Basic Usage Example
See the [samples directory](samples/) which demonstrates:
- Single PDF upload and Q&A ([01_document_qa](samples/01-get-started/01_document_qa.py))
- Multi-turn sessions with cached results ([02_multi_turn_session](samples/01-get-started/02_multi_turn_session.py))
- PDF + audio + video parallel analysis ([03_multimodal_chat](samples/01-get-started/03_multimodal_chat.py))
- Structured field extraction with prebuilt-invoice ([04_invoice_processing](samples/01-get-started/04_invoice_processing.py))
- CU extraction + OpenAI vector store RAG ([05_large_doc_file_search](samples/01-get-started/05_large_doc_file_search.py))
- Interactive web UI with DevUI ([02-devui](samples/02-devui/))
```python
import asyncio
from agent_framework import Agent, AgentSession, Message, Content
from agent_framework.foundry import FoundryChatClient
from agent_framework.foundry import ContentUnderstandingContextProvider
from azure.identity import AzureCliCredential
credential = AzureCliCredential()
cu = ContentUnderstandingContextProvider(
endpoint="https://my-resource.cognitiveservices.azure.com/",
credential=credential,
max_wait=None, # block until CU extraction completes before sending to LLM
)
client = FoundryChatClient(
project_endpoint="https://your-project.services.ai.azure.com",
model="gpt-4.1",
credential=credential,
)
async def main():
async with cu:
agent = Agent(
client=client,
name="DocumentQA",
instructions="You are a helpful document analyst.",
context_providers=[cu],
)
session = AgentSession()
response = await agent.run(
Message(role="user", contents=[
Content.from_text("What's on this invoice?"),
Content.from_uri(
"https://raw.githubusercontent.com/Azure-Samples/"
"azure-ai-content-understanding-assets/main/document/invoice.pdf",
media_type="application/pdf",
additional_properties={"filename": "invoice.pdf"},
),
]),
session=session,
)
print(response.text)
asyncio.run(main())
```
### Supported File Types
| Category | Types |
|----------|-------|
| Documents | PDF, DOCX, XLSX, PPTX, HTML, TXT, Markdown |
| Images | JPEG, PNG, TIFF, BMP |
| Audio | WAV, MP3, M4A, FLAC, OGG |
| Video | MP4, MOV, AVI, WebM |
For the complete list of supported file types and size limits, see [Azure Content Understanding service limits](https://learn.microsoft.com/azure/ai-services/content-understanding/service-limits#input-file-limits).
### Environment Variables
The provider supports automatic endpoint resolution from environment variables.
When ``endpoint`` is not passed to the constructor, it is loaded from
``AZURE_CONTENTUNDERSTANDING_ENDPOINT``:
```python
# Endpoint auto-loaded from AZURE_CONTENTUNDERSTANDING_ENDPOINT env var
cu = ContentUnderstandingContextProvider(credential=credential)
```
Set these in your shell or in a `.env` file:
```bash
AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/
AZURE_AI_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com
AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4.1
```
You also need to be logged in with `az login` (for `AzureCliCredential`).
### Next steps
- Explore the [samples directory](samples/) for complete code examples
- Read the [Azure Content Understanding documentation](https://learn.microsoft.com/azure/ai-services/content-understanding/) for detailed service information
- Learn more about the [Microsoft Agent Framework](https://aka.ms/agent-framework)
@@ -0,0 +1,28 @@
# Copyright (c) Microsoft. All rights reserved.
"""Azure Content Understanding integration for Microsoft Agent Framework.
Provides a context provider that analyzes file attachments (documents, images,
audio, video) using Azure Content Understanding and injects structured results
into the LLM context.
"""
import importlib.metadata
from ._context_provider import ContentUnderstandingContextProvider
from ._file_search import FileSearchBackend
from ._models import AnalysisSection, DocumentStatus, FileSearchConfig
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"AnalysisSection",
"ContentUnderstandingContextProvider",
"DocumentStatus",
"FileSearchBackend",
"FileSearchConfig",
"__version__",
]
@@ -0,0 +1,905 @@
# Copyright (c) Microsoft. All rights reserved.
"""Azure Content Understanding context provider using ContextProvider.
This module provides ``ContentUnderstandingContextProvider``, built on the
:class:`ContextProvider` hooks pattern. It automatically detects file
attachments, analyzes them via the Azure Content Understanding API, and
injects structured results into the LLM context.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import sys
import time
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, ClassVar, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
Content,
ContextProvider,
FunctionTool,
Message,
SessionContext,
)
from agent_framework._sessions import AgentSession
from agent_framework._settings import load_settings
from azure.ai.contentunderstanding import to_llm_input
from azure.ai.contentunderstanding.aio import ContentUnderstandingClient
from azure.ai.contentunderstanding.models import AnalysisInput, AnalysisResult
from azure.core.credentials import AzureKeyCredential
from azure.core.credentials_async import AsyncTokenCredential
if TYPE_CHECKING:
from agent_framework._agents import SupportsAgentRun
from ._detection import (
detect_and_strip_files,
)
from ._models import AnalysisSection, DocumentEntry, DocumentStatus, FileSearchConfig
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
logger = logging.getLogger("agent_framework.azure_contentunderstanding")
AzureCredentialTypes = AzureKeyCredential | AsyncTokenCredential
# Mapping from media type prefix to the appropriate prebuilt CU analyzer.
# Used when analyzer_id is None (auto-detect mode).
MEDIA_TYPE_ANALYZER_MAP: dict[str, str] = {
"audio/": "prebuilt-audioSearch",
"video/": "prebuilt-videoSearch",
}
DEFAULT_ANALYZER: str = "prebuilt-documentSearch"
# Matches the leading YAML front-matter block emitted by ``to_llm_input``.
# A rendered text with no markdown body (e.g. when the CU result has empty
# ``markdown`` and no fields) is recognised by an empty tail after this match.
# Accept both LF and CRLF line endings so body detection works cross-platform.
_FRONT_MATTER_RE: re.Pattern[str] = re.compile(r"\A---\r?\n.*?\r?\n---(?:\r?\n|\Z)", flags=re.DOTALL)
def _has_renderable_body(text: str) -> bool:
"""Return True when ``text`` has any non-whitespace content beyond YAML front matter.
Used to skip ``file_search`` uploads when CU produced a result with no
markdown content — uploading a front-matter-only stub would pollute the
vector store without giving the LLM anything searchable.
"""
if not text:
return False
match = _FRONT_MATTER_RE.match(text)
if match is None:
return bool(text.strip())
return bool(text[match.end() :].strip())
class ContentUnderstandingSettings(TypedDict, total=False):
"""Settings for ContentUnderstandingContextProvider with auto-loading from environment.
Settings are resolved in this order: explicit keyword arguments, values from an
explicitly provided .env file, then environment variables with the prefix
``AZURE_CONTENTUNDERSTANDING_``.
Keys:
endpoint: Azure AI Foundry endpoint URL.
Can be set via environment variable ``AZURE_CONTENTUNDERSTANDING_ENDPOINT``.
"""
endpoint: str | None
class ContentUnderstandingContextProvider(ContextProvider):
"""Context provider that analyzes file attachments using Azure Content Understanding.
Automatically detects supported file attachments in the agent's input,
analyzes them via CU, and injects the structured results (markdown, fields)
into the LLM context. Supports multiple documents per session with background
processing for long-running analyses. Optionally integrates with a vector
store backend for ``file_search``-based RAG retrieval on LLM clients that
support it.
Args:
endpoint: Azure AI Foundry endpoint URL
(e.g., ``"https://<your-foundry-resource>.services.ai.azure.com/"``).
Can also be set via environment variable
``AZURE_CONTENTUNDERSTANDING_ENDPOINT``.
credential: An ``AzureKeyCredential`` for API key auth or an
``AsyncTokenCredential`` (e.g., ``DefaultAzureCredential``) for
Microsoft Entra ID auth.
analyzer_id: A prebuilt or custom CU analyzer ID. When ``None``
(default), a prebuilt analyzer is chosen automatically based on
the file's media type: ``prebuilt-documentSearch`` for documents
and images, ``prebuilt-audioSearch`` for audio, and
``prebuilt-videoSearch`` for video.
Analyzer reference: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/analyzer-reference
Prebuilt analyzers: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/prebuilt-analyzers
max_wait: Max seconds to wait for analysis before deferring to background.
``None`` waits until complete.
output_sections: Which CU output sections to pass to LLM.
Defaults to ``["markdown", "fields"]``.
file_search: Optional configuration for uploading CU-extracted markdown to
a vector store for token-efficient RAG retrieval. When provided, full
content injection is replaced by ``file_search`` tool registration.
The ``FileSearchConfig`` abstraction is backend-agnostic — use
``FileSearchConfig.from_openai()`` or ``FileSearchConfig.from_foundry()``
for supported providers, or supply a custom ``FileSearchBackend``
implementation for other vector store services.
source_id: Unique identifier for this provider instance, used for message
attribution and tool registration. Defaults to ``"azure_contentunderstanding"``.
env_file_path: Path to a ``.env`` file for loading settings.
env_file_encoding: Encoding of the ``.env`` file.
Per-file ``additional_properties`` on ``Content`` objects:
The provider reads the following keys from
``Content.additional_properties`` (passed via ``Content.from_data()``
or ``Content.from_uri()``):
``filename`` (str):
The document key used for tracking, status, and LLM references.
Without a filename, a UUID-based key is generated.
Must be unique within a session — uploading a file with a
duplicate filename will be rejected and the file will not be
analyzed.
``analyzer_id`` (str):
Per-file analyzer override. Takes priority over the provider-level
``analyzer_id``. Useful for mixing analyzers in the same turn
(e.g., ``prebuilt-invoice`` for invoices alongside
``prebuilt-documentSearch`` for general documents).
``content_range`` (str):
Subset of the input to analyze. For documents, use 1-based page
numbers (e.g., ``"1-3"`` for pages 1-3, ``"1,3,5-"`` for pages
1, 3, and 5 onward). For audio/video, use milliseconds
(e.g., ``"0-60000"`` for the first 60 seconds).
Example::
Content.from_data(
pdf_bytes,
"application/pdf",
additional_properties={
"filename": "invoice.pdf",
"analyzer_id": "prebuilt-invoice",
"content_range": "1-3",
},
)
"""
DEFAULT_SOURCE_ID: ClassVar[str] = "azure_contentunderstanding"
DEFAULT_MAX_WAIT_SECONDS: ClassVar[float] = 5.0
def __init__(
self,
*,
endpoint: str | None = None,
credential: AzureCredentialTypes | None = None,
client: ContentUnderstandingClient | None = None,
analyzer_id: str | None = None,
max_wait: float | None = DEFAULT_MAX_WAIT_SECONDS,
output_sections: list[AnalysisSection] | None = None,
file_search: FileSearchConfig | None = None,
source_id: str = DEFAULT_SOURCE_ID,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
super().__init__(source_id)
if client is not None:
# Use the pre-built client directly — endpoint/credential are ignored.
self._client = client
self._owns_client = False
self._endpoint = ""
self._credential = None
else:
# Build a new client from endpoint + credential.
settings = load_settings(
ContentUnderstandingSettings,
env_prefix="AZURE_CONTENTUNDERSTANDING_",
required_fields=["endpoint"],
endpoint=endpoint,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
resolved_endpoint: str = settings["endpoint"] # type: ignore[assignment] # validated by load_settings
if credential is None:
raise ValueError(
"Azure credential is required. Provide a 'credential' keyword argument "
"(e.g., AzureKeyCredential or AzureCliCredential), or pass a pre-built "
"'client' (ContentUnderstandingClient) instead."
)
self._endpoint = resolved_endpoint
self._credential = credential
self._client = ContentUnderstandingClient(
self._endpoint, self._credential, user_agent=AGENT_FRAMEWORK_USER_AGENT
)
self._owns_client = True
self.analyzer_id = analyzer_id
self.max_wait = max_wait
self.output_sections: list[AnalysisSection] = output_sections or ["markdown", "fields"]
self.file_search = file_search
# Global list of uploaded file IDs — used only by close() for
# best-effort cleanup. The authoritative per-session copy lives in
# state["_uploaded_file_ids"] (populated in before_run). This global
# list may contain entries from multiple sessions; that is intentional
# for cleanup.
self._all_uploaded_file_ids: list[str] = []
async def __aenter__(self) -> Self:
"""Async context manager entry."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Async context manager exit — cleanup clients."""
await self.close()
async def close(self) -> None:
"""Close the underlying CU client and clean up resources.
Uses global tracking lists for best-effort cleanup across all
sessions that used this provider instance.
"""
# Clean up uploaded files; the vector store itself is caller-managed.
if self.file_search and self._all_uploaded_file_ids:
await self._cleanup_uploaded_files()
# Only close the client if we created it internally.
# When a pre-built client was passed in, the caller owns its lifecycle.
if self._owns_client:
await self._client.close()
async def before_run(
self,
*,
agent: SupportsAgentRun,
session: AgentSession,
context: SessionContext,
state: dict[str, Any],
) -> None:
"""Analyze file attachments and inject results into the LLM context.
This method is called automatically by the framework before each LLM invocation.
"""
documents: dict[str, DocumentEntry] = state.setdefault("documents", {})
# Per-session mutable state — isolated per session to prevent cross-session leakage.
# _pending_tokens stores serializable continuation tokens (not asyncio.Task objects)
# so that state can be persisted to disk/storage by the framework.
# Structure: {doc_key: {"continuation_token": <opaque Azure SDK string>,
# "analyzer_id": <CU analyzer used for this file>}}
pending_tokens: dict[str, dict[str, str]] = state.setdefault("_pending_tokens", {})
pending_uploads: list[tuple[str, DocumentEntry]] = state.setdefault("_pending_uploads", [])
# Resolve pending Content Understanding analysis from its continuation tokens
await self._resolve_pending_analysis(pending_tokens, pending_uploads, documents, context)
# 1b. Upload any documents that completed in the background (file_search mode)
if pending_uploads:
# Use a bounded timeout so before_run() stays responsive and does not block
# indefinitely on slow vector store indexing.
upload_timeout = getattr(self, "max_wait", None)
remaining_uploads: list[tuple[str, DocumentEntry]] = []
for upload_key, upload_entry in pending_uploads:
try:
if upload_timeout is not None:
await asyncio.wait_for(
self._upload_to_vector_store(upload_key, upload_entry, state=state),
timeout=upload_timeout,
)
else:
await self._upload_to_vector_store(upload_key, upload_entry, state=state)
except asyncio.TimeoutError:
# Leave timed-out uploads pending so they can be retried on a later turn.
logger.warning(
"Timed out while uploading document '%s' to vector store; will retry later.",
upload_key,
)
remaining_uploads.append((upload_key, upload_entry))
except Exception:
# Log unexpected failures and drop the upload entry; this matches prior
# behavior where all pending uploads were cleared regardless of outcome.
logger.exception(
"Error while uploading document '%s' to vector store; dropping from pending list.",
upload_key,
)
context.extend_messages(
self.source_id,
[
Message(
role="user",
contents=[
(
f"Document '{upload_key}' was analyzed but failed to upload "
"to the vector store. The document content is not available for search."
)
],
)
],
)
state["_pending_uploads"] = remaining_uploads
pending_uploads = remaining_uploads
# 2. Detect CU-supported file attachments, strip them from input, and return for analysis
new_files = detect_and_strip_files(context)
# 3. Analyze new files using CU (track elapsed time for combined timeout)
file_start_times: dict[str, float] = {}
accepted_keys: set[str] = set() # doc_keys successfully accepted for analysis this turn
for doc_key, content_item, binary_data in new_files:
# Reject duplicate filenames — re-analyzing would orphan vector store entries
if doc_key in documents:
logger.warning("Duplicate document key '%s' — skipping (already exists in session).", doc_key)
context.extend_messages(
self.source_id,
[
Message(
role="user",
contents=[
(
f"The user tried to upload '{doc_key}', but a file with that name "
"was already uploaded earlier in this session. The new upload was rejected "
"and was not analyzed. Tell the user that a file with the same name "
"already exists and they need to rename the file before uploading again."
)
],
)
],
)
continue
file_start_times[doc_key] = time.monotonic()
doc_entry = await self._analyze_file(doc_key, content_item, binary_data, context, pending_tokens)
if doc_entry:
documents[doc_key] = doc_entry
accepted_keys.add(doc_key)
# 4. Inject content for ready documents and register tools
if documents:
self._register_tools(documents, context)
# 5. On upload turns, inject content for docs accepted this turn
for doc_key in accepted_keys:
entry = documents.get(doc_key)
if entry and entry["status"] == DocumentStatus.READY and entry["result"]:
# Upload to vector store if file_search is configured
if self.file_search:
# Combined timeout: subtract CU analysis time from max_wait
remaining: float | None = None
if self.max_wait is not None:
elapsed = time.monotonic() - file_start_times.get(doc_key, time.monotonic())
remaining = max(0.0, self.max_wait - elapsed)
uploaded = await self._upload_to_vector_store(doc_key, entry, timeout=remaining, state=state)
if uploaded:
context.extend_messages(
self.source_id,
[
Message(
role="user",
contents=[
(
f"The user just uploaded '{entry['filename']}'. It has been analyzed "
"using Azure Content Understanding and indexed in a vector store. "
f"When using file_search, include '{entry['filename']}' in your query "
"to retrieve content from this specific document."
)
],
)
],
)
elif entry.get("error"):
# Upload failed (not timeout — actual error)
context.extend_messages(
self.source_id,
[
Message(
role="user",
contents=[
(
f"Document '{entry['filename']}' was analyzed but failed to upload "
"to the vector store. The document content is not available for search."
)
],
)
],
)
else:
# Upload deferred to background (timeout)
context.extend_messages(
self.source_id,
[
Message(
role="user",
contents=[
(
f"Document '{entry['filename']}' has been analyzed and is being indexed. "
"Ask about it again in a moment."
)
],
)
],
)
else:
# Without file_search, inject full content into context
context.extend_messages(
self,
[
Message(role="user", contents=[entry["result"] or ""]),
],
)
context.extend_messages(
self.source_id,
[
Message(
role="user",
contents=[
(
f"The user just uploaded '{entry['filename']}'."
" It has been analyzed using Azure Content Understanding."
" The document content (markdown) and extracted fields"
" (YAML front matter) are provided above."
" If the user's question is ambiguous,"
" prioritize this most recently uploaded document."
" Use specific field values and cite page numbers"
" when answering."
)
],
)
],
)
# 6. Register file_search tool (for LLM clients that support it)
if self.file_search:
context.extend_tools(
self.source_id,
[self.file_search.file_search_tool],
)
context.extend_instructions(
self.source_id,
"Tool usage guidelines:\n"
"- Use file_search ONLY when answering questions about document content.\n"
"- Use list_documents() for status queries (e.g. 'list docs', 'what's uploaded?').\n"
"- Do NOT call file_search for status queries — it wastes tokens.",
)
# ------------------------------------------------------------------
# Analyzer Resolution
# ------------------------------------------------------------------
def _resolve_analyzer_id(self, media_type: str) -> str:
"""Return the analyzer ID to use for the given media type.
When ``self.analyzer_id`` is set, it is always returned (explicit
override). Otherwise the media type prefix is matched against the
known mapping, falling back to ``prebuilt-documentSearch``.
"""
if self.analyzer_id is not None:
return self.analyzer_id
for prefix, analyzer in MEDIA_TYPE_ANALYZER_MAP.items():
if media_type.startswith(prefix):
return analyzer
return DEFAULT_ANALYZER
# ------------------------------------------------------------------
# Analysis
# ------------------------------------------------------------------
async def _analyze_file(
self,
doc_key: str,
content: Content,
binary_data: bytes | None,
context: SessionContext,
pending_tokens: dict[str, dict[str, str]] | None = None,
) -> DocumentEntry | None:
"""Analyze a single file via CU with timeout handling.
The analyzer is resolved in priority order:
1. Per-file override via ``content.additional_properties["analyzer_id"]``
2. Provider-level default via ``self.analyzer_id``
3. Auto-detect by media type (document/audio/video)
Returns:
A ``DocumentEntry`` (ready, analyzing, or failed), or ``None`` if
file data could not be extracted.
"""
media_type = content.media_type or "application/octet-stream"
filename = doc_key
# Per-file analyzer override from additional_properties
props = content.additional_properties or {}
per_file_analyzer = props.get("analyzer_id")
content_range = props.get("content_range")
resolved_analyzer = per_file_analyzer or self._resolve_analyzer_id(media_type)
t0 = time.monotonic()
try:
# Start CU analysis
if content.type == "uri" and content.uri and not content.uri.startswith("data:"):
poller = await self._client.begin_analyze(
resolved_analyzer,
inputs=[AnalysisInput(url=content.uri, content_range=content_range)],
)
elif binary_data:
poller = await self._client.begin_analyze_binary(
resolved_analyzer,
binary_input=binary_data,
content_type=media_type,
)
else:
context.extend_messages(
self.source_id,
[Message(role="user", contents=[f"Could not extract file data from '{filename}'."])],
)
return None
# Wait with timeout; defer to background polling on timeout.
try:
result = await asyncio.wait_for(poller.result(), timeout=self.max_wait)
except asyncio.TimeoutError:
# Save continuation token for resuming on next before_run().
# Continuation tokens are serializable strings, so state can
# be persisted to disk/storage without issues.
token = poller.continuation_token()
logger.info("Analysis of '%s' timed out; deferring to background via continuation token.", filename)
if pending_tokens is not None:
pending_tokens[doc_key] = {
"continuation_token": token,
"analyzer_id": resolved_analyzer,
}
context.extend_messages(
self.source_id,
[
Message(
role="user",
contents=[f"Document '{filename}' is being analyzed. Ask about it again in a moment."],
)
],
)
return DocumentEntry(
status=DocumentStatus.ANALYZING,
filename=filename,
media_type=media_type,
analyzer_id=resolved_analyzer,
analyzed_at=None,
analysis_duration_s=None,
upload_duration_s=None,
result=None,
error=None,
)
# Analysis completed within timeout
analysis_duration = round(time.monotonic() - t0, 2)
rendered = self._render_for_llm(result, filename)
logger.info("Analyzed '%s' with analyzer '%s' in %.1fs.", filename, resolved_analyzer, analysis_duration)
return DocumentEntry(
status=DocumentStatus.READY,
filename=filename,
media_type=media_type,
analyzer_id=resolved_analyzer,
analyzed_at=datetime.now(tz=timezone.utc).isoformat(),
analysis_duration_s=analysis_duration,
upload_duration_s=None,
result=rendered,
error=None,
)
except asyncio.TimeoutError:
raise
except Exception as e:
logger.warning("CU analysis error for '%s': %s", filename, e)
context.extend_messages(
self.source_id,
[Message(role="user", contents=[f"Could not analyze '{filename}': {e}"])],
)
return DocumentEntry(
status=DocumentStatus.FAILED,
filename=filename,
media_type=media_type,
analyzer_id=resolved_analyzer,
analyzed_at=datetime.now(tz=timezone.utc).isoformat(),
analysis_duration_s=round(time.monotonic() - t0, 2),
upload_duration_s=None,
result=None,
error=str(e),
)
# ------------------------------------------------------------------
# Pending Analysis Resolution
# ------------------------------------------------------------------
async def _resolve_pending_analysis(
self,
pending_tokens: dict[str, dict[str, str]],
pending_uploads: list[tuple[str, DocumentEntry]],
documents: dict[str, DocumentEntry],
context: SessionContext,
) -> None:
"""Resume pending CU analyses using serializable continuation tokens.
When a file's CU analysis exceeds ``max_wait``, a continuation token
(an opaque string from the Azure SDK) is saved in ``state`` instead of
an ``asyncio.Task``. This keeps state fully serializable — it can be
persisted to disk/storage by the framework.
On the next ``before_run()`` call, this method resumes each pending
operation by passing the token back to ``begin_analyze()``. If the
server-side operation has completed, the result is available
immediately; otherwise the token is kept for the next turn.
"""
if not pending_tokens:
return
logger.info("Resolving %d pending analysis token(s).", len(pending_tokens))
completed_keys: list[str] = []
for doc_key, token_info in pending_tokens.items():
entry = documents.get(doc_key)
if not entry:
completed_keys.append(doc_key)
continue
try:
poller = await self._client.begin_analyze( # type: ignore[call-overload, reportUnknownVariableType]
token_info["analyzer_id"],
continuation_token=token_info["continuation_token"],
)
# Use wait_for to avoid blocking before_run indefinitely.
# poller.done() always returns False for resumed pollers (stale
# cached status), so we call poller.result() which polls the server.
#
# Timeout: at least 10s regardless of max_wait. The upload-turn
# max_wait can be very short (e.g. 5s) for responsiveness, but
# on resolution turns the resumed poller needs a network round-trip
# to fetch the result. If the analysis is still running after 10s,
# the token is kept and retried on the next turn.
MIN_RESOLUTION_TIMEOUT = 10.0
resolution_timeout = max(self.max_wait or MIN_RESOLUTION_TIMEOUT, MIN_RESOLUTION_TIMEOUT)
try:
result: AnalysisResult = await asyncio.wait_for(
poller.result(), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
timeout=resolution_timeout,
)
except asyncio.TimeoutError:
# Still running — update token and keep for next turn
new_token: str = poller.continuation_token() # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
token_info["continuation_token"] = new_token
logger.info("Analysis for '%s' still running; keeping token for next turn.", doc_key)
continue
completed_keys.append(doc_key)
rendered = self._render_for_llm(result, entry["filename"])
entry["status"] = DocumentStatus.READY
entry["analyzed_at"] = datetime.now(tz=timezone.utc).isoformat()
entry["result"] = rendered
entry["error"] = None
logger.info("Background analysis of '%s' completed.", entry["filename"])
# Inject newly ready content
if self.file_search:
pending_uploads.append((doc_key, entry))
else:
context.extend_messages(
self,
[
Message(role="user", contents=[rendered]),
],
)
context.extend_messages(
self.source_id,
[
Message(
role="user",
contents=[
f"Document '{entry['filename']}' analysis is now complete."
+ (
" The document is being indexed in the vector store and will become"
" searchable via file_search shortly."
if self.file_search
else " The content is provided above."
)
],
)
],
)
except Exception as e:
completed_keys.append(doc_key)
logger.warning("Background analysis of '%s' failed: %s", entry.get("filename", doc_key), e)
entry["status"] = DocumentStatus.FAILED
entry["analyzed_at"] = datetime.now(tz=timezone.utc).isoformat()
entry["error"] = str(e)
context.extend_messages(
self.source_id,
[Message(role="user", contents=[f"Document '{entry['filename']}' analysis failed: {e}"])],
)
for key in completed_keys:
del pending_tokens[key]
# ------------------------------------------------------------------
# LLM Input Rendering (delegates to azure.ai.contentunderstanding.to_llm_input)
# ------------------------------------------------------------------
def _render_for_llm(
self,
result: AnalysisResult,
filename: str,
) -> str:
"""Render a CU ``AnalysisResult`` into LLM-friendly text.
Maps the MAF ``output_sections`` list to ``to_llm_input`` kwargs:
- ``"markdown" in output_sections`` -> ``include_markdown=True``
- ``"fields" in output_sections`` -> ``include_fields=True``
Args:
result: The CU analysis result.
filename: Document filename, surfaced to the LLM via the
``source`` front matter key.
Returns:
A YAML-front-matter-prefixed text block ready for direct LLM
consumption or vector store upload.
"""
return to_llm_input(
result,
include_markdown="markdown" in self.output_sections,
include_fields="fields" in self.output_sections,
metadata={"source": filename},
)
# ------------------------------------------------------------------
# Tool Registration
# ------------------------------------------------------------------
def _register_tools(
self,
documents: dict[str, DocumentEntry],
context: SessionContext,
) -> None:
"""Register document tools on the context.
Only ``list_documents`` is registered — the full document content is
already injected into conversation history on the upload turn, so a
separate retrieval tool is not needed.
"""
context.extend_tools(
self.source_id,
[self._make_list_documents_tool(documents)],
)
@staticmethod
def _make_list_documents_tool(documents: dict[str, DocumentEntry]) -> FunctionTool:
"""Create a tool that lists all tracked documents with their status."""
docs_ref = documents
def list_documents() -> str:
"""List all documents that have been uploaded and their analysis status."""
entries: list[dict[str, object]] = []
for name, entry in docs_ref.items():
entries.append({
"name": name,
"status": entry["status"],
"media_type": entry["media_type"],
"analyzed_at": entry["analyzed_at"],
"analysis_duration_s": entry["analysis_duration_s"],
"upload_duration_s": entry["upload_duration_s"],
})
return json.dumps(entries, indent=2, default=str)
return FunctionTool(
name="list_documents",
description=(
"List all documents that have been uploaded in this session "
"with their analysis status (analyzing, uploading, ready, or failed)."
),
func=list_documents,
)
# ------------------------------------------------------------------
# file_search Vector Store Integration
# ------------------------------------------------------------------
async def _upload_to_vector_store(
self,
doc_key: str,
entry: DocumentEntry,
*,
timeout: float | None = None,
state: dict[str, Any] | None = None,
) -> bool:
"""Upload CU-extracted markdown to the caller's vector store.
Delegates to the configured ``FileSearchBackend`` (OpenAI, Foundry,
or a custom implementation). The upload includes file upload **and**
vector store indexing (embedding + ingestion) — ``create_and_poll``
waits for the index to be fully ready before returning.
Args:
doc_key: Document identifier.
entry: The document entry with extracted results.
timeout: Max seconds to wait for upload + indexing. ``None`` waits
indefinitely. On timeout the upload is deferred to the
per-session ``_pending_uploads`` queue for the next
``before_run()`` call.
state: Per-session state dict for tracking uploaded file IDs and
pending uploads.
Returns:
True if the upload succeeded, False otherwise.
"""
if not self.file_search:
return False
result = entry.get("result")
if not result:
return False
if not _has_renderable_body(result):
# Empty CU result (e.g. blank markdown, no fields) — skip the
# upload so the vector store stays clean. The DocumentEntry still
# records the front-matter-only ``result`` so callers can introspect.
return False
entry["status"] = DocumentStatus.UPLOADING
t0 = time.monotonic()
try:
upload_coro = self.file_search.backend.upload_file(
self.file_search.vector_store_id, f"{doc_key}.md", result.encode("utf-8")
)
file_id = await asyncio.wait_for(upload_coro, timeout=timeout)
upload_duration = round(time.monotonic() - t0, 2)
# Track in per-session state and global list (for close() cleanup)
if state is not None:
state.setdefault("_uploaded_file_ids", []).append(file_id)
self._all_uploaded_file_ids.append(file_id)
entry["status"] = DocumentStatus.READY
entry["upload_duration_s"] = upload_duration
logger.info("Uploaded '%s' to vector store in %.1fs (%s bytes).", doc_key, upload_duration, len(result))
return True
except asyncio.TimeoutError:
logger.info("Vector store upload for '%s' timed out; deferring to background.", doc_key)
entry["status"] = DocumentStatus.UPLOADING
if state is not None:
state.setdefault("_pending_uploads", []).append((doc_key, entry))
return False
except Exception as e:
logger.warning("Failed to upload '%s' to vector store: %s", doc_key, e)
entry["status"] = DocumentStatus.FAILED
entry["upload_duration_s"] = round(time.monotonic() - t0, 2)
entry["error"] = f"Vector store upload failed: {e}"
return False
async def _cleanup_uploaded_files(self) -> None:
"""Delete files uploaded by this provider via the configured backend.
The vector store itself is caller-managed and is not deleted here.
"""
if not self.file_search:
return
backend = self.file_search.backend
try:
for file_id in self._all_uploaded_file_ids:
await backend.delete_file(file_id)
self._all_uploaded_file_ids.clear()
except Exception as e:
logger.warning("Failed to clean up uploaded files: %s", e)
@@ -0,0 +1,234 @@
# Copyright (c) Microsoft. All rights reserved.
"""File detection utilities for Azure Content Understanding context provider.
Functions for scanning input messages, sniffing MIME types, deriving
document keys, and extracting binary data from content items.
"""
from __future__ import annotations
import base64
import logging
import mimetypes
import re
import uuid
import filetype
from agent_framework import Content, SessionContext
logger = logging.getLogger("agent_framework.azure_contentunderstanding")
# MIME types used to match against the resolved media type for routing files to CU analysis.
# The media type may be provided via Content.media_type or inferred (e.g., via sniffing or filename)
# when missing or generic (such as application/octet-stream). Only files whose resolved media type is
# in this set will be processed; others are skipped.
#
# Supported input file types:
# https://learn.microsoft.com/azure/ai-services/content-understanding/service-limits#input-file-limits
SUPPORTED_MEDIA_TYPES: frozenset[str] = frozenset({
# Documents and images
"application/pdf",
"image/jpeg",
"image/png",
"image/tiff",
"image/bmp",
"image/heif",
"image/heic",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
# Text
"text/plain",
"text/html",
"text/markdown",
"text/rtf",
"text/xml",
"application/xml",
"message/rfc822",
"application/vnd.ms-outlook",
# Audio
"audio/wav",
"audio/mpeg",
"audio/mp3",
"audio/mp4",
"audio/m4a",
"audio/flac",
"audio/ogg",
"audio/opus",
"audio/webm",
"audio/x-ms-wma",
"audio/aac",
"audio/amr",
"audio/3gpp",
# Video
"video/mp4",
"video/quicktime",
"video/x-msvideo",
"video/webm",
"video/x-flv",
"video/x-ms-wmv",
"video/x-ms-asf",
"video/x-matroska",
})
# Mapping from filetype's MIME output to our canonical SUPPORTED_MEDIA_TYPES values.
# filetype uses some x-prefixed variants that differ from our set.
MIME_ALIASES: dict[str, str] = {
"audio/x-wav": "audio/wav",
"audio/x-flac": "audio/flac",
"video/x-m4v": "video/mp4",
}
def detect_and_strip_files(
context: SessionContext,
) -> list[tuple[str, Content, bytes | None]]:
"""Scan input messages for supported file content and prepare for CU analysis.
Scans for type ``data`` or ``uri`` content supported by Azure Content
Understanding, strips them from messages to prevent raw binary being sent
to the LLM, and returns metadata for CU analysis.
Detected files are tracked via ``doc_key`` (derived from filename, URL,
or UUID) and their analysis status is managed in session state.
When the upstream MIME type is unreliable (``application/octet-stream``
or missing), binary content sniffing via ``filetype`` is used to
determine the real media type, with ``mimetypes.guess_type`` as a
filename-based fallback.
Returns:
List of (doc_key, content_item, binary_data) tuples for files to analyze.
"""
results: list[tuple[str, Content, bytes | None]] = []
strip_ids: set[int] = set()
for msg in context.input_messages:
for c in msg.contents:
if c.type not in ("data", "uri"):
continue
media_type = c.media_type
# Fast path: already a known supported type
if media_type and media_type in SUPPORTED_MEDIA_TYPES:
binary_data = extract_binary(c)
results.append((derive_doc_key(c), c, binary_data))
strip_ids.add(id(c))
continue
# Slow path: unreliable MIME — sniff binary content
if (not media_type) or (media_type == "application/octet-stream"):
binary_data = extract_binary(c)
resolved = sniff_media_type(binary_data, c)
if resolved and (resolved in SUPPORTED_MEDIA_TYPES):
c.media_type = resolved
results.append((derive_doc_key(c), c, binary_data))
strip_ids.add(id(c))
# Strip detected files from input so raw binary isn't sent to LLM
msg.contents = [c for c in msg.contents if id(c) not in strip_ids]
return results
def sniff_media_type(binary_data: bytes | None, content: Content) -> str | None:
"""Sniff the actual MIME type from binary data, with filename fallback.
Uses ``filetype`` (magic-bytes) first, then ``mimetypes.guess_type``
on the filename. Normalizes filetype's variant MIME values (e.g.
``audio/x-wav`` -> ``audio/wav``) via ``MIME_ALIASES``.
"""
# 1. Binary sniffing via filetype (needs only first 261 bytes)
if binary_data:
kind = filetype.guess(binary_data[:262]) # type: ignore[reportUnknownMemberType]
if kind:
mime: str = kind.mime
return MIME_ALIASES.get(mime, mime)
# 2. Filename extension fallback — try additional_properties first,
# then extract basename from external URL path
filename: str | None = None
if content.additional_properties:
filename = content.additional_properties.get("filename")
if not filename and content.uri and not content.uri.startswith("data:"):
# Extract basename from URL path (e.g. "https://example.com/report.pdf?v=1" -> "report.pdf")
filename = content.uri.split("?")[0].split("#")[0].rsplit("/", 1)[-1]
if filename:
guessed, _ = mimetypes.guess_type(filename) # uses file extension to guess MIME type
if guessed:
return MIME_ALIASES.get(guessed, guessed)
return None
def is_supported_content(content: Content) -> bool:
"""Check if a content item is a supported file type for CU analysis."""
if content.type not in ("data", "uri"):
return False
media_type = content.media_type
if not media_type:
return False
return media_type in SUPPORTED_MEDIA_TYPES
def sanitize_doc_key(raw: str) -> str:
"""Sanitize a document key to prevent prompt injection.
Removes control characters (newlines, tabs, etc.), collapses
whitespace, strips surrounding whitespace, and caps length at
255 characters.
"""
# Remove control characters (C0/C1 controls, including \n, \r, \t)
cleaned = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", raw)
# Collapse whitespace
cleaned = " ".join(cleaned.split())
# Cap length
return cleaned[:255] if cleaned else f"doc_{uuid.uuid4().hex[:8]}"
def derive_doc_key(content: Content) -> str:
"""Derive a unique document key from content metadata.
The key is used to track documents in session state. Duplicate keys
within a session are rejected (not re-analyzed) to prevent orphaned
vector store entries.
The returned key is sanitized to prevent prompt injection via
crafted filenames (control characters removed, length capped).
Priority: filename > URL basename > generated UUID.
"""
# 1. Filename from additional_properties
if content.additional_properties:
filename = content.additional_properties.get("filename")
if filename and isinstance(filename, str):
return sanitize_doc_key(filename)
# 2. URL path basename for external URIs (e.g. "https://example.com/report.pdf" -> "report.pdf")
if content.type == "uri" and content.uri and not content.uri.startswith("data:"):
path = content.uri.split("?")[0].split("#")[0] # strip query params and fragments
# rstrip("/") handles trailing slashes (e.g. ".../files/" -> ".../files")
# rsplit("/", 1)[-1] splits from the right once to get the last path segment
basename = path.rstrip("/").rsplit("/", 1)[-1]
if basename:
return sanitize_doc_key(basename)
# 3. Fallback: generate a unique ID for anonymous uploads (no filename, no URL)
return f"doc_{uuid.uuid4().hex[:8]}"
def extract_binary(content: Content) -> bytes | None:
"""Extract binary data from a data URI content item.
Only handles ``data:`` URIs (base64-encoded). Returns ``None`` for
external URLs -- those are passed directly to CU via ``begin_analyze``.
"""
if content.uri and content.uri.startswith("data:"):
try:
_, data_part = content.uri.split(",", 1)
return base64.b64decode(data_part)
except Exception:
logger.warning("Failed to decode base64 data URI")
return None
return None
@@ -0,0 +1,101 @@
# Copyright (c) Microsoft. All rights reserved.
"""File search backend abstraction for vector store file operations.
Provides a unified interface for uploading CU-extracted content to
vector stores across different LLM clients. Two implementations:
- ``OpenAIFileSearchBackend`` — for ``OpenAIChatClient`` (Responses API)
- ``FoundryFileSearchBackend`` — for ``FoundryChatClient`` (Responses API via Azure)
Both share the same OpenAI-compatible vector store file API but differ
in the file upload ``purpose`` value.
Vector store creation, tool construction, and lifecycle management are
the caller's responsibility — the backend only handles file upload/delete.
"""
from __future__ import annotations
import io
from abc import ABC, abstractmethod
from typing import Any
class FileSearchBackend(ABC):
"""Abstract interface for vector store file operations.
Implementations handle the differences between OpenAI and Foundry
file upload APIs (e.g., different ``purpose`` values).
Vector store creation, deletion, and ``file_search`` tool construction
are **not** part of this interface — those are managed by the caller.
"""
@abstractmethod
async def upload_file(self, vector_store_id: str, filename: str, content: bytes) -> str:
"""Upload a file to a vector store and return the file ID."""
@abstractmethod
async def delete_file(self, file_id: str) -> None:
"""Delete a previously uploaded file by ID."""
class _OpenAICompatBackend(FileSearchBackend):
"""Shared base for OpenAI-compatible file upload backends.
Both OpenAI and Foundry use the same ``client.files.*`` and
``client.vector_stores.files.*`` API surface. Subclasses only
override the file upload ``purpose``.
"""
_FILE_PURPOSE: str # Subclasses must set this
def __init__(self, client: Any) -> None:
self._client = client
async def upload_file(self, vector_store_id: str, filename: str, content: bytes) -> str:
uploaded = await self._client.files.create(
file=(filename, io.BytesIO(content)),
purpose=self._FILE_PURPOSE,
)
# Use create_and_poll to wait for indexing to complete before returning.
# Without this, file_search queries may return no results immediately
# after upload because the vector store index isn't ready yet.
await self._client.vector_stores.files.create_and_poll(
vector_store_id=vector_store_id,
file_id=uploaded.id,
)
return uploaded.id
async def delete_file(self, file_id: str) -> None:
await self._client.files.delete(file_id)
class OpenAIFileSearchBackend(_OpenAICompatBackend):
"""File search backend for OpenAI Responses API.
Use with ``OpenAIChatClient`` or ``AzureOpenAIResponsesClient``.
Requires an ``AsyncOpenAI`` or ``AsyncAzureOpenAI`` client.
Args:
client: An async OpenAI client (``AsyncOpenAI`` or ``AsyncAzureOpenAI``)
that supports ``client.files.*`` and ``client.vector_stores.*`` APIs.
"""
_FILE_PURPOSE = "user_data"
class FoundryFileSearchBackend(_OpenAICompatBackend):
"""File search backend for Azure AI Foundry.
Use with ``FoundryChatClient``. Requires the OpenAI-compatible client
obtained from ``FoundryChatClient.client`` (i.e.,
``project_client.get_openai_client()``).
Args:
client: The OpenAI-compatible async client from a ``FoundryChatClient``
(access via ``foundry_client.client``).
"""
_FILE_PURPOSE = "assistants"
@@ -0,0 +1,121 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any, Literal, TypedDict
from ._file_search import FileSearchBackend, FoundryFileSearchBackend, OpenAIFileSearchBackend
class DocumentStatus(str, Enum):
"""Analysis lifecycle state of a tracked document."""
ANALYZING = "analyzing"
"""CU analysis is in progress (deferred to background)."""
UPLOADING = "uploading"
"""Analysis complete; vector store upload + indexing is in progress."""
READY = "ready"
"""Analysis (and upload, if applicable) completed successfully."""
FAILED = "failed"
"""Analysis or upload failed."""
AnalysisSection = Literal["markdown", "fields"]
"""Which sections of the CU output to pass to the LLM.
- ``"markdown"``: Full document text with tables as HTML, reading order preserved.
- ``"fields"``: Extracted typed fields with confidence scores (when available).
"""
class DocumentEntry(TypedDict):
"""Tracks the analysis state of a single document in session state."""
status: DocumentStatus
filename: str
media_type: str
analyzer_id: str
analyzed_at: str | None
analysis_duration_s: float | None
upload_duration_s: float | None
result: str | None
"""LLM-ready text rendered by ``azure.ai.contentunderstanding.to_llm_input``.
Stored as a string (YAML front matter + markdown body) so every consumer
(LLM context injection, vector store upload) can use it without re-rendering.
``None`` until analysis completes successfully.
"""
error: str | None
@dataclass
class FileSearchConfig:
"""Configuration for uploading CU-extracted content to an existing vector store.
When provided to ``ContentUnderstandingContextProvider``, analyzed document
markdown is automatically uploaded to the specified vector store and the
given ``file_search`` tool is registered on the context. This enables
token-efficient RAG retrieval on follow-up turns for large documents.
The caller is responsible for creating and managing the vector store and
the ``file_search`` tool. Use :meth:`from_openai` or :meth:`from_foundry`
factory methods for convenience.
Args:
backend: A ``FileSearchBackend`` that handles file upload/delete
operations for the target vector store.
vector_store_id: The ID of a pre-existing vector store to upload to.
file_search_tool: A ``file_search`` tool object created via the LLM
client's ``get_file_search_tool()`` factory method. This is
registered on the context via ``extend_tools`` so the LLM can
retrieve uploaded content.
"""
backend: FileSearchBackend
vector_store_id: str
file_search_tool: Any
@staticmethod
def from_openai(
client: Any,
*,
vector_store_id: str,
file_search_tool: Any,
) -> FileSearchConfig:
"""Create a config for OpenAI Responses API (``OpenAIChatClient``).
Args:
client: An ``AsyncOpenAI`` or ``AsyncAzureOpenAI`` client.
vector_store_id: The ID of the vector store to upload to.
file_search_tool: Tool from ``OpenAIChatClient.get_file_search_tool()``.
"""
return FileSearchConfig(
backend=OpenAIFileSearchBackend(client),
vector_store_id=vector_store_id,
file_search_tool=file_search_tool,
)
@staticmethod
def from_foundry(
client: Any,
*,
vector_store_id: str,
file_search_tool: Any,
) -> FileSearchConfig:
"""Create a config for Azure AI Foundry (``FoundryChatClient``).
Args:
client: The OpenAI-compatible client from ``FoundryChatClient.client``.
vector_store_id: The ID of the vector store to upload to.
file_search_tool: Tool from ``FoundryChatClient.get_file_search_tool()``.
"""
return FileSearchConfig(
backend=FoundryFileSearchBackend(client),
vector_store_id=vector_store_id,
file_search_tool=file_search_tool,
)
@@ -0,0 +1,101 @@
[project]
name = "agent-framework-azure-contentunderstanding"
description = "Azure Content Understanding integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260618"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.9.0,<2",
"agent-framework-foundry>=1.6.0,<2",
"azure-ai-contentunderstanding>=1.2.0b2,<2",
"aiohttp>=3.9,<4",
"filetype>=1.2,<2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.ruff.lint.per-file-ignores]
"**/tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"]
"samples/**" = ["D", "INP", "ERA001", "RUF", "S", "T201", "CPY"]
[tool.coverage.run]
omit = ["**/__init__.py"]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_azure_contentunderstanding"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_azure_contentunderstanding"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_contentunderstanding"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_azure_contentunderstanding --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,117 @@
# Copyright (c) Microsoft. All rights reserved.
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-azure-contentunderstanding",
# "agent-framework-foundry",
# "azure-identity",
# ]
# ///
# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py
import asyncio
import os
from pathlib import Path
from agent_framework import Agent, Content, Message
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
load_dotenv()
"""
Document Q&A — PDF upload with CU-powered extraction
This sample demonstrates the simplest CU integration: upload a PDF and
ask questions about it. Azure Content Understanding extracts structured
markdown with table preservation — superior to LLM-only vision for
scanned PDFs, handwritten content, and complex layouts.
Environment variables:
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
"""
# Path to a sample PDF — uses the shared sample asset if available,
# otherwise falls back to a public URL
SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf"
async def main() -> None:
credential = AzureCliCredential()
# Set up Azure Content Understanding context provider
cu = ContentUnderstandingContextProvider(
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
credential=credential,
analyzer_id="prebuilt-documentSearch", # RAG-optimized document analyzer
max_wait=None, # wait until CU analysis finishes (no background deferral)
)
# Set up the LLM client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=credential,
)
# Create agent with CU context provider.
# The provider extracts document content via CU and injects it into the
# LLM context so the agent can answer questions about the document.
async with cu:
agent = Agent(
client=client,
name="DocumentQA",
instructions=(
"You are a helpful document analyst. Use the analyzed document "
"content and extracted fields to answer questions precisely."
),
context_providers=[cu],
)
# --- Turn 1: Upload PDF and ask a question ---
# 4. Upload PDF and ask questions
# The CU provider extracts markdown + fields from the PDF and injects
# the full content into context so the agent can answer precisely.
print("--- Upload PDF and ask questions ---")
pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
response = await agent.run(
Message(
role="user",
contents=[
Content.from_text(
"What is this document about? Who is the vendor, and what is the total amount due?"
),
Content.from_data(
pdf_bytes,
"application/pdf",
# Always provide filename — used as the document key
additional_properties={"filename": SAMPLE_PDF_PATH.name},
),
],
)
)
usage = response.usage_details or {}
print(f"Agent: {response}")
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
--- Upload PDF and ask questions ---
Agent: This document is an **invoice** for services and fees billed to
**MICROSOFT CORPORATION** (Invoice **INV-100**), including line items
(e.g., Consulting Services, Document Fee, Printing Fee) and a billing summary.
- **Vendor:** **CONTOSO LTD.**
- **Total amount due:** **$610.00**
[Input tokens: 988]
"""
@@ -0,0 +1,143 @@
# Copyright (c) Microsoft. All rights reserved.
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-azure-contentunderstanding",
# "agent-framework-foundry",
# "azure-identity",
# ]
# ///
# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py
import asyncio
import os
from pathlib import Path
from agent_framework import Agent, AgentSession, Content, Message
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
load_dotenv()
"""
Multi-Turn Session — Cached results across turns
This sample demonstrates multi-turn document Q&A using an AgentSession.
The session persists CU analysis results and conversation history across
turns so the agent can answer follow-up questions about previously
uploaded documents without re-analyzing them.
Key concepts:
- AgentSession keeps CU state and conversation history across agent.run() calls
- Turn 1: CU analyzes the PDF and injects full content into context
- Turn 2: Unrelated question — agent answers from general knowledge
- Turn 3: Detailed question — agent uses document content from conversation
history (injected in Turn 1) to answer precisely
Environment variables:
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
"""
SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf"
async def main() -> None:
# 1. Set up credentials and CU context provider
credential = AzureCliCredential()
cu = ContentUnderstandingContextProvider(
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
credential=credential,
analyzer_id="prebuilt-documentSearch",
max_wait=None, # wait until CU analysis finishes (no background deferral)
)
# 2. Set up the LLM client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=credential,
)
# 3. Create agent and persistent session
async with cu:
agent = Agent(
client=client,
name="DocumentQA",
instructions=(
"You are a helpful document analyst. Use the analyzed document "
"content and extracted fields to answer questions precisely."
),
context_providers=[cu],
)
# Create a persistent session — this keeps CU state across turns
session = AgentSession()
# 4. Turn 1: Upload PDF
# CU analyzes the PDF and injects full content into context.
print("--- Turn 1: Upload PDF ---")
pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
response = await agent.run(
Message(
role="user",
contents=[
Content.from_text("What is this document about?"),
Content.from_data(
pdf_bytes,
"application/pdf",
additional_properties={"filename": SAMPLE_PDF_PATH.name},
),
],
),
session=session, # <-- persist state across turns
)
usage = response.usage_details or {}
print(f"Agent: {response}")
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
# 5. Turn 2: Unrelated question
# No document needed — agent answers from general knowledge.
print("--- Turn 2: Unrelated question ---")
response = await agent.run("What is the capital of France?", session=session)
usage = response.usage_details or {}
print(f"Agent: {response}")
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
# 6. Turn 3: Detailed follow-up
# The agent answers from the full document content that was injected
# into conversation history in Turn 1. No re-analysis or tool call needed.
print("--- Turn 3: Detailed follow-up ---")
response = await agent.run(
"What is the shipping address on the invoice?",
session=session,
)
usage = response.usage_details or {}
print(f"Agent: {response}")
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]\n")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
--- Turn 1: Upload PDF ---
Agent: This document is an **invoice** from **CONTOSO LTD.** to **MICROSOFT
CORPORATION**. Amount Due: $610.00. Invoice INV-100, dated 11/15/2019.
[Input tokens: 975]
--- Turn 2: Unrelated question ---
Agent: Paris.
[Input tokens: 1134]
--- Turn 3: Detailed follow-up ---
Agent: Shipping address (SHIP TO): Microsoft Delivery, 123 Ship St,
Redmond WA, 98052.
[Input tokens: 1155]
"""
@@ -0,0 +1,186 @@
# Copyright (c) Microsoft. All rights reserved.
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-azure-contentunderstanding",
# "agent-framework-foundry",
# "azure-identity",
# ]
# ///
# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py
import asyncio
import os
import time
from pathlib import Path
from agent_framework import Agent, AgentSession, Content, Message
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
load_dotenv()
"""
Multi-Modal Chat — PDF, audio, and video in a single turn
This sample demonstrates CU's multi-modal capability: upload a PDF invoice,
an audio call recording, and a video file all at once. The provider analyzes
all three in parallel using the right CU analyzer for each media type.
The provider auto-detects the media type and selects the right CU analyzer:
- PDF/images → prebuilt-documentSearch
- Audio → prebuilt-audioSearch
- Video → prebuilt-videoSearch
Environment variables:
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
"""
# Local PDF from package assets
SAMPLE_PDF = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf"
# Public audio/video from Azure CU samples repo (raw GitHub URLs)
_CU_ASSETS = "https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main"
AUDIO_URL = f"{_CU_ASSETS}/audio/callCenterRecording.mp3"
VIDEO_URL = f"{_CU_ASSETS}/videos/sdk_samples/FlightSimulator.mp4"
async def main() -> None:
# 1. Set up credentials and CU context provider
credential = AzureCliCredential()
# No analyzer_id specified — the provider auto-detects from media type:
# PDF/images → prebuilt-documentSearch
# Audio → prebuilt-audioSearch
# Video → prebuilt-videoSearch
cu = ContentUnderstandingContextProvider(
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
credential=credential,
max_wait=None, # wait until each analysis finishes
)
# 2. Set up the LLM client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=credential,
)
# 3. Create agent and session
async with cu:
agent = Agent(
client=client,
name="MultiModalAgent",
instructions=(
"You are a helpful assistant that can analyze documents, audio, "
"and video files. Answer questions using the extracted content."
),
context_providers=[cu],
)
session = AgentSession()
# --- Turn 1: Upload all 3 modalities at once ---
# The provider analyzes all files in parallel using the appropriate
# CU analyzer for each media type. All results are injected into
# the same context so the agent can answer about all of them.
turn1_prompt = (
"I'm uploading three files: an invoice PDF, a call center "
"audio recording, and a flight simulator video. "
"Give a brief summary of each file."
)
print("--- Turn 1: Upload PDF + audio + video (parallel analysis) ---")
print(" (CU analysis may take a few minutes for these audio/video files...)")
print(f"User: {turn1_prompt}")
t0 = time.perf_counter()
response = await agent.run(
Message(
role="user",
contents=[
Content.from_text(turn1_prompt),
Content.from_data(
SAMPLE_PDF.read_bytes(),
"application/pdf",
additional_properties={"filename": "invoice.pdf"},
),
Content.from_uri(
AUDIO_URL,
media_type="audio/mp3",
additional_properties={"filename": "callCenterRecording.mp3"},
),
Content.from_uri(
VIDEO_URL,
media_type="video/mp4",
additional_properties={"filename": "FlightSimulator.mp4"},
),
],
),
session=session,
)
elapsed = time.perf_counter() - t0
usage = response.usage_details or {}
print(f" [Analyzed in {elapsed:.1f}s | Input tokens: {usage.get('input_token_count', 'N/A')}]")
print(f"Agent: {response}\n")
# --- Turn 2: Detail question about the PDF ---
turn2_prompt = "What are the line items and their amounts on the invoice?"
print("--- Turn 2: PDF detail ---")
print(f"User: {turn2_prompt}")
response = await agent.run(turn2_prompt, session=session)
usage = response.usage_details or {}
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
print(f"Agent: {response}\n")
# --- Turn 3: Detail question about the audio ---
turn3_prompt = "What was the customer's issue in the call recording?"
print("--- Turn 3: Audio detail ---")
print(f"User: {turn3_prompt}")
response = await agent.run(turn3_prompt, session=session)
usage = response.usage_details or {}
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
print(f"Agent: {response}\n")
# --- Turn 4: Detail question about the video ---
turn4_prompt = "What key scenes or actions are shown in the flight simulator video?"
print("--- Turn 4: Video detail ---")
print(f"User: {turn4_prompt}")
response = await agent.run(turn4_prompt, session=session)
usage = response.usage_details or {}
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
print(f"Agent: {response}\n")
# --- Turn 5: Cross-document question ---
turn5_prompt = (
"Across all three files, which one contains financial data, "
"which one involves a customer interaction, and which one is "
"a visual demonstration?"
)
print("--- Turn 5: Cross-document question ---")
print(f"User: {turn5_prompt}")
response = await agent.run(turn5_prompt, session=session)
usage = response.usage_details or {}
print(f" [Input tokens: {usage.get('input_token_count', 'N/A')}]")
print(f"Agent: {response}\n")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
--- Turn 1: Upload PDF + audio + video (parallel analysis) ---
User: I'm uploading three files...
(CU analysis may take 1-2 minutes for audio/video files...)
[Analyzed in ~94s | Input tokens: ~2939]
Agent: ### invoice.pdf: An invoice from CONTOSO LTD. to MICROSOFT CORPORATION...
### callCenterRecording.mp3: A customer service call about point balance...
### FlightSimulator.mp4: A clip discussing neural text-to-speech...
--- Turn 2-5: Detail and cross-document questions ---
(Agent answers from conversation history without re-analysis)
"""
@@ -0,0 +1,193 @@
# Copyright (c) Microsoft. All rights reserved.
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-azure-contentunderstanding",
# "agent-framework-foundry",
# "azure-identity",
# "pydantic",
# ]
# ///
# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py
import asyncio
import os
from pathlib import Path
from agent_framework import Agent, AgentSession, Content, Message
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import BaseModel, Field
load_dotenv()
"""
Invoice Processing — Structured output with prebuilt-invoice analyzer
This sample demonstrates CU's structured field extraction combined with
LLM structured output (Pydantic model). The prebuilt-invoice analyzer extracts
typed fields (VendorName, InvoiceTotal, DueDate, LineItems, etc.) with
confidence scores. We use output_sections=["fields"] only (no markdown needed)
since we want the LLM to produce a structured JSON response from the extracted
fields, not summarize document text.
Environment variables:
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
"""
SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf"
# Structured output model — the LLM will return JSON matching this schema
# Structured output models — the LLM returns JSON matching this schema.
#
# Note: the prebuilt-invoice analyzer extracts an extensive set of fields
# (VendorName, BillingAddress, ShippingAddress, TaxDetails, PONumber, etc.).
# This sample defines a simplified schema to extract only the fields of
# interest to the caller. The LLM maps the full CU field output to this
# subset automatically.
# Learn more about prebuilt analyzers: https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/prebuilt-analyzers
class LineItem(BaseModel):
description: str
quantity: float | None = None
unit_price: float | None = None
amount: float | None = None
class LowConfidenceField(BaseModel):
field_name: str
confidence: float
class InvoiceResult(BaseModel):
vendor_name: str
total_amount: float | None = None
currency: str = "USD"
due_date: str | None = None
line_items: list[LineItem] = Field(default_factory=list)
low_confidence_fields: list[LowConfidenceField] = Field(
default_factory=list,
description="Fields with confidence < 0.8, including their confidence score",
)
async def main() -> None:
# 1. Set up credentials and CU context provider
credential = AzureCliCredential()
# Default analyzer is prebuilt-documentSearch (RAG-optimized).
# Per-file override via additional_properties["analyzer_id"] lets us
# use prebuilt-invoice for structured field extraction on specific files.
#
# Only request "fields" (not "markdown") — we want the extracted typed
# fields for structured output, not the raw document text.
cu = ContentUnderstandingContextProvider(
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
credential=credential,
analyzer_id="prebuilt-documentSearch", # default for all files
max_wait=None, # wait until CU analysis finishes
output_sections=["fields"], # fields only — structured output doesn't need markdown
)
# 2. Set up the LLM client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=credential,
)
# 3. Create agent and session
async with cu:
agent = Agent(
client=client,
name="InvoiceProcessor",
instructions=(
"You are an invoice processing assistant. Extract invoice data from "
"the provided CU fields (JSON with confidence scores). Return structured "
"output matching the requested schema. Flag fields with confidence < 0.8 "
"in the low_confidence_fields list."
),
context_providers=[cu],
)
session = AgentSession()
# 4. Upload an invoice PDF — uses structured output (Pydantic model)
print("--- Upload Invoice (Structured Output) ---")
pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
response = await agent.run(
Message(
role="user",
contents=[
Content.from_text(
"Process this invoice. Extract the vendor name, total amount, due date, and all line items."
),
Content.from_data(
pdf_bytes,
"application/pdf",
# Per-file analyzer override: use prebuilt-invoice for
# structured field extraction (VendorName, InvoiceTotal, etc.)
# instead of the provider default (prebuilt-documentSearch).
additional_properties={
"filename": SAMPLE_PDF_PATH.name,
"analyzer_id": "prebuilt-invoice",
},
),
],
),
session=session,
options={"response_format": InvoiceResult},
)
# Parse the structured output from JSON text
try:
invoice = InvoiceResult.model_validate_json(response.text)
print(f"Vendor: {invoice.vendor_name}")
print(f"Total: {invoice.currency} {invoice.total_amount}")
print(f"Due date: {invoice.due_date}")
print(f"Line items ({len(invoice.line_items)}):")
for item in invoice.line_items:
print(f" - {item.description}: {item.amount}")
if invoice.low_confidence_fields:
print("⚠ Low confidence fields:")
for f in invoice.low_confidence_fields:
print(f" - {f.field_name}: {f.confidence:.3f}")
except Exception:
print(f"Agent (raw): {response.text}\n")
# 5. Follow-up: free-text question about the invoice
print("\n--- Follow-up (Free Text) ---")
response = await agent.run(
"What is the payment term? Are there any fields with low confidence?",
session=session,
)
print(f"Agent: {response}\n")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
--- Upload Invoice (Structured Output) ---
Vendor: CONTOSO LTD.
Total: USD 110.0
Due date: 2019-12-15
Line items (3):
- Consulting Services: 60.0
- Document Fee: 30.0
- Printing Fee: 10.0
⚠ Low confidence: VendorName, CustomerName
--- Follow-up (Free Text) ---
Agent: The payment terms are not explicitly stated on the invoice...
"""
@@ -0,0 +1,166 @@
# Copyright (c) Microsoft. All rights reserved.
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-azure-contentunderstanding",
# "agent-framework-foundry",
# "azure-identity",
# ]
# ///
# Run with: uv run packages/azure-contentunderstanding/samples/01-get-started/05_large_doc_file_search.py
import asyncio
import os
from pathlib import Path
from agent_framework import Agent, AgentSession, Content, Message
from agent_framework.foundry import (
ContentUnderstandingContextProvider,
FileSearchConfig,
FoundryChatClient,
)
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
load_dotenv()
"""
Large Document + file_search RAG — CU extraction + OpenAI vector store
For large documents (100+ pages) or long audio/video, injecting the full
CU-extracted content into the LLM context is impractical. This sample shows
how to use the built-in file_search integration: CU extracts markdown and
automatically uploads it to an OpenAI vector store for token-efficient RAG.
When ``FileSearchConfig`` is provided, the provider:
1. Extracts markdown via CU (handles scanned PDFs, audio, video)
2. Uploads the extracted markdown to a vector store
3. Registers a ``file_search`` tool on the agent context
4. Cleans up the vector store on close
Architecture:
Large PDF -> CU extracts markdown -> auto-upload to vector store -> file_search
Follow-up -> file_search retrieves top-k chunks -> LLM answers
NOTE: Requires an async OpenAI client for vector store operations.
This sample uses a single small invoice PDF for simplicity. In practice,
you can upload multiple files in the same session (each is indexed
separately in the vector store), and this pattern is most valuable for
large documents (up to 300 pages), long audio recordings, or video files
where full-context injection would exceed the LLM's context window.
CU supports PDFs up to 300 pages / 200 MB, and audio files up to 300 MB
— see the full service limits:
https://learn.microsoft.com/azure/ai-services/content-understanding/service-limits#input-file-limits
Environment variables:
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
"""
SAMPLE_PDF_PATH = Path(__file__).resolve().parents[1] / "shared" / "sample_assets" / "invoice.pdf"
async def main() -> None:
# 1. Set up credentials and LLM client
credential = AzureCliCredential()
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=credential,
)
# 2. Get the async OpenAI client from FoundryChatClient for vector store operations
openai_client = client.client
# 3. Create vector store and file_search tool
vector_store = await openai_client.vector_stores.create(
name="cu_large_doc_demo",
expires_after={"anchor": "last_active_at", "days": 1},
)
file_search_tool = client.get_file_search_tool(vector_store_ids=[vector_store.id])
# 4. Configure CU provider with file_search integration
# When file_search is set, CU-extracted markdown is automatically uploaded
# to the vector store and the file_search tool is registered on the context.
cu = ContentUnderstandingContextProvider(
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
credential=credential,
analyzer_id="prebuilt-documentSearch",
max_wait=None, # wait until CU analysis + vector store upload finishes
file_search=FileSearchConfig.from_foundry(
openai_client,
vector_store_id=vector_store.id,
file_search_tool=file_search_tool,
),
)
pdf_bytes = SAMPLE_PDF_PATH.read_bytes()
# The provider handles everything: CU extraction + vector store upload + file_search tool
async with cu:
agent = Agent(
client=client,
name="LargeDocAgent",
instructions=(
"You are a document analyst. Use the file_search tool to find "
"relevant sections from the document and answer precisely. "
"Cite specific sections when answering."
),
context_providers=[cu],
)
session = AgentSession()
# Turn 1: Upload — CU extracts and uploads to vector store automatically
print("--- Turn 1: Upload document ---")
response = await agent.run(
Message(
role="user",
contents=[
Content.from_text("What are the key points in this document?"),
Content.from_data(
pdf_bytes,
"application/pdf",
additional_properties={"filename": SAMPLE_PDF_PATH.name},
),
],
),
session=session,
)
print(f"Agent: {response}\n")
# Turn 2: Follow-up — file_search retrieves relevant chunks (token efficient)
print("--- Turn 2: Follow-up (RAG) ---")
response = await agent.run(
"What numbers or financial metrics are mentioned?",
session=session,
)
print(f"Agent: {response}\n")
# Explicitly delete the vector store created for this sample
await openai_client.vector_stores.delete(vector_store.id)
print("Done. Vector store deleted.")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
--- Turn 1: Upload document ---
Agent: An invoice from Contoso Ltd. to Microsoft Corporation (INV-100).
Line items: Consulting Services $60, Document Fee $30, Printing Fee $10.
Subtotal $100, Sales tax $10, Total $110, Previous balance $500, Amount due $610.
--- Turn 2: Follow-up (RAG) ---
Agent: Subtotal $100.00, Sales tax $10.00, Total $110.00,
Previous unpaid balance $500.00, Amount due $610.00.
Line items: 2 hours @ $30 = $60, 3 @ $10 = $30, 10 pages @ $1 = $10.
Done. Vector store cleaned up automatically.
"""
@@ -0,0 +1,33 @@
# DevUI Multi-Modal Agent
Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding.
## Setup
1. Set environment variables (or create a `.env` file in `python/`):
```bash
FOUNDRY_PROJECT_ENDPOINT=https://your-project.api.azureml.ms
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4.1
AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/
```
2. Log in with Azure CLI:
```bash
az login
```
3. Run with DevUI:
```bash
uv run poe devui --agent packages/azure-contentunderstanding/samples/devui_multimodal_agent
```
4. Open the DevUI URL in your browser and start uploading files.
## What You Can Do
- **Upload PDFs** — including scanned/image-based PDFs that LLM vision struggles with
- **Upload images** — handwritten notes, infographics, charts
- **Upload audio** — meeting recordings, call center calls (transcription with speaker ID)
- **Upload video** — product demos, training videos (frame extraction + transcription)
- **Ask questions** across all uploaded documents
- **Check status** — "which documents are ready?" uses the auto-registered `list_documents()` tool
@@ -0,0 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
"""DevUI Multi-Modal Agent with Azure Content Understanding."""
from .agent import agent
__all__ = ["agent"]
@@ -0,0 +1,66 @@
# Copyright (c) Microsoft. All rights reserved.
"""DevUI Multi-Modal Agent — file upload + CU-powered analysis.
This agent uses Azure Content Understanding to analyze uploaded files
(PDFs, scanned documents, handwritten images, audio recordings, video)
and answer questions about them through the DevUI web interface.
Unlike the standard azure_responses_agent which sends files directly to the LLM,
this agent uses CU for structured extraction — superior for scanned PDFs,
handwritten content, audio transcription, and video analysis.
Required environment variables:
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
Run with DevUI:
uv run poe devui --agent packages/azure-contentunderstanding/samples/devui_multimodal_agent
"""
import os
from agent_framework import Agent
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
from azure.core.credentials import AzureKeyCredential
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
load_dotenv()
# --- Auth ---
_credential = AzureCliCredential()
_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY")
_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else _credential
cu = ContentUnderstandingContextProvider(
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
credential=_cu_credential,
# max_wait controls how long before_run() waits for CU analysis before
# deferring to background. For interactive DevUI use, a short timeout
# (e.g. 5s) keeps the chat responsive — the agent tells the user the
# file is still being analyzed and resolves it on the next turn.
# Use max_wait=None to always wait for analysis to complete.
max_wait=5.0,
)
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=_credential,
)
agent = Agent(
client=client,
name="MultiModalDocAgent",
instructions=(
"You are a helpful document analysis assistant. "
"When a user uploads files, they are automatically analyzed using Azure Content Understanding. "
"Use list_documents() to check which documents are ready, pending, or failed "
"and to see which files are available for answering questions. "
"Tell the user if any documents are still being analyzed. "
"You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. "
"When answering, cite specific content from the documents."
),
context_providers=[cu],
)
@@ -0,0 +1,51 @@
# DevUI File Search Agent
Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding + OpenAI file_search RAG.
## How It Works
1. **Upload** any supported file (PDF, image, audio, video) via the DevUI chat
2. **CU analyzes** the file — auto-selects the right analyzer per media type
3. **Markdown extracted** by CU is uploaded to an OpenAI vector store
4. **file_search** tool is registered — LLM retrieves top-k relevant chunks
5. **Ask questions** across all uploaded documents with token-efficient RAG
## Setup
1. Set environment variables (or create a `.env` file in `python/`):
```bash
FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4.1
AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/
```
2. Log in with Azure CLI:
```bash
az login
```
3. Run with DevUI:
```bash
devui packages/azure-contentunderstanding/samples/devui_azure_openai_file_search_agent
```
4. Open the DevUI URL in your browser and start uploading files.
## Supported File Types
| Type | Formats | CU Analyzer (auto-detected) |
|------|---------|----------------------------|
| Documents | PDF, DOCX, XLSX, PPTX, HTML, TXT, Markdown | `prebuilt-documentSearch` |
| Images | JPEG, PNG, TIFF, BMP | `prebuilt-documentSearch` |
| Audio | WAV, MP3, FLAC, OGG, M4A | `prebuilt-audioSearch` |
| Video | MP4, MOV, AVI, WebM | `prebuilt-videoSearch` |
## vs. devui_multimodal_agent
| Feature | multimodal_agent | file_search_agent |
|---------|-----------------|-------------------|
| CU extraction | ✅ Full content injected | ✅ Content indexed in vector store |
| RAG | ❌ | ✅ file_search retrieves top-k chunks |
| Large docs (100+ pages) | ⚠️ May exceed context window | ✅ Token-efficient |
| Multiple large files | ⚠️ Context overflow risk | ✅ All indexed, searchable |
| Best for | Small docs, quick inspection | Large docs, multi-file Q&A |
@@ -0,0 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
"""DevUI Multi-Modal Agent with CU + file_search RAG."""
from .agent import agent
__all__ = ["agent"]
@@ -0,0 +1,105 @@
# Copyright (c) Microsoft. All rights reserved.
"""DevUI Multi-Modal Agent — CU extraction + file_search RAG.
This agent combines Azure Content Understanding with OpenAI file_search
for token-efficient RAG over large or multi-modal documents.
Upload flow:
1. CU extracts high-quality markdown (handles scanned PDFs, audio, video)
2. Extracted markdown is auto-uploaded to an OpenAI vector store
3. file_search tool is registered so the LLM retrieves top-k chunks
4. Vector store is configured to auto-expire after inactivity
This is ideal for large documents (100+ pages), long audio recordings,
or multiple files in the same conversation where full-context injection
would exceed the LLM's context window.
Analyzer auto-detection:
When no analyzer_id is specified, the provider auto-selects the
appropriate CU analyzer based on media type:
- Documents/images → prebuilt-documentSearch
- Audio → prebuilt-audioSearch
- Video → prebuilt-videoSearch
Required environment variables:
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
Run with DevUI:
devui packages/azure-contentunderstanding/samples/devui_azure_openai_file_search_agent
"""
import os
from agent_framework import Agent
from agent_framework.foundry import (
ContentUnderstandingContextProvider,
FileSearchConfig,
FoundryChatClient,
)
from azure.ai.projects import AIProjectClient
from azure.core.credentials import AzureKeyCredential
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
load_dotenv()
# --- Auth ---
_credential = AzureCliCredential()
_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY")
_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else _credential
_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
# --- LLM client + sync vector store setup ---
# DevUI loads agent modules synchronously at startup while an event loop is already
# running, so we cannot use async APIs here. A sync AIProjectClient is used for
# one-time vector store creation; runtime file uploads use client.client (async).
client = FoundryChatClient(
project_endpoint=_endpoint,
model=os.environ["FOUNDRY_MODEL"],
credential=_credential,
)
_sync_project = AIProjectClient(endpoint=_endpoint, credential=_credential) # type: ignore[arg-type]
_sync_openai = _sync_project.get_openai_client()
_vector_store = _sync_openai.vector_stores.create(
name="devui_cu_file_search",
expires_after={"anchor": "last_active_at", "days": 1},
)
_sync_openai.close()
_file_search_tool = client.get_file_search_tool(
vector_store_ids=[_vector_store.id],
max_num_results=3, # limit chunks to reduce input token usage
)
# --- CU context provider with file_search ---
# client.client is the async OpenAI client used for runtime file uploads.
# No analyzer_id → auto-selects per media type (documents, audio, video)
cu = ContentUnderstandingContextProvider(
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
credential=_cu_credential,
file_search=FileSearchConfig.from_foundry(
client.client, # reuse the LLM client's internal AsyncAzureOpenAI for file uploads
vector_store_id=_vector_store.id,
file_search_tool=_file_search_tool,
),
)
agent = Agent(
client=client,
name="FileSearchDocAgent",
instructions=(
"You are a helpful document analysis assistant with RAG capabilities. "
"When a user uploads files, they are automatically analyzed using Azure Content Understanding "
"and indexed in a vector store for efficient retrieval. "
"Analysis takes time (seconds for documents, longer for audio/video) — if a document "
"is still pending, let the user know and suggest they ask again shortly. "
"You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. "
"Multiple files can be uploaded and queried in the same conversation. "
"When answering, cite specific content from the documents."
),
context_providers=[cu],
)
@@ -0,0 +1,34 @@
# DevUI Foundry File Search Agent
Interactive web UI for uploading and chatting with documents, images, audio, and video using Azure Content Understanding + Foundry file_search RAG.
This is the **Foundry** variant. For the Azure OpenAI Responses API variant, see `devui_azure_openai_file_search_agent`.
## How It Works
1. **Upload** any supported file (PDF, image, audio, video) via the DevUI chat
2. **CU analyzes** the file — auto-selects the right analyzer per media type
3. **Markdown extracted** by CU is uploaded to a Foundry vector store
4. **file_search** tool is registered — LLM retrieves top-k relevant chunks
5. **Ask questions** across all uploaded documents with token-efficient RAG
## Setup
1. Set environment variables (or create a `.env` file in `python/`):
```bash
FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/
FOUNDRY_MODEL=gpt-4.1
AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.services.ai.azure.com/
```
2. Log in with Azure CLI:
```bash
az login
```
3. Run with DevUI:
```bash
devui packages/azure-contentunderstanding/samples/devui_foundry_file_search_agent
```
4. Open the DevUI URL in your browser and start uploading files.
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,109 @@
# Copyright (c) Microsoft. All rights reserved.
"""DevUI Multi-Modal Agent — CU extraction + file_search RAG via Azure AI Foundry.
This agent combines Azure Content Understanding with Foundry's file_search
for token-efficient RAG over large or multi-modal documents.
Upload flow:
1. CU extracts high-quality markdown (handles scanned PDFs, audio, video)
2. Extracted markdown is uploaded to a Foundry vector store
3. file_search tool is registered so the LLM retrieves top-k chunks
4. Uploaded files are cleaned up on server shutdown
This sample uses ``FoundryChatClient`` and ``FoundryFileSearchBackend``.
For the OpenAI Responses API variant, see ``devui_azure_openai_file_search_agent``.
Analyzer auto-detection:
When no analyzer_id is specified, the provider auto-selects the
appropriate CU analyzer based on media type:
- Documents/images → prebuilt-documentSearch
- Audio → prebuilt-audioSearch
- Video → prebuilt-videoSearch
Required environment variables:
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4.1)
AZURE_CONTENTUNDERSTANDING_ENDPOINT — CU endpoint URL
Run with DevUI:
devui packages/azure-contentunderstanding/samples/devui_foundry_file_search_agent
"""
import os
from agent_framework import Agent
from agent_framework.foundry import (
ContentUnderstandingContextProvider,
FileSearchConfig,
FoundryChatClient,
)
from azure.core.credentials import AzureKeyCredential
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from openai import AzureOpenAI
load_dotenv()
# --- Auth ---
# AzureCliCredential for Foundry. CU API key optional if on a different resource.
_credential = AzureCliCredential()
_cu_api_key = os.environ.get("AZURE_CONTENTUNDERSTANDING_API_KEY")
_cu_credential = AzureKeyCredential(_cu_api_key) if _cu_api_key else _credential
# --- Foundry LLM client ---
client = FoundryChatClient(
project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT", ""),
model=os.environ.get("FOUNDRY_MODEL", ""),
credential=_credential,
)
# --- Create vector store (sync client to avoid event loop conflicts in DevUI) ---
_token = _credential.get_token("https://ai.azure.com/.default").token
_sync_openai = AzureOpenAI(
azure_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT", ""),
azure_ad_token=_token,
api_version="2025-04-01-preview",
)
_vector_store = _sync_openai.vector_stores.create(
name="devui_cu_foundry_file_search",
expires_after={"anchor": "last_active_at", "days": 1},
)
_sync_openai.close()
_file_search_tool = client.get_file_search_tool(
vector_store_ids=[_vector_store.id],
max_num_results=3, # limit chunks to reduce input token usage
)
# --- CU context provider with file_search ---
# No analyzer_id → auto-selects per media type (documents, audio, video)
cu = ContentUnderstandingContextProvider(
endpoint=os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"],
credential=_cu_credential,
# max_wait is the combined budget for CU analysis + vector store upload.
# For file_search mode, 10s gives enough time for small documents to be
# analyzed and indexed in one turn. Larger files (audio, video) will
# be deferred to background and resolved on the next turn.
max_wait=10.0,
file_search=FileSearchConfig.from_foundry(
client.client,
vector_store_id=_vector_store.id,
file_search_tool=_file_search_tool,
),
)
agent = Agent(
client=client,
name="FoundryFileSearchDocAgent",
instructions=(
"You are a helpful document analysis assistant with RAG capabilities. "
"When a user uploads files, they are automatically analyzed using Azure Content Understanding "
"and indexed in a vector store for efficient retrieval. "
"Analysis takes time (seconds for documents, longer for audio/video) — if a document "
"is still pending, let the user know and suggest they ask again shortly. "
"You can process PDFs, scanned documents, handwritten images, audio recordings, and video files. "
"Multiple files can be uploaded and queried in the same conversation. "
"When answering, cite specific content from the documents."
),
context_providers=[cu],
)
@@ -0,0 +1,39 @@
# Azure Content Understanding Samples
These samples demonstrate how to use the `agent-framework-azure-contentunderstanding` package to add document, image, audio, and video understanding to your agents.
## Prerequisites
1. Azure CLI logged in: `az login`
2. Environment variables set (or `.env` file in the `python/` directory):
```
FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com
FOUNDRY_MODEL=gpt-4.1
AZURE_CONTENTUNDERSTANDING_ENDPOINT=https://your-cu-resource.cognitiveservices.azure.com/
```
## Samples
### 01-get-started — Script samples (easy → advanced)
| # | Sample | Description | Run |
|---|--------|-------------|-----|
| 01 | [Document Q&A](01-get-started/01_document_qa.py) | Upload a PDF, ask questions with CU-powered extraction | `uv run samples/01-get-started/01_document_qa.py` |
| 02 | [Multi-Turn Session](01-get-started/02_multi_turn_session.py) | AgentSession persistence across turns | `uv run samples/01-get-started/02_multi_turn_session.py` |
| 03 | [Multi-Modal Chat](01-get-started/03_multimodal_chat.py) | PDF + audio + video parallel analysis | `uv run samples/01-get-started/03_multimodal_chat.py` |
| 04 | [Invoice Processing](01-get-started/04_invoice_processing.py) | Structured field extraction with prebuilt-invoice | `uv run samples/01-get-started/04_invoice_processing.py` |
| 05 | [Large Doc + file_search](01-get-started/05_large_doc_file_search.py) | CU extraction + OpenAI vector store RAG | `uv run samples/01-get-started/05_large_doc_file_search.py` |
### 02-devui — Interactive web UI samples
| # | Sample | Description | Run |
|---|--------|-------------|-----|
| 01 | [Multi-Modal Agent](02-devui/01-multimodal_agent/) | Web UI for file upload + CU-powered chat | `devui samples/02-devui/01-multimodal_agent` |
| 02a | [file_search (Azure OpenAI backend)](02-devui/02-file_search_agent/azure_openai_backend/) | DevUI with CU + Azure OpenAI vector store | `devui samples/02-devui/02-file_search_agent/azure_openai_backend` |
| 02b | [file_search (Foundry backend)](02-devui/02-file_search_agent/foundry_backend/) | DevUI with CU + Foundry vector store | `devui samples/02-devui/02-file_search_agent/foundry_backend` |
## Install (preview)
```bash
pip install --pre agent-framework-azure-contentunderstanding
```
@@ -0,0 +1,106 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from azure.ai.contentunderstanding.models import AnalysisResult
FIXTURES_DIR = Path(__file__).parent / "fixtures"
def _load_fixture(name: str) -> dict[str, Any]:
return json.loads((FIXTURES_DIR / name).read_text()) # type: ignore[no-any-return]
@pytest.fixture
def pdf_fixture_raw() -> dict[str, Any]:
return _load_fixture("analyze_pdf_result.json")
@pytest.fixture
def pdf_analysis_result(pdf_fixture_raw: dict[str, Any]) -> AnalysisResult:
return AnalysisResult(pdf_fixture_raw)
@pytest.fixture
def audio_fixture_raw() -> dict[str, Any]:
return _load_fixture("analyze_audio_result.json")
@pytest.fixture
def audio_analysis_result(audio_fixture_raw: dict[str, Any]) -> AnalysisResult:
return AnalysisResult(audio_fixture_raw)
@pytest.fixture
def invoice_fixture_raw() -> dict[str, Any]:
return _load_fixture("analyze_invoice_result.json")
@pytest.fixture
def invoice_analysis_result(invoice_fixture_raw: dict[str, Any]) -> AnalysisResult:
return AnalysisResult(invoice_fixture_raw)
@pytest.fixture
def video_fixture_raw() -> dict[str, Any]:
return _load_fixture("analyze_video_result.json")
@pytest.fixture
def video_analysis_result(video_fixture_raw: dict[str, Any]) -> AnalysisResult:
return AnalysisResult(video_fixture_raw)
@pytest.fixture
def image_fixture_raw() -> dict[str, Any]:
return _load_fixture("analyze_image_result.json")
@pytest.fixture
def image_analysis_result(image_fixture_raw: dict[str, Any]) -> AnalysisResult:
return AnalysisResult(image_fixture_raw)
@pytest.fixture
def mock_cu_client() -> AsyncMock:
"""Create a mock ContentUnderstandingClient."""
client = AsyncMock()
client.close = AsyncMock()
return client
def make_mock_poller(result: AnalysisResult) -> AsyncMock:
"""Create a mock poller that returns the given result immediately."""
poller = AsyncMock()
poller.result = AsyncMock(return_value=result)
poller.continuation_token = MagicMock(return_value="mock_continuation_token")
poller.done = MagicMock(return_value=True)
return poller
def make_slow_poller(result: AnalysisResult, delay: float = 10.0) -> MagicMock:
"""Create a mock poller that simulates a timeout then eventually returns."""
poller = MagicMock()
async def slow_result() -> AnalysisResult:
await asyncio.sleep(delay)
return result
poller.result = slow_result
poller.continuation_token = MagicMock(return_value="mock_slow_continuation_token")
poller.done = MagicMock(return_value=False)
return poller
def make_failing_poller(error: Exception) -> AsyncMock:
"""Create a mock poller that raises an exception."""
poller = AsyncMock()
poller.result = AsyncMock(side_effect=error)
return poller
@@ -0,0 +1,13 @@
{
"id": "synthetic-audio-001",
"status": "Succeeded",
"analyzer_id": "prebuilt-audioSearch",
"api_version": "2025-05-01-preview",
"created_at": "2026-03-21T10:05:00Z",
"contents": [
{
"markdown": "## Call Center Recording\n\n**Duration:** 2 minutes 15 seconds\n**Speakers:** 2\n\n### Transcript\n\n**Speaker 1 (Agent):** Thank you for calling Contoso support. My name is Sarah. How can I help you today?\n\n**Speaker 2 (Customer):** Hi Sarah, I'm calling about my recent order number ORD-5678. It was supposed to arrive yesterday but I haven't received it.\n\n**Speaker 1 (Agent):** I'm sorry to hear that. Let me look up your order. Can you confirm your name and email address?\n\n**Speaker 2 (Customer):** Sure, it's John Smith, john.smith@example.com.\n\n**Speaker 1 (Agent):** Thank you, John. I can see your order was shipped on March 18th. It looks like there was a delay with the carrier. The updated delivery estimate is March 22nd.\n\n**Speaker 2 (Customer):** That's helpful, thank you. Is there anything I can do to track it?\n\n**Speaker 1 (Agent):** Yes, I'll send you a tracking link to your email right away. Is there anything else I can help with?\n\n**Speaker 2 (Customer):** No, that's all. Thanks for your help.\n\n**Speaker 1 (Agent):** You're welcome! Have a great day.",
"fields": {}
}
]
}
@@ -0,0 +1,857 @@
{
"analyzerId": "prebuilt-documentSearch",
"apiVersion": "2025-11-01",
"createdAt": "2026-03-21T22:44:21Z",
"stringEncoding": "codePoint",
"warnings": [],
"contents": [
{
"path": "input1",
"markdown": "# Contoso Q1 2025 Financial Summary\n\nTotal revenue for Q1 2025 was $42.7 million, an increase of 18% over Q1 2024.\nOperating expenses were $31.2 million. Net profit was $11.5 million. The largest\nrevenue segment was Cloud Services at $19.3 million, followed by Professional\nServices at $14.8 million and Product Licensing at $8.6 million. Headcount at end of\nQ1 was 1,247 employees across 8 offices worldwide.\n",
"fields": {
"Summary": {
"type": "string",
"valueString": "The document provides a financial summary for Contoso in Q1 2025, reporting total revenue of $42.7 million, an 18% increase from Q1 2024. Operating expenses were $31.2 million, resulting in a net profit of $11.5 million. The largest revenue segment was Cloud Services with $19.3 million, followed by Professional Services at $14.8 million and Product Licensing at $8.6 million. The company had 1,247 employees across 8 offices worldwide at the end of Q1.",
"spans": [
{
"offset": 37,
"length": 77
},
{
"offset": 115,
"length": 80
},
{
"offset": 196,
"length": 77
},
{
"offset": 274,
"length": 84
},
{
"offset": 359,
"length": 50
}
],
"confidence": 0.592,
"source": "D(1,212.0000,334.0000,1394.0000,334.0000,1394.0000,374.0000,212.0000,374.0000);D(1,213.0000,379.0000,1398.0000,379.0000,1398.0000,422.0000,213.0000,422.0000);D(1,212.0000,423.0000,1389.0000,423.0000,1389.0000,464.0000,212.0000,464.0000);D(1,213.0000,468.0000,1453.0000,468.0000,1453.0000,510.0000,213.0000,510.0000);D(1,213.0000,512.0000,1000.0000,512.0000,1000.0000,554.0000,213.0000,554.0000)"
}
},
"kind": "document",
"startPageNumber": 1,
"endPageNumber": 1,
"unit": "pixel",
"pages": [
{
"pageNumber": 1,
"angle": -0.0242,
"width": 1700,
"height": 2200,
"spans": [
{
"offset": 0,
"length": 410
}
],
"words": [
{
"content": "Contoso",
"span": {
"offset": 2,
"length": 7
},
"confidence": 0.99,
"source": "D(1,214,222,401,222,401,274,214,273)"
},
{
"content": "Q1",
"span": {
"offset": 10,
"length": 2
},
"confidence": 0.957,
"source": "D(1,414,222,473,222,473,275,414,274)"
},
{
"content": "2025",
"span": {
"offset": 13,
"length": 4
},
"confidence": 0.929,
"source": "D(1,494,222,607,222,607,276,494,275)"
},
{
"content": "Financial",
"span": {
"offset": 18,
"length": 9
},
"confidence": 0.975,
"source": "D(1,624,222,819,223,819,277,624,276)"
},
{
"content": "Summary",
"span": {
"offset": 28,
"length": 7
},
"confidence": 0.991,
"source": "D(1,836,223,1050,225,1050,279,836,277)"
},
{
"content": "Total",
"span": {
"offset": 37,
"length": 5
},
"confidence": 0.996,
"source": "D(1,212,335,287,334,288,374,212,373)"
},
{
"content": "revenue",
"span": {
"offset": 43,
"length": 7
},
"confidence": 0.994,
"source": "D(1,299,334,417,334,418,374,299,374)"
},
{
"content": "for",
"span": {
"offset": 51,
"length": 3
},
"confidence": 0.994,
"source": "D(1,427,334,467,334,467,374,427,374)"
},
{
"content": "Q1",
"span": {
"offset": 55,
"length": 2
},
"confidence": 0.944,
"source": "D(1,475,334,515,334,515,374,475,374)"
},
{
"content": "2025",
"span": {
"offset": 58,
"length": 4
},
"confidence": 0.876,
"source": "D(1,528,334,604,334,604,374,529,374)"
},
{
"content": "was",
"span": {
"offset": 63,
"length": 3
},
"confidence": 0.991,
"source": "D(1,613,334,672,334,672,374,613,374)"
},
{
"content": "$",
"span": {
"offset": 67,
"length": 1
},
"confidence": 0.999,
"source": "D(1,681,334,698,334,698,374,681,374)"
},
{
"content": "42.7",
"span": {
"offset": 68,
"length": 4
},
"confidence": 0.946,
"source": "D(1,700,334,765,334,765,374,700,374)"
},
{
"content": "million",
"span": {
"offset": 73,
"length": 7
},
"confidence": 0.977,
"source": "D(1,775,334,867,334,867,374,776,374)"
},
{
"content": ",",
"span": {
"offset": 80,
"length": 1
},
"confidence": 0.998,
"source": "D(1,870,334,877,334,877,374,870,374)"
},
{
"content": "an",
"span": {
"offset": 82,
"length": 2
},
"confidence": 0.998,
"source": "D(1,888,334,922,334,922,374,888,374)"
},
{
"content": "increase",
"span": {
"offset": 85,
"length": 8
},
"confidence": 0.991,
"source": "D(1,934,334,1058,335,1059,374,934,374)"
},
{
"content": "of",
"span": {
"offset": 94,
"length": 2
},
"confidence": 0.982,
"source": "D(1,1069,335,1098,335,1098,374,1069,374)"
},
{
"content": "18",
"span": {
"offset": 97,
"length": 2
},
"confidence": 0.963,
"source": "D(1,1108,335,1142,335,1142,374,1108,374)"
},
{
"content": "%",
"span": {
"offset": 99,
"length": 1
},
"confidence": 0.998,
"source": "D(1,1143,335,1171,335,1171,374,1143,374)"
},
{
"content": "over",
"span": {
"offset": 101,
"length": 4
},
"confidence": 0.946,
"source": "D(1,1181,335,1248,335,1248,374,1181,374)"
},
{
"content": "Q1",
"span": {
"offset": 106,
"length": 2
},
"confidence": 0.875,
"source": "D(1,1256,335,1295,335,1295,374,1256,374)"
},
{
"content": "2024",
"span": {
"offset": 109,
"length": 4
},
"confidence": 0.683,
"source": "D(1,1310,335,1384,335,1384,374,1310,374)"
},
{
"content": ".",
"span": {
"offset": 113,
"length": 1
},
"confidence": 0.991,
"source": "D(1,1385,335,1394,335,1394,374,1385,374)"
},
{
"content": "Operating",
"span": {
"offset": 115,
"length": 9
},
"confidence": 0.996,
"source": "D(1,213,380,358,380,358,422,213,422)"
},
{
"content": "expenses",
"span": {
"offset": 125,
"length": 8
},
"confidence": 0.997,
"source": "D(1,369,380,513,379,513,421,369,421)"
},
{
"content": "were",
"span": {
"offset": 134,
"length": 4
},
"confidence": 0.998,
"source": "D(1,521,379,595,379,595,421,521,421)"
},
{
"content": "$",
"span": {
"offset": 139,
"length": 1
},
"confidence": 0.999,
"source": "D(1,603,379,620,379,620,421,603,421)"
},
{
"content": "31.2",
"span": {
"offset": 140,
"length": 4
},
"confidence": 0.938,
"source": "D(1,623,379,686,379,686,421,623,421)"
},
{
"content": "million",
"span": {
"offset": 145,
"length": 7
},
"confidence": 0.913,
"source": "D(1,696,379,790,379,790,421,696,421)"
},
{
"content": ".",
"span": {
"offset": 152,
"length": 1
},
"confidence": 0.975,
"source": "D(1,793,379,800,379,800,421,793,421)"
},
{
"content": "Net",
"span": {
"offset": 154,
"length": 3
},
"confidence": 0.976,
"source": "D(1,811,379,862,379,862,420,811,421)"
},
{
"content": "profit",
"span": {
"offset": 158,
"length": 6
},
"confidence": 0.993,
"source": "D(1,871,379,947,379,947,420,871,420)"
},
{
"content": "was",
"span": {
"offset": 165,
"length": 3
},
"confidence": 0.997,
"source": "D(1,954,379,1012,379,1012,420,953,420)"
},
{
"content": "$",
"span": {
"offset": 169,
"length": 1
},
"confidence": 0.998,
"source": "D(1,1021,379,1039,379,1039,420,1021,420)"
},
{
"content": "11.5",
"span": {
"offset": 170,
"length": 4
},
"confidence": 0.954,
"source": "D(1,1043,379,1106,379,1106,421,1043,420)"
},
{
"content": "million",
"span": {
"offset": 175,
"length": 7
},
"confidence": 0.837,
"source": "D(1,1118,379,1208,379,1208,421,1118,421)"
},
{
"content": ".",
"span": {
"offset": 182,
"length": 1
},
"confidence": 0.978,
"source": "D(1,1210,379,1217,379,1217,421,1210,421)"
},
{
"content": "The",
"span": {
"offset": 184,
"length": 3
},
"confidence": 0.949,
"source": "D(1,1228,379,1285,379,1285,421,1228,421)"
},
{
"content": "largest",
"span": {
"offset": 188,
"length": 7
},
"confidence": 0.978,
"source": "D(1,1295,379,1398,379,1398,421,1295,421)"
},
{
"content": "revenue",
"span": {
"offset": 196,
"length": 7
},
"confidence": 0.995,
"source": "D(1,212,425,334,425,334,464,212,464)"
},
{
"content": "segment",
"span": {
"offset": 204,
"length": 7
},
"confidence": 0.996,
"source": "D(1,344,425,472,424,472,464,344,464)"
},
{
"content": "was",
"span": {
"offset": 212,
"length": 3
},
"confidence": 0.998,
"source": "D(1,480,424,541,424,541,464,480,464)"
},
{
"content": "Cloud",
"span": {
"offset": 216,
"length": 5
},
"confidence": 0.997,
"source": "D(1,550,424,636,424,637,464,551,464)"
},
{
"content": "Services",
"span": {
"offset": 222,
"length": 8
},
"confidence": 0.995,
"source": "D(1,647,424,774,424,774,464,647,464)"
},
{
"content": "at",
"span": {
"offset": 231,
"length": 2
},
"confidence": 0.996,
"source": "D(1,784,424,812,424,812,464,784,464)"
},
{
"content": "$",
"span": {
"offset": 234,
"length": 1
},
"confidence": 0.998,
"source": "D(1,820,424,837,424,837,464,820,464)"
},
{
"content": "19.3",
"span": {
"offset": 235,
"length": 4
},
"confidence": 0.879,
"source": "D(1,840,424,903,423,903,463,840,464)"
},
{
"content": "million",
"span": {
"offset": 240,
"length": 7
},
"confidence": 0.876,
"source": "D(1,915,423,1006,423,1006,463,915,463)"
},
{
"content": ",",
"span": {
"offset": 247,
"length": 1
},
"confidence": 0.999,
"source": "D(1,1008,423,1015,423,1015,463,1008,463)"
},
{
"content": "followed",
"span": {
"offset": 249,
"length": 8
},
"confidence": 0.978,
"source": "D(1,1026,423,1148,424,1148,463,1026,463)"
},
{
"content": "by",
"span": {
"offset": 258,
"length": 2
},
"confidence": 0.986,
"source": "D(1,1160,424,1194,424,1194,463,1160,463)"
},
{
"content": "Professional",
"span": {
"offset": 261,
"length": 12
},
"confidence": 0.965,
"source": "D(1,1204,424,1389,424,1389,463,1204,463)"
},
{
"content": "Services",
"span": {
"offset": 274,
"length": 8
},
"confidence": 0.991,
"source": "D(1,213,469,341,469,341,510,213,510)"
},
{
"content": "at",
"span": {
"offset": 283,
"length": 2
},
"confidence": 0.997,
"source": "D(1,352,469,380,469,380,510,352,510)"
},
{
"content": "$",
"span": {
"offset": 286,
"length": 1
},
"confidence": 0.998,
"source": "D(1,388,469,405,469,405,510,388,510)"
},
{
"content": "14.8",
"span": {
"offset": 287,
"length": 4
},
"confidence": 0.973,
"source": "D(1,410,469,472,469,472,510,410,510)"
},
{
"content": "million",
"span": {
"offset": 292,
"length": 7
},
"confidence": 0.987,
"source": "D(1,483,469,575,469,575,510,483,510)"
},
{
"content": "and",
"span": {
"offset": 300,
"length": 3
},
"confidence": 0.999,
"source": "D(1,585,469,638,469,638,510,585,510)"
},
{
"content": "Product",
"span": {
"offset": 304,
"length": 7
},
"confidence": 0.995,
"source": "D(1,652,469,765,469,765,510,652,510)"
},
{
"content": "Licensing",
"span": {
"offset": 312,
"length": 9
},
"confidence": 0.993,
"source": "D(1,777,469,914,469,914,510,777,510)"
},
{
"content": "at",
"span": {
"offset": 322,
"length": 2
},
"confidence": 0.998,
"source": "D(1,925,469,953,469,953,510,925,510)"
},
{
"content": "$",
"span": {
"offset": 325,
"length": 1
},
"confidence": 0.998,
"source": "D(1,961,469,978,469,978,510,961,510)"
},
{
"content": "8.6",
"span": {
"offset": 326,
"length": 3
},
"confidence": 0.958,
"source": "D(1,980,469,1025,469,1025,510,980,510)"
},
{
"content": "million",
"span": {
"offset": 330,
"length": 7
},
"confidence": 0.908,
"source": "D(1,1036,469,1128,468,1128,510,1036,510)"
},
{
"content": ".",
"span": {
"offset": 337,
"length": 1
},
"confidence": 0.987,
"source": "D(1,1130,468,1137,468,1137,510,1130,510)"
},
{
"content": "Headcount",
"span": {
"offset": 339,
"length": 9
},
"confidence": 0.934,
"source": "D(1,1150,468,1310,468,1310,510,1150,510)"
},
{
"content": "at",
"span": {
"offset": 349,
"length": 2
},
"confidence": 0.993,
"source": "D(1,1318,468,1348,468,1348,510,1318,510)"
},
{
"content": "end",
"span": {
"offset": 352,
"length": 3
},
"confidence": 0.947,
"source": "D(1,1355,468,1410,468,1410,510,1355,510)"
},
{
"content": "of",
"span": {
"offset": 356,
"length": 2
},
"confidence": 0.974,
"source": "D(1,1419,468,1453,468,1453,509,1419,509)"
},
{
"content": "Q1",
"span": {
"offset": 359,
"length": 2
},
"confidence": 0.931,
"source": "D(1,213,512,252,512,252,554,213,554)"
},
{
"content": "was",
"span": {
"offset": 362,
"length": 3
},
"confidence": 0.847,
"source": "D(1,267,512,326,512,326,554,267,554)"
},
{
"content": "1,247",
"span": {
"offset": 366,
"length": 5
},
"confidence": 0.523,
"source": "D(1,338,512,419,512,419,554,338,554)"
},
{
"content": "employees",
"span": {
"offset": 372,
"length": 9
},
"confidence": 0.972,
"source": "D(1,429,513,591,512,591,554,429,554)"
},
{
"content": "across",
"span": {
"offset": 382,
"length": 6
},
"confidence": 0.972,
"source": "D(1,601,512,697,512,697,554,601,554)"
},
{
"content": "8",
"span": {
"offset": 389,
"length": 1
},
"confidence": 0.946,
"source": "D(1,708,512,725,512,725,553,708,554)"
},
{
"content": "offices",
"span": {
"offset": 391,
"length": 7
},
"confidence": 0.95,
"source": "D(1,736,512,831,512,831,553,736,553)"
},
{
"content": "worldwide",
"span": {
"offset": 399,
"length": 9
},
"confidence": 0.988,
"source": "D(1,840,512,989,512,989,552,840,553)"
},
{
"content": ".",
"span": {
"offset": 408,
"length": 1
},
"confidence": 0.996,
"source": "D(1,991,512,1000,512,1000,552,991,552)"
}
],
"lines": [
{
"content": "Contoso Q1 2025 Financial Summary",
"source": "D(1,214,221,1050,225,1050,279,213,273)",
"span": {
"offset": 2,
"length": 33
}
},
{
"content": "Total revenue for Q1 2025 was $42.7 million, an increase of 18% over Q1 2024.",
"source": "D(1,212,334,1394,335,1394,374,212,374)",
"span": {
"offset": 37,
"length": 77
}
},
{
"content": "Operating expenses were $31.2 million. Net profit was $11.5 million. The largest",
"source": "D(1,213,379,1398,378,1398,421,213,422)",
"span": {
"offset": 115,
"length": 80
}
},
{
"content": "revenue segment was Cloud Services at $19.3 million, followed by Professional",
"source": "D(1,212,424,1389,423,1389,463,212,464)",
"span": {
"offset": 196,
"length": 77
}
},
{
"content": "Services at $14.8 million and Product Licensing at $8.6 million. Headcount at end of",
"source": "D(1,213,469,1453,468,1453,510,213,511)",
"span": {
"offset": 274,
"length": 84
}
},
{
"content": "Q1 was 1,247 employees across 8 offices worldwide.",
"source": "D(1,213,512,1000,512,1000,554,213,554)",
"span": {
"offset": 359,
"length": 50
}
}
]
}
],
"paragraphs": [
{
"role": "title",
"content": "Contoso Q1 2025 Financial Summary",
"source": "D(1,214,219,1050,225,1050,279,213,273)",
"span": {
"offset": 0,
"length": 35
}
},
{
"content": "Total revenue for Q1 2025 was $42.7 million, an increase of 18% over Q1 2024. Operating expenses were $31.2 million. Net profit was $11.5 million. The largest revenue segment was Cloud Services at $19.3 million, followed by Professional Services at $14.8 million and Product Licensing at $8.6 million. Headcount at end of Q1 was 1,247 employees across 8 offices worldwide.",
"source": "D(1,212,334,1453,333,1454,553,212,554)",
"span": {
"offset": 37,
"length": 372
}
}
],
"sections": [
{
"span": {
"offset": 0,
"length": 409
},
"elements": [
"/paragraphs/0",
"/paragraphs/1"
]
}
],
"analyzerId": "prebuilt-documentSearch",
"mimeType": "image/png"
}
]
}
@@ -0,0 +1,114 @@
{
"analyzerId": "prebuilt-invoice",
"apiVersion": "2025-11-01",
"createdAt": "2026-03-21T22:44:33Z",
"stringEncoding": "codePoint",
"warnings": [],
"contents": [
{
"markdown": "# Master Services Agreement\n\nClient: Alpine Industries Inc.\n\nContract Reference: MSA-2025-ALP-00847\n\nEffective Date: January 15, 2025\nPrepared for: Robert Chen, Chief Executive Officer, Alpine Industries Inc.\n\nAddress: 742 Evergreen Blvd, Denver, CO 80203\n\nThis Master Services Agreement (the 'Agreement') is entered into by and between Alpine Industries\nInc. (the 'Client') and TechServe Global Partners (the 'Provider'). This agreement governs the provision\nof managed technology services as descri",
"fields": {
"VendorName": {
"type": "string",
"valueString": "TechServe Global Partners",
"confidence": 0.71
},
"DueDate": {
"type": "date",
"valueDate": "2025-02-15",
"confidence": 0.793
},
"InvoiceDate": {
"type": "date",
"valueDate": "2025-01-15",
"confidence": 0.693
},
"InvoiceId": {
"type": "string",
"valueString": "INV-100",
"confidence": 0.489
},
"AmountDue": {
"type": "object",
"valueObject": {
"Amount": {
"type": "number",
"valueNumber": 610,
"confidence": 0.758
},
"CurrencyCode": {
"type": "string",
"valueString": "USD"
}
}
},
"SubtotalAmount": {
"type": "object",
"valueObject": {
"Amount": {
"type": "number",
"valueNumber": 100,
"confidence": 0.902
},
"CurrencyCode": {
"type": "string",
"valueString": "USD"
}
}
},
"LineItems": {
"type": "array",
"valueArray": [
{
"type": "object",
"valueObject": {
"Description": {
"type": "string",
"valueString": "Consulting Services",
"confidence": 0.664
},
"Quantity": {
"type": "number",
"valueNumber": 2,
"confidence": 0.957
},
"UnitPrice": {
"type": "object",
"valueObject": {
"Amount": {
"type": "number",
"valueNumber": 30,
"confidence": 0.956
},
"CurrencyCode": {
"type": "string",
"valueString": "USD"
}
}
}
}
},
{
"type": "object",
"valueObject": {
"Description": {
"type": "string",
"valueString": "Document Fee",
"confidence": 0.712
},
"Quantity": {
"type": "number",
"valueNumber": 3,
"confidence": 0.939
}
}
}
]
}
},
"kind": "document",
"startPageNumber": 1,
"endPageNumber": 100
}
]
}
@@ -0,0 +1,23 @@
{
"analyzerId": "prebuilt-documentSearch",
"apiVersion": "2025-11-01",
"createdAt": "2026-03-21T22:44:09Z",
"contents": [
{
"path": "input1",
"markdown": "# Contoso Q1 2025 Financial Summary\n\nTotal revenue for Q1 2025 was $42.7 million, an increase of 18% over Q1 2024.\nOperating expenses were $31.2 million. Net profit was $11.5 million. The largest\nrevenue segment was Cloud Services at $19.3 million, followed by Professional\nServices at $14.8 million and Product Licensing at $8.6 million. Headcount at end of\nQ1 was 1,247 employees across 8 offices worldwide.\n\n<!-- PageBreak -->\n\n\n# Contoso Q2 2025 Financial Summary\n\nTotal revenue for Q2 2025 was $48.1 million, an increase of 22% over Q2 2024.\nOperating expenses were $33.9 million. Net profit was $14.2 million. Cloud Services\ngrew to $22.5 million, Professional Services was $15.7 million, and Product Licensing\nwas $9.9 million. The company opened a new office in Tokyo, bringing the total to 9\noffices. Headcount grew to 1,389 employees.\n\n<!-- PageBreak -->\n\n\n## Contoso Product Roadmap 2025\n\nThree major product launches are planned for 2025: (1) Contoso CloudVault - an\nenterprise document storage solution, launching August 2025, with an expected price\nof $29.99/user/month. (2) Contoso DataPulse - a real-time analytics dashboard,\nlaunching October 2025. (3) Contoso SecureLink - a zero-trust networking product,\nlaunching December 2025. Total R&D; budget for 2025 is $18.4 million.\n\n<!-- PageBreak -->\n\n\n# Contoso Employee Satisfaction Survey Results\n\nThe annual employee satisfaction survey was completed in March 2025 with a 87%\nresponse rate. Overall satisfaction score was 4.2 out of 5.0. Work-life balance scored\n3.8/5.0. Career growth opportunities scored 3.9/5.0. Compensation satisfaction\nscored 3.6/5.0. The top requested improvement was 'more flexible remote work\noptions' cited by 62% of respondents. Employee retention rate for the trailing 12\nmonths was 91%.\n\n<!-- PageBreak -->\n\n\n## Contoso Partnership Announcements\n\nContoso announced three strategic partnerships in H1 2025: (1) A joint venture with\nMeridian Technologies for AI-powered document processing, valued at $5.2 million\nover 3 years. (2) A distribution agreement with Pacific Rim Solutions covering 12\ncountries in Asia-Pacific. (3) A technology integration partnership with NovaBridge\nSystems for unified identity management. The Chief Partnership Officer, Helena\nNakagawa, stated the partnerships are expected to generate an additional $15 million\nin revenue by 2027.\n",
"fields": {
"Summary": {
"type": "string",
"valueString": "The document provides a comprehensive overview of Contoso's key business metrics and initiatives for 2025, including financial performance for Q1 and Q2 with revenue, expenses, and profit details; a product roadmap with three major launches and R&D budget; employee satisfaction survey results highlighting scores and retention; and strategic partnership announcements expected to boost future revenue.",
"confidence": 0.46
}
},
"kind": "document",
"startPageNumber": 1,
"endPageNumber": 5,
"mimeType": "application/pdf",
"analyzerId": "prebuilt-documentSearch"
}
]
}
@@ -0,0 +1,51 @@
{
"id": "synthetic-video-001",
"status": "Succeeded",
"analyzer_id": "prebuilt-videoSearch",
"api_version": "2025-05-01-preview",
"created_at": "2026-03-21T10:15:00Z",
"contents": [
{
"kind": "audioVisual",
"startTimeMs": 1000,
"endTimeMs": 14000,
"width": 640,
"height": 480,
"markdown": "# Video: 00:01.000 => 00:14.000\n\nTranscript\n```\nWEBVTT\n\n00:01.000 --> 00:05.000\n<Speaker 1>Welcome to the Contoso Product Demo.\n\n00:05.000 --> 00:14.000\n<Speaker 1>Today we'll be showcasing our latest cloud infrastructure management tool.\n```",
"fields": {
"Summary": {
"type": "string",
"valueString": "Introduction to the Contoso Product Demo showcasing the latest cloud infrastructure management tool."
}
}
},
{
"kind": "audioVisual",
"startTimeMs": 15000,
"endTimeMs": 35000,
"width": 640,
"height": 480,
"markdown": "# Video: 00:15.000 => 00:35.000\n\nTranscript\n```\nWEBVTT\n\n00:15.000 --> 00:25.000\n<Speaker 1>As you can see on the dashboard, the system provides real-time monitoring of all deployed resources.\n\n00:25.000 --> 00:35.000\n<Speaker 1>Key features include automated scaling, cost optimization, and security compliance monitoring.\n```",
"fields": {
"Summary": {
"type": "string",
"valueString": "Dashboard walkthrough covering real-time monitoring, automated scaling, cost optimization, and security compliance."
}
}
},
{
"kind": "audioVisual",
"startTimeMs": 36000,
"endTimeMs": 42000,
"width": 640,
"height": 480,
"markdown": "# Video: 00:36.000 => 00:42.000\n\nTranscript\n```\nWEBVTT\n\n00:36.000 --> 00:42.000\n<Speaker 1>Visit contoso.com/cloud-manager to learn more and start your free trial.\n```",
"fields": {
"Summary": {
"type": "string",
"valueString": "Call to action directing viewers to contoso.com/cloud-manager for more information and a free trial."
}
}
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,318 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for ContentUnderstandingContextProvider.
These tests require a live Azure Content Understanding endpoint.
Set AZURE_CONTENTUNDERSTANDING_ENDPOINT to enable them.
To generate fixtures for unit tests, run these tests with --update-fixtures flag
and the resulting JSON files will be written to tests/cu/fixtures/.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, cast
import pytest
skip_if_cu_integration_tests_disabled = pytest.mark.skipif(
not os.environ.get("AZURE_CONTENTUNDERSTANDING_ENDPOINT"),
reason="CU integration tests disabled (AZURE_CONTENTUNDERSTANDING_ENDPOINT not set)",
)
FIXTURES_DIR = Path(__file__).parent / "fixtures"
# Shared sample asset — same PDF used by samples and integration tests
INVOICE_PDF_PATH = Path(__file__).resolve().parents[2] / "samples" / "shared" / "sample_assets" / "invoice.pdf"
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_cu_integration_tests_disabled
async def test_analyze_pdf_binary() -> None:
"""Analyze a PDF via binary upload and optionally capture fixture."""
from azure.ai.contentunderstanding.aio import ContentUnderstandingClient
from azure.identity.aio import DefaultAzureCredential
endpoint = os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"]
analyzer_id = os.environ.get("AZURE_CONTENTUNDERSTANDING_ANALYZER_ID", "prebuilt-documentSearch")
pdf_path = INVOICE_PDF_PATH
assert pdf_path.exists(), f"Test fixture not found: {pdf_path}"
pdf_bytes = pdf_path.read_bytes()
async with DefaultAzureCredential() as credential, ContentUnderstandingClient(endpoint, credential) as client: # pyrefly: ignore[bad-argument-type]
poller = await client.begin_analyze_binary(
analyzer_id,
binary_input=pdf_bytes,
content_type="application/pdf",
string_encoding="utf-8",
)
result = await poller.result()
assert result.contents
assert result.contents[0].markdown
assert len(result.contents[0].markdown) > 10
assert "CONTOSO LTD." in result.contents[0].markdown
# Optionally capture fixture
if os.environ.get("CU_UPDATE_FIXTURES"):
FIXTURES_DIR.mkdir(exist_ok=True)
fixture_path = FIXTURES_DIR / "analyze_pdf_result.json"
fixture_path.write_text(json.dumps(result.as_dict(), indent=2, default=str))
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_cu_integration_tests_disabled
async def test_before_run_e2e() -> None:
"""End-to-end test: Content.from_data → before_run → state populated."""
from agent_framework import Content, Message, SessionContext
from agent_framework._sessions import AgentSession
from azure.identity.aio import DefaultAzureCredential
from agent_framework_azure_contentunderstanding import ContentUnderstandingContextProvider
endpoint = os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"]
pdf_path = INVOICE_PDF_PATH
assert pdf_path.exists(), f"Test fixture not found: {pdf_path}"
pdf_bytes = pdf_path.read_bytes()
async with DefaultAzureCredential() as credential:
cu = ContentUnderstandingContextProvider(
endpoint=endpoint,
credential=credential, # pyrefly: ignore[bad-argument-type]
max_wait=None, # wait until analysis completes (no background deferral)
)
async with cu:
msg = Message(
role="user",
contents=[
Content.from_text("What's in this document?"),
Content.from_data(
pdf_bytes,
"application/pdf",
additional_properties={"filename": "invoice.pdf"},
),
],
)
context = SessionContext(input_messages=[msg])
state: dict[str, object] = {}
session = AgentSession()
from unittest.mock import MagicMock
await cu.before_run(agent=MagicMock(), session=session, context=context, state=state)
docs = cast("dict[str, Any]", state.get("documents", {}))
assert isinstance(docs, dict)
assert "invoice.pdf" in docs
doc_entry = docs["invoice.pdf"]
assert doc_entry["status"] == "ready"
# ``result`` is now the rendered string from ``to_llm_input``.
rendered = doc_entry["result"]
assert isinstance(rendered, str)
assert len(rendered) > 10
assert "source: invoice.pdf" in rendered
assert "CONTOSO LTD." in rendered
# Raw GitHub URL for a public invoice PDF from the CU samples repo
_INVOICE_PDF_URL = (
"https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/invoice.pdf"
)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_cu_integration_tests_disabled
async def test_before_run_uri_content() -> None:
"""End-to-end test: Content.from_uri with an external URL → before_run → state populated.
Verifies that CU can analyze a file referenced by URL (not base64 data).
Uses a public invoice PDF from the Azure CU samples repository.
"""
from agent_framework import Content, Message, SessionContext
from agent_framework._sessions import AgentSession
from azure.identity.aio import DefaultAzureCredential
from agent_framework_azure_contentunderstanding import ContentUnderstandingContextProvider
endpoint = os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"]
async with DefaultAzureCredential() as credential:
cu = ContentUnderstandingContextProvider(
endpoint=endpoint,
credential=credential, # pyrefly: ignore[bad-argument-type]
max_wait=None, # wait until analysis completes (no background deferral)
)
async with cu:
msg = Message(
role="user",
contents=[
Content.from_text("What's on this invoice?"),
Content.from_uri(
uri=_INVOICE_PDF_URL,
media_type="application/pdf",
additional_properties={"filename": "invoice.pdf"},
),
],
)
context = SessionContext(input_messages=[msg])
state: dict[str, object] = {}
session = AgentSession()
from unittest.mock import MagicMock
await cu.before_run(agent=MagicMock(), session=session, context=context, state=state)
docs = cast("dict[str, Any]", state.get("documents", {}))
assert isinstance(docs, dict)
assert "invoice.pdf" in docs
doc_entry = docs["invoice.pdf"]
assert doc_entry["status"] == "ready"
rendered = doc_entry["result"]
assert isinstance(rendered, str)
assert len(rendered) > 10
assert "source: invoice.pdf" in rendered
assert "CONTOSO LTD." in rendered
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_cu_integration_tests_disabled
async def test_before_run_data_uri_content() -> None:
"""End-to-end test: Content.from_uri with a base64 data URI → before_run → state populated.
Verifies that CU can analyze a file embedded as a data URI (data:application/pdf;base64,...).
This tests the data URI path: from_uri with "data:" prefix → type="data" → begin_analyze_binary.
"""
import base64
from agent_framework import Content, Message, SessionContext
from agent_framework._sessions import AgentSession
from azure.identity.aio import DefaultAzureCredential
from agent_framework_azure_contentunderstanding import ContentUnderstandingContextProvider
endpoint = os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"]
pdf_path = INVOICE_PDF_PATH
assert pdf_path.exists(), f"Test fixture not found: {pdf_path}"
pdf_bytes = pdf_path.read_bytes()
b64 = base64.b64encode(pdf_bytes).decode("ascii")
data_uri = f"data:application/pdf;base64,{b64}"
async with DefaultAzureCredential() as credential:
cu = ContentUnderstandingContextProvider(
endpoint=endpoint,
credential=credential, # pyrefly: ignore[bad-argument-type]
max_wait=None, # wait until analysis completes
)
async with cu:
msg = Message(
role="user",
contents=[
Content.from_text("What's on this invoice?"),
Content.from_uri(
uri=data_uri,
media_type="application/pdf",
additional_properties={"filename": "invoice_b64.pdf"},
),
],
)
context = SessionContext(input_messages=[msg])
state: dict[str, object] = {}
session = AgentSession()
from unittest.mock import MagicMock
await cu.before_run(agent=MagicMock(), session=session, context=context, state=state)
docs = cast("dict[str, Any]", state.get("documents", {}))
assert isinstance(docs, dict)
assert "invoice_b64.pdf" in docs
doc_entry = docs["invoice_b64.pdf"]
assert doc_entry["status"] == "ready"
rendered = doc_entry["result"]
assert isinstance(rendered, str)
assert len(rendered) > 10
assert "source: invoice_b64.pdf" in rendered
assert "CONTOSO LTD." in rendered
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_cu_integration_tests_disabled
async def test_before_run_background_analysis() -> None:
"""End-to-end test: max_wait timeout → background analysis → resolved on next turn.
Uses a short max_wait (0.5s) so CU analysis is deferred to background.
Then waits for analysis to complete and calls before_run again to verify
the background task resolves and the document becomes ready.
"""
import asyncio
from agent_framework import Content, Message, SessionContext
from agent_framework._sessions import AgentSession
from azure.identity.aio import DefaultAzureCredential
from agent_framework_azure_contentunderstanding import ContentUnderstandingContextProvider
endpoint = os.environ["AZURE_CONTENTUNDERSTANDING_ENDPOINT"]
async with DefaultAzureCredential() as credential:
cu = ContentUnderstandingContextProvider(
endpoint=endpoint,
credential=credential, # pyrefly: ignore[bad-argument-type]
max_wait=0.5, # short timeout to force background deferral
)
async with cu:
# Turn 1: upload file — should time out and defer to background
msg = Message(
role="user",
contents=[
Content.from_text("What's on this invoice?"),
Content.from_uri(
uri=_INVOICE_PDF_URL,
media_type="application/pdf",
additional_properties={"filename": "invoice.pdf"},
),
],
)
context = SessionContext(input_messages=[msg])
state: dict[str, object] = {}
session = AgentSession()
from unittest.mock import MagicMock
await cu.before_run(agent=MagicMock(), session=session, context=context, state=state)
docs = cast("dict[str, Any]", state.get("documents", {}))
assert isinstance(docs, dict)
assert "invoice.pdf" in docs
assert docs["invoice.pdf"]["status"] == "analyzing", (
f"Expected 'analyzing' but got '{docs['invoice.pdf']['status']}'"
"CU responded too fast for the 0.5s timeout"
)
assert docs["invoice.pdf"]["result"] is None
# Wait for background analysis to complete
await asyncio.sleep(30)
# Turn 2: no new files — should resolve the background task
msg2 = Message(role="user", contents=[Content.from_text("Is it ready?")])
context2 = SessionContext(input_messages=[msg2])
await cu.before_run(agent=MagicMock(), session=session, context=context2, state=state)
assert docs["invoice.pdf"]["status"] == "ready"
rendered = docs["invoice.pdf"]["result"]
assert isinstance(rendered, str)
assert "CONTOSO LTD." in rendered
@@ -0,0 +1,68 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from unittest.mock import AsyncMock
from agent_framework_azure_contentunderstanding._models import (
DocumentEntry,
DocumentStatus,
FileSearchConfig,
)
class TestDocumentEntry:
def test_construction(self) -> None:
entry: DocumentEntry = {
"status": DocumentStatus.READY,
"filename": "invoice.pdf",
"media_type": "application/pdf",
"analyzer_id": "prebuilt-documentSearch",
"analyzed_at": "2026-01-01T00:00:00+00:00",
"analysis_duration_s": 1.23,
"upload_duration_s": None,
"result": "---\nsource: invoice.pdf\n---\n# Title",
"error": None,
}
assert entry["status"] == DocumentStatus.READY
assert entry["filename"] == "invoice.pdf"
assert entry["analyzer_id"] == "prebuilt-documentSearch"
assert entry["analysis_duration_s"] == 1.23
assert entry["upload_duration_s"] is None
assert isinstance(entry["result"], str)
def test_failed_entry(self) -> None:
entry: DocumentEntry = {
"status": DocumentStatus.FAILED,
"filename": "bad.pdf",
"media_type": "application/pdf",
"analyzer_id": "prebuilt-documentSearch",
"analyzed_at": "2026-01-01T00:00:00+00:00",
"analysis_duration_s": 0.5,
"upload_duration_s": None,
"result": None,
"error": "Service unavailable",
}
assert entry["status"] == DocumentStatus.FAILED
assert entry["error"] == "Service unavailable"
assert entry["result"] is None
class TestFileSearchConfig:
def test_required_fields(self) -> None:
backend = AsyncMock()
tool = {"type": "file_search", "vector_store_ids": ["vs_123"]}
config = FileSearchConfig(backend=backend, vector_store_id="vs_123", file_search_tool=tool)
assert config.backend is backend
assert config.vector_store_id == "vs_123"
assert config.file_search_tool is tool
def test_from_openai_factory(self) -> None:
from agent_framework_azure_contentunderstanding._file_search import OpenAIFileSearchBackend
client = AsyncMock()
tool = {"type": "file_search", "vector_store_ids": ["vs_abc"]}
config = FileSearchConfig.from_openai(client, vector_store_id="vs_abc", file_search_tool=tool)
assert isinstance(config.backend, OpenAIFileSearchBackend)
assert config.vector_store_id == "vs_abc"
assert config.file_search_tool is tool