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,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,
)