From 1e1eda65ce928651c05e9ee6ed747a282395533f Mon Sep 17 00:00:00 2001 From: Yung-Shin Lin Date: Tue, 28 Apr 2026 13:55:59 -0700 Subject: [PATCH] [Python] Add agent-framework-azure-ai-contentunderstanding package (#4829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add agent-framework-azure-contentunderstanding package Add Azure Content Understanding integration as a context provider for the Agent Framework. The package automatically analyzes file attachments (documents, images, audio, video) using Azure CU and injects structured results (markdown, fields) into the LLM context. Key features: - Multi-document session state with status tracking (pending/ready/failed) - Configurable timeout with async background fallback for large files - Output filtering via AnalysisSection enum - Auto-registered list_documents() and get_analyzed_document() tools - Supports all CU modalities: documents, images, audio, video - Content limits enforcement (pages, file size, duration) - Binary stripping of supported files from input messages Public API: - ContentUnderstandingContextProvider (main class) - AnalysisSection (output section selector enum) - ContentLimits (configurable limits dataclass) Tests: 46 unit tests, 91% coverage, all linting and type checks pass. * fix: update CU fixtures with real API data, fix test assertions - Replace synthetic fixtures with real CU API responses (sanitized) - Update test assertions to match real data (Contoso vs CONTOSO, TotalAmount vs InvoiceTotal, field values from real analysis) - Add --pre install note in README (preview package) - Document unenforced ContentLimits fields (max_pages, duration) * chore: add connector .gitignore, update uv.lock * refactor: rename to azure-ai-contentunderstanding, fix CI issues Align naming with Azure SDK convention and AF pattern: - Directory: azure-contentunderstanding -> azure-ai-contentunderstanding - PyPI: agent-framework-azure-contentunderstanding -> agent-framework-azure-ai-contentunderstanding - Module: agent_framework_azure_contentunderstanding -> agent_framework_azure_ai_contentunderstanding CI fixes: - Inline conftest helpers to avoid cross-package import collision in xdist - Remove PyPI badge and dead API reference link from README (package not published yet) * feat: add samples (document_qa, invoice_processing, multimodal_chat) - document_qa.py: Single PDF upload, CU context provider, follow-up Q&A - invoice_processing.py: Structured field extraction with prebuilt-invoice - multimodal_chat.py: Multi-file session with status tracking - Add ruff per-file-ignores for samples/ directory - Update README with samples section, env vars, and run instructions * feat: add remaining samples (devui_multimodal_agent, large_doc_file_search) - S3: devui_multimodal_agent/ — DevUI web UI with CU-powered file analysis - S4: large_doc_file_search.py — CU extraction + OpenAI vector store RAG - Update README and samples/README.md with all 5 samples * feat: add file_search integration for large document RAG Add FileSearchConfig — when provided, CU-extracted markdown is automatically uploaded to an OpenAI vector store and a file_search tool is registered on the context. This enables token-efficient RAG retrieval for large documents without users needing to manage vector stores manually. - FileSearchConfig dataclass (openai_client, vector_store_name) - Auto-create vector store, upload markdown, register file_search tool - Auto-cleanup on close() - When file_search is enabled, skip full content injection (use RAG instead) - Update large_doc_file_search sample to use the integration - 4 new tests (50 total, 90% coverage) * fix: add key-based auth support to all samples Follow established AF pattern: check for API key env var first, fall back to AzureCliCredential. Supports AZURE_OPENAI_API_KEY and AZURE_CONTENTUNDERSTANDING_API_KEY environment variables. * FEATURE(python): add analyzer auto-detection, file_search RAG, and lazy init _context_provider.py: - Make analyzer_id optional (default None) with auto-detection by media type prefix: audio->audioSearch, video->videoSearch, else documentSearch - Add _ensure_initialized() for lazy client creation in before_run() - Add FileSearchConfig-based vector store upload - Fix: background-completed docs in file_search mode now upload to vector store instead of injecting full markdown into context messages - Add _pending_uploads queue for deferred vector store uploads devui_file_search_agent/ (new sample): - DevUI agent combining CU extraction + OpenAI file_search RAG azure_responses_agent (existing sample fix): - Add AzureCliCredential support and AZURE_AI_PROJECT_ENDPOINT fallback Tests (19 new), Docs updated (AGENTS.md, README.md) * feat(cu): MIME sniffing, media-aware formatting, unified timeout, vector store expiration - Add three-layer MIME detection (fast path → filetype binary sniff → filename fallback) to handle unreliable upstream MIME types (e.g. mp4 sent as application/octet-stream). Adds filetype>=1.2,<2 dependency. - Media-aware output formatting: video shows duration/resolution + all fields as JSON; audio promotes Summary as prose; document unchanged. - Unified timeout for all media types (removed file_search special-case that waited indefinitely for video/audio). All files use max_wait with background polling fallback. - Vector store created with expires_after=1 day as crash safety net. - Add 8 MIME sniffing tests (TestMimeSniffing class). * fix: merge all CU content segments for video/audio analysis CU's prebuilt-videoSearch and prebuilt-audioSearch analyzers split long media files into multiple `contents[]` segments. Previously, `_extract_sections()` only read `contents[0]`, causing truncated duration, missing transcript, and incomplete fields for any video/audio longer than a single scene. Now iterates all segments and merges: - duration: global min(startTimeMs) → max(endTimeMs) - markdown: concatenated with `---` separators - fields: same-named fields collected into per-segment list - metadata (kind, resolution): taken from first segment Single-segment results (documents, short audio) are unaffected. Update test fixture to realistic 3-segment video structure and expand assertions to verify multi-segment merging. Add documentation for multi-segment processing and speaker diarization limitation. * refactor: improve CU context provider docs and remove ContentLimits - Improve class docstring: clarify endpoint (Azure AI Foundry URL with example), credential (AzureKeyCredential vs Entra ID), and analyzer_id (prebuilt/custom with auto-selection behavior and reference links) - Add SUPPORTED_MEDIA_TYPES comments explaining MIME-based matching behavior and add missing file types per CU service docs - Use namespaced logger to align with other packages - Remove ContentLimits and related code/tests - Rename DEFAULT_MAX_WAIT to DEFAULT_MAX_WAIT_SECONDS for clarity * feat: support user-provided vector store in FileSearchConfig - Add vector_store_id field to FileSearchConfig (None = auto-create) - Track _owns_vector_store to only delete auto-created stores on close() - Remove vector_store_name; use internal _DEFAULT_VECTOR_STORE_NAME - Add inline comments for private state fields - Document output_sections default in docstring - Update AGENTS.md, samples, and tests * fix: remove ContentLimits from README code block * refactor: create CU client in __init__ instead of __aenter__ Follow Azure AI Search provider pattern: create the client eagerly in __init__, make __aenter__ a no-op. This ensures __aexit__/close() is always safe to call and eliminates the _ensure_initialized() workaround. * docs: add file_search param to class docstring * feat: introduce FileSearchBackend abstraction for cross-client support Replace direct OpenAI client usage with FileSearchBackend ABC: - OpenAIFileSearchBackend: for OpenAIChatClient (Responses API) - FoundryFileSearchBackend: for FoundryChatClient (Azure Foundry) - Shared base _OpenAICompatBackend for common vector store CRUD FileSearchConfig now takes a backend instead of openai_client. Factory methods from_openai() and from_foundry() for convenience. BREAKING: FileSearchConfig(openai_client=...) -> FileSearchConfig.from_openai(...) * refactor: FileSearchBackend abstraction + caller-owned vector store * fix: file_search reliability and sample improvements - Poll vector store indexing (create_and_poll) to ensure file_search returns results immediately after upload - Set status to failed when vector store upload fails - Skip get_analyzed_document tool in file_search mode to prevent LLM from bypassing RAG - Simplify sample auth: single credential, direct parameters - Use from_foundry backend for Foundry project endpoints * perf: set max_num_results=10 for file_search to reduce token usage * fix: move import to top of file (E402 lint) * chore: remove unused imports * fix: align azure-ai-contentunderstanding with MAF coding conventions - Add module-level docstrings to __init__.py and _context_provider.py - Use Self return type for __aenter__ (with typing_extensions fallback) - Use explicit typed params for __aexit__ signature - Add sync TokenCredential to AzureCredentialTypes union - Pass AGENT_FRAMEWORK_USER_AGENT to ContentUnderstandingClient - Remove unused ContentLimits from public API and tests - Fix FileSearchConfig tests to match refactored backend API - Fix lifecycle tests to match eager client initialization * refactor: improve CU context provider API surface and fix CI - Refactor _analyze_file to return DocumentEntry instead of mutating dict - Remove TokenCredential from AzureCredentialTypes (fixes mypy/pyright CI) - Remove OpenAIFileSearchBackend/FoundryFileSearchBackend from public API (internal to FileSearchConfig factory methods) - Remove DocumentStatus from public exports (implementation detail) - Update file_search comments to reflect backend-agnostic design - Add DocumentStatus enum, analysis/upload duration tracking - Add combined timeout for CU analysis + vector store upload * fix: improve file_search samples and move tool guidelines to context provider - Delete redundant devui_file_search_agent sample (duplicate of azure_openai variant) - Move tool usage guidelines from sample agent instructions into context provider (extend_instructions in step 6, applied automatically for all file_search users) - Fix file_search purpose: use from_foundry() for Azure OpenAI (purpose="assistants") - Add filename hint in upload instructions for targeted file_search queries - Reduce max_num_results from 10 to 3 in both devui samples - Simplify agent instructions in both samples (remove tool-specific guidance) * feat: improve source_id, integration tests, and content assertions - Rename DEFAULT_SOURCE_ID to "azure_ai_contentunderstanding" (matches azure_ai_search convention) - Improve source_id docstring to describe default value - Clarify _detect_and_strip_files docstring (CU-supported files) - Add invoice.pdf test fixture from Azure CU samples repo - Refactor integration tests to use invoice.pdf directly (assert instead of skip when fixture missing) - Add URI content test (Content.from_uri with external URL) - Add "CONTOSO LTD." content assertion to all integration tests - Use max_wait=None in integration tests (wait until complete) * feat: reject duplicate filenames, add integration tests and sample comments - Reject duplicate document keys in before_run (skip + warn LLM to rename) - Update _derive_doc_key docstring to document uniqueness constraint - Add unit tests for duplicate filename rejection (cross-turn and same-turn) - Add integration test for data URI content (from_uri with base64) - Add integration test for background analysis (max_wait timeout + resolve) - Add filename recommendation comments to all samples' Content.from_data() * chore: improve doc key derivation, comments, and README - Replace hash-based doc key with uuid4 for anonymous uploads (O(1), no payload traversal) - Remove hashlib import (no longer needed) - Add File Naming section to README (filename importance, duplicate rejection) - Improve inline comments (_derive_doc_key, _extract_binary, URL parsing) * test: strengthen _format_result assertions with exact expected strings - Replace loose 'in' checks with exact 'assert formatted == expected' for both multi-segment and single-segment format tests - Add object-type fields (ShippingAddress, Speakers) to test data to cover nested dict/list serialization - Add position-based ordering assertions to verify structural correctness (header -> markdown -> fields across segments) * refactor: move invoice.pdf to shared sample_assets directory - Move invoice.pdf from tests/cu/test_data/ to python/samples/shared/sample_assets/ as single source of truth - Add INVOICE_PDF_PATH constant in test_integration.py pointing to the shared location - Update document_qa.py, invoice_processing.py, large_doc_file_search.py to use invoice.pdf instead of sample.pdf * refactor: reorganize samples into numbered dirs and simplify auth - Move script samples into 01-get-started/ with numbered prefixes (01_document_qa, 02_multimodal_chat, 03_invoice_processing, 04_large_doc_file_search) - Move devui samples into 02-devui/ with 01-multimodal_agent and 02-file_search_agent/{azure_openai_backend,foundry_backend} - Move invoice.pdf to CU package-local samples/shared/sample_assets/ - Replace kwargs dicts with direct constructor calls; support both API key (AZURE_OPENAI_API_KEY) and AzureCliCredential - Update README sample table with new paths * fix: resolve CI lint errors (D205, RUF001, E501) - Fix D205: single-line docstring summary for _detect_and_strip_files - Fix RUF001: replace EN DASH with HYPHEN-MINUS in segment headers - Fix E501: wrap long assertion lines in tests - Also includes samples reorg and auth simplification * refactor: overhaul samples — FoundryChatClient, sessions, remove get_analyzed_document Samples: - Switch all samples from deprecated AzureOpenAIResponsesClient to FoundryChatClient - Add 02_multi_turn_session.py showing AgentSession persistence across turns - Rewrite 03_multimodal_chat.py with real PDF + audio + video (parallel analysis), per-modality follow-ups, cross-document question, elapsed time, user prompts, and input token counts - Renumber: 02->03 multimodal, 03->04 invoice, 04->05 file_search Context provider: - Remove get_analyzed_document tool -- full content is in conversation history via InMemoryHistoryProvider, no retrieval tool needed - Remove follow-up turn instructions about tools - Only list_documents tool remains (for status queries) - Update README to reflect tool removal * feat: add 05_background_analysis sample and fix 04 session/max_wait - Add 05_background_analysis.py demonstrating non-blocking CU analysis with max_wait=1s, status tracking via list_documents(), and automatic background task resolution on subsequent turns - Fix 04_invoice_processing.py: add max_wait=None and AgentSession - Rename 05→06 large_doc_file_search - Update README sample table * docs: update README and fix sample 06 README: - Switch Quick Start from AzureOpenAIResponsesClient to FoundryChatClient - Add AgentSession to Quick Start example - Fix status values: pending -> analyzing/uploading/ready/failed - Fix env var: AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME -> AZURE_OPENAI_DEPLOYMENT_NAME - Update samples section with new paths, link to samples/README.md - Update multi-segment description to reflect per-segment fields Sample 06: - Fix from_openai -> from_foundry for Azure endpoints - Add AgentSession and max_wait=None * docs: rewrite README — concise format, prerequisites, CU link * fix: resolve pyright errors in _format_result segment cast * docs: add numbered section comments and fresh sample output to all samples - Add numbered section comments (# 1. ..., # 2. ...) per SAMPLE_GUIDELINES - Re-run all 6 samples and update expected output with real results - Fix duplicate sample output blocks in 04 and 05 - Update README code example to use public invoice URL * feat: add load_settings support for env var configuration - Make endpoint optional in constructor — auto-loads from AZURE_CONTENTUNDERSTANDING_ENDPOINT env var via load_settings() - Add ContentUnderstandingSettings TypedDict - Add env_file_path/env_file_encoding params for .env file support - Add 4 unit tests: env var loading, explicit override, missing endpoint error, missing credential error - Update README with env var auto-resolution docs - Follows framework convention used by all other packages * docs: polish README — fix duplicate env var, add Next steps, service limits link * chore: trim invoice fixture from 199K to 33 lines Keep only VendorName, InvoiceTotal, DueDate, InvoiceDate, InvoiceId fields and first 500 chars of markdown. Strip spans/source/coordinates. Reduces fixture from 6.6MB to 1.2KB. * feat: per-file analyzer_id override via additional_properties - Read analyzer_id from Content.additional_properties for per-file override - Resolution order: per-file > provider-level > auto-detect by media type - Update class docstring documenting filename and analyzer_id properties - Update sample 04 to demonstrate per-file override (prebuilt-invoice) - Add unit test for per-file analyzer override * Trim PDF test fixture and clarify unique filename requirement - Trim analyze_pdf_result.json from 4427 to 23 lines by removing pages, words, lines, paragraphs, sections, spans, and source fields that are not used by any unit test. - Add docstring note that filename must be unique within a session; duplicate filenames are rejected and the file will not be analyzed. * Update python/packages/azure-ai-contentunderstanding/agent_framework_azure_ai_contentunderstanding/_context_provider.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/azure-ai-contentunderstanding/agent_framework_azure_ai_contentunderstanding/_context_provider.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/azure-ai-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/agent.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/azure-ai-contentunderstanding/samples/02-devui/01-multimodal_agent/agent.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/azure-ai-contentunderstanding/samples/01-get-started/06_large_doc_file_search.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix AGENTS.md to match implementation; remove unused variable in test helper AGENTS.md: - Remove _ensure_initialized() reference (client is created in __init__) - Fix multi-segment docs: segments kept as list, not merged into fields - Remove get_analyzed_document() reference (only list_documents registered) - Update sample names to match current directory structure test_context_provider.py: - Simplify _make_data_uri() — remove unused 'encoded' variable * Fix premature file_search instruction for background-completed docs - Change _resolve_pending_tasks() instruction from 'Use file_search' to 'being indexed' since the upload hasn't completed yet at that point. - Add LLM instruction on upload failure in step 1b so the agent can inform the user the document isn't searchable. * fix: wrap long line in devui agent instructions (E501) * Fix Copilot review: unused logger, stray code in README, await cancelled tasks - _file_search.py: Remove unused logger and logging import - 01-multimodal_agent/README.md: Remove accidentally pasted Python script - _context_provider.py close(): Await cancelled tasks before closing client to prevent 'Task destroyed but pending' warnings * Sanitize doc keys and fix duplicate filename re-injection - Add _sanitize_doc_key() to strip control characters, collapse whitespace, and cap length at 255 chars — prevents prompt injection via crafted filenames in extend_instructions() calls. - Track accepted doc_keys in step 3 so step 5 only injects content for files actually analyzed this turn, not pre-existing duplicates. - Soften duplicate upload instruction wording (remove IMPORTANT/caps). * fix: add type annotation to tasks_to_cancel for pyright * Move per-session mutable state to state dict for session isolation Previously _pending_tasks, _pending_uploads, and _uploaded_file_ids were stored on self, shared across all sessions. This caused cross-session leakage: Session A's background task results could be injected into Session B's context. Now these are stored in the per-session state dict. Global copies (_all_pending_tasks, _all_uploaded_file_ids) are kept on self only for best-effort cleanup in close(). Add 2 new TestSessionIsolation tests verifying that background tasks and resolved content stay within their originating session. * Remove unused AnalysisSection enum values Only MARKDOWN and FIELDS are handled by _extract_sections(). Remove FIELD_GROUNDING, TABLES, PARAGRAPHS, SECTIONS to avoid exposing dead options to users. * Recursively flatten object/array field values for cleaner LLM output - Use SDK .value property with recursive extraction for object/array fields - Object: AmountDue -> {Amount: 610, CurrencyCode: USD} (was raw SDK dict) - Array: LineItems -> list of flattened items (was raw SDK list) - Update invoice fixture with object/array fields from prebuilt-invoice - Add 3 unit tests for object, array, and nested object field extraction * Preserve sub-field confidence; compare full expected JSON in tests * Remove incorrect MIME aliases (audio/mp4, video/x-matroska) * feat: add AnalysisInput, content_range, warnings, and category support - Use SDK AnalysisInput model instead of raw body dict for begin_analyze - Forward content_range from additional_properties to CU (page/time ranges) - Extract CU warnings with code/message/target (ODataV4Format) into output - Include content-level category from classifier analyzers - Add 5 new tests: warnings, category, content_range forwarding - Fix pyright with explicit casts; fix en-dash lint (RUF002) * fix: falsy-0 bug in duration calc; improve test coverage - Fix start_time_ms=0 treated as falsy by 'or' short-circuit, use 'is None' checks instead for duration and segment time extraction - Update warnings test to use RAI ContentFiltered codes - Enrich warnings extraction to include code/message/target (ODataV4Format) - Add multi-segment video category test with per-segment assertions * refactor: split _context_provider.py into focused modules - Extract _constants.py: SUPPORTED_MEDIA_TYPES, MIME_ALIASES, analyzer maps - Extract _detection.py: file detection, MIME sniffing, doc key derivation - Extract _extraction.py: result extraction, field flattening, LLM formatting - _context_provider.py delegates via thin wrappers (793 lines, was 1255) - Update test imports to use _constants.py for SUPPORTED_MEDIA_TYPES * docs: update AGENTS.md with DocumentStatus, FileSearchBackend, and _file_search.py * refactor: replace AnalysisSection enum with Literal type for simpler DX - Remove AnalysisSection(str, Enum) class, replace with Literal["markdown", "fields"] type alias - Users can now pass plain strings: output_sections=["markdown"] — no extra import needed - AnalysisSection type alias still exported for type annotation use - Update all samples, tests, and internal code to use string literals - Address PR review feedback (eavanvalkenburg) * refactor: replace asyncio.Task with continuation tokens for serializable state - Replace state["_pending_tasks"] (asyncio.Task — not serializable) with state["_pending_tokens"] (dict of continuation token strings) so the framework can persist session state to disk/storage - Resume pending analyses via Azure SDK continuation_token mechanism - Fix: resumed pollers have stale cached status (done() always False), use asyncio.wait_for(poller.result()) with 10s min timeout instead - Remove _background_poll(), _all_pending_tasks, and task cancellation - Address PR review feedback (eavanvalkenburg): state must be serializable * fix: resolve CI lint (RUF052) and mypy (call-overload) errors * feat: add structured output (Pydantic model) to invoice processing sample - Use response_format=InvoiceResult for schema-constrained LLM output - Use output_sections=["fields"] only (no markdown needed for structured output) - Add LowConfidenceField model with confidence values - Add comments about prebuilt-invoice extensive schema vs simplified model - Address PR review feedback (eavanvalkenburg): use structured response * fix: use FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL env vars in all samples Replace AZURE_AI_PROJECT_ENDPOINT → FOUNDRY_PROJECT_ENDPOINT and AZURE_OPENAI_DEPLOYMENT_NAME → FOUNDRY_MODEL across all sample .py and README.md files. Address PR review feedback (eavanvalkenburg). * refactor: remove background_analysis sample, use FoundryChatClient in DevUI - Remove 05_background_analysis.py (per reviewer feedback — discuss max_wait design separately from samples) - Renumber 06_large_doc_file_search.py → 05_large_doc_file_search.py - Replace AzureOpenAIResponsesClient with FoundryChatClient in all DevUI samples - Replace client.as_agent() with Agent(client=client, ...) everywhere - Add max_wait comments explaining interactive vs batch usage - Update README.md and AGENTS.md - Address PR review feedback (eavanvalkenburg) * fix: vector_stores API moved from beta namespace in OpenAI SDK * docs: add comments about multi-file support and CU service limits in file_search sample * fix: broken markdown links after sample removal and renumbering * fix: migrate BaseContextProvider to ContextProvider (non-deprecated) * fix: Message(text=) -> Message(contents=[]) for API compatibility * Inline _constants.py into consuming modules Remove _constants.py and move constants to where they are used: - SUPPORTED_MEDIA_TYPES, MIME_ALIASES → _detection.py - MEDIA_TYPE_ANALYZER_MAP, DEFAULT_ANALYZER → _context_provider.py Addresses review feedback to reduce file count. * Mark package as alpha per package management skill - Version: 1.0.0b260401 → 1.0.0a260401 - Classifier: Development Status 4 - Beta → 3 - Alpha - Add to PACKAGE_STATUS.md as alpha Follows the alpha package checklist from python-package-management skill. * Replace extend_instructions with extend_messages for status notifications Status/error/result notifications now use extend_messages (conversation context) instead of extend_instructions (system prompt). This avoids system prompt bloat and keeps behavioral directives separate from event notifications. - 11 extend_instructions calls → extend_messages (role='user') - 1 extend_instructions retained: tool usage guidelines (behavioral) - 6 test assertions updated to check context_messages All 84 unit tests + 5 live integration tests pass. * Fix lint: E402 import order, ISC004 implicit string concatenation - Move constants after all imports to fix E402 - Wrap multi-line strings in parentheses inside contents=[] to fix ISC004 * Fix lint: remove unused json import in invoice sample * Fix CI: apply ruff format + fix E501 line length after reformatting ruff format expands Message() calls to multi-line, pushing string indentation deeper. Break long strings to fit within 120 char limit after formatting. Also removes unused json import in sample. * Address review feedback: keyword-only args, accept pre-built client, remove wrappers - All __init__ args now keyword-only (matches FoundryChatClient pattern) - New 'client' param accepts pre-built ContentUnderstandingClient - core dep bound: >=1.0.0rc5 → >=1.0.0,<2 - Self import moved after local imports - Removed 9 static method wrappers; callsites use module functions directly - Tests updated to import derive_doc_key and format_result directly * fix: remove duplicate ContentUnderstandingClient instantiation The client was being created twice — once inside the if/else block and again unconditionally after it. The second instantiation overwrote the pre-built client path and failed type checking when credential was None. * rename: azure-ai-contentunderstanding → azure-contentunderstanding Package: agent-framework-azure-ai-contentunderstanding → agent-framework-azure-contentunderstanding Module: agent_framework_azure_ai_contentunderstanding → agent_framework_azure_contentunderstanding Directory: packages/azure-ai-contentunderstanding → packages/azure-contentunderstanding Per agreement with PM and MAF team to drop 'AI' from the package name. * feat: add ContentUnderstanding re-export to agent_framework.foundry namespace Enables: from agent_framework.foundry import ContentUnderstandingContextProvider Exports: ContentUnderstandingContextProvider, FileSearchConfig, FileSearchBackend, AnalysisSection, DocumentStatus Updates all samples and README to use the foundry namespace import. * fix: add missing copyright headers to standalone sample scripts * chore: remove .vscode/settings.json and add to .gitignore * refactor: reuse FoundryChatClient.client for vector store ops in file_search sample Address review feedback from TaoChenOSU: - 05_large_doc_file_search.py: use client.client instead of manually constructing AsyncAzureOpenAI; remove openai dependency - azure_openai_backend/agent.py: import reorder only (AIProjectClient kept — required for sync vector store creation in DevUI) * fix: skip closing client when caller passes pre-built client When a ContentUnderstandingClient is passed via client=, the caller owns its lifecycle. Added _owns_client flag so close() only closes the client when we created it internally. --------- Co-authored-by: yungshinlin Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/AGENTS.md | 1 + python/PACKAGE_STATUS.md | 1 + .../azure-contentunderstanding/.gitignore | 3 + .../azure-contentunderstanding/AGENTS.md | 71 + .../azure-contentunderstanding/LICENSE | 21 + .../azure-contentunderstanding/README.md | 127 + .../__init__.py | 28 + .../_context_provider.py | 858 +++++++ .../_detection.py | 234 ++ .../_extraction.py | 297 +++ .../_file_search.py | 101 + .../_models.py | 115 + .../azure-contentunderstanding/pyproject.toml | 100 + .../samples/01-get-started/01_document_qa.py | 119 + .../01-get-started/02_multi_turn_session.py | 145 ++ .../01-get-started/03_multimodal_chat.py | 188 ++ .../01-get-started/04_invoice_processing.py | 195 ++ .../05_large_doc_file_search.py | 166 ++ .../02-devui/01-multimodal_agent/README.md | 33 + .../02-devui/01-multimodal_agent/__init__.py | 6 + .../02-devui/01-multimodal_agent/agent.py | 68 + .../azure_openai_backend/README.md | 51 + .../azure_openai_backend/__init__.py | 6 + .../azure_openai_backend/agent.py | 105 + .../foundry_backend/README.md | 34 + .../foundry_backend/__init__.py | 1 + .../foundry_backend/agent.py | 110 + .../samples/README.md | 39 + .../samples/shared/sample_assets/invoice.pdf | Bin 0 -> 151363 bytes .../tests/cu/conftest.py | 106 + .../cu/fixtures/analyze_audio_result.json | 13 + .../cu/fixtures/analyze_image_result.json | 857 +++++++ .../cu/fixtures/analyze_invoice_result.json | 114 + .../tests/cu/fixtures/analyze_pdf_result.json | 23 + .../cu/fixtures/analyze_video_result.json | 51 + .../tests/cu/test_context_provider.py | 2049 +++++++++++++++++ .../tests/cu/test_integration.py | 312 +++ .../tests/cu/test_models.py | 67 + .../core/agent_framework/foundry/__init__.py | 6 + .../core/agent_framework/foundry/__init__.pyi | 12 + python/pyproject.toml | 2 + python/uv.lock | 43 + 42 files changed, 6878 insertions(+) create mode 100644 python/packages/azure-contentunderstanding/.gitignore create mode 100644 python/packages/azure-contentunderstanding/AGENTS.md create mode 100644 python/packages/azure-contentunderstanding/LICENSE create mode 100644 python/packages/azure-contentunderstanding/README.md create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/__init__.py create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_context_provider.py create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_detection.py create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_extraction.py create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_file_search.py create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_models.py create mode 100644 python/packages/azure-contentunderstanding/pyproject.toml create mode 100644 python/packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py create mode 100644 python/packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py create mode 100644 python/packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py create mode 100644 python/packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py create mode 100644 python/packages/azure-contentunderstanding/samples/01-get-started/05_large_doc_file_search.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/README.md create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/__init__.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/agent.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/README.md create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/__init__.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/agent.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/README.md create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/__init__.py create mode 100644 python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/agent.py create mode 100644 python/packages/azure-contentunderstanding/samples/README.md create mode 100644 python/packages/azure-contentunderstanding/samples/shared/sample_assets/invoice.pdf create mode 100644 python/packages/azure-contentunderstanding/tests/cu/conftest.py create mode 100644 python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_audio_result.json create mode 100644 python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_image_result.json create mode 100644 python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_invoice_result.json create mode 100644 python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_pdf_result.json create mode 100644 python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_video_result.json create mode 100644 python/packages/azure-contentunderstanding/tests/cu/test_context_provider.py create mode 100644 python/packages/azure-contentunderstanding/tests/cu/test_integration.py create mode 100644 python/packages/azure-contentunderstanding/tests/cu/test_models.py diff --git a/python/AGENTS.md b/python/AGENTS.md index e4697e18d5..f910919f95 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -69,6 +69,7 @@ python/ ### Azure Integrations - [foundry](packages/foundry/README.md) - Microsoft Foundry chat, agent, memory, and embedding integrations +- [azure-contentunderstanding](packages/azure-contentunderstanding/AGENTS.md) - Azure Content Understanding context provider - [azure-ai-search](packages/azure-ai-search/AGENTS.md) - Azure AI Search RAG - [azure-cosmos](packages/azure-cosmos/AGENTS.md) - Azure Cosmos DB-backed history provider - [azurefunctions](packages/azurefunctions/AGENTS.md) - Azure Functions hosting diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 661cebe53a..7d9cc65767 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -18,6 +18,7 @@ Status is grouped into these buckets: | `agent-framework-a2a` | `python/packages/a2a` | `beta` | | `agent-framework-ag-ui` | `python/packages/ag-ui` | `beta` | | `agent-framework-anthropic` | `python/packages/anthropic` | `beta` | +| `agent-framework-azure-contentunderstanding` | `python/packages/azure-contentunderstanding` | `alpha` | | `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` | | `agent-framework-azure-cosmos` | `python/packages/azure-cosmos` | `beta` | | `agent-framework-azurefunctions` | `python/packages/azurefunctions` | `beta` | diff --git a/python/packages/azure-contentunderstanding/.gitignore b/python/packages/azure-contentunderstanding/.gitignore new file mode 100644 index 0000000000..051cb93f3d --- /dev/null +++ b/python/packages/azure-contentunderstanding/.gitignore @@ -0,0 +1,3 @@ +# Local-only files (not committed) +_local_only/ +*_local_only* diff --git a/python/packages/azure-contentunderstanding/AGENTS.md b/python/packages/azure-contentunderstanding/AGENTS.md new file mode 100644 index 0000000000..530a01643d --- /dev/null +++ b/python/packages/azure-contentunderstanding/AGENTS.md @@ -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 + ``, ``, 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 +``` diff --git a/python/packages/azure-contentunderstanding/LICENSE b/python/packages/azure-contentunderstanding/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/azure-contentunderstanding/LICENSE @@ -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 diff --git a/python/packages/azure-contentunderstanding/README.md b/python/packages/azure-contentunderstanding/README.md new file mode 100644 index 0000000000..b46975fee3 --- /dev/null +++ b/python/packages/azure-contentunderstanding/README.md @@ -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) diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/__init__.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/__init__.py new file mode 100644 index 0000000000..9b05519560 --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/__init__.py @@ -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__", +] diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_context_provider.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_context_provider.py new file mode 100644 index 0000000000..3271d2a3ac --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_context_provider.py @@ -0,0 +1,858 @@ +# 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 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.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 ._extraction import extract_sections, format_result +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" + + +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://.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": , + # "analyzer_id": }} + pending_tokens: dict[str, dict[str, str]] = state.setdefault("_pending_tokens", {}) + pending_uploads: list[tuple[str, DocumentEntry]] = state.setdefault("_pending_uploads", []) + + # 1. Resolve pending background analyses via continuation tokens + await self._resolve_pending_tokens(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=[format_result(entry["filename"], entry["result"])]), + ], + ) + 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" + " (JSON) 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) + extracted = self._extract_sections(result) + 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=extracted, + 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 Token Resolution + # ------------------------------------------------------------------ + + async def _resolve_pending_tokens( + 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"], # pyright: ignore[reportCallIssue] + ) + # 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, + ) # pyright: ignore[reportUnknownVariableType] + 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) + extracted = self._extract_sections(result) # pyright: ignore[reportUnknownArgumentType] + entry["status"] = DocumentStatus.READY + entry["analyzed_at"] = datetime.now(tz=timezone.utc).isoformat() + entry["result"] = extracted + 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=[format_result(entry["filename"], extracted)]), + ], + ) + 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] + + # ------------------------------------------------------------------ + # Output Extraction & Formatting (delegates to _extraction module) + # ------------------------------------------------------------------ + + def _extract_sections(self, result: AnalysisResult) -> dict[str, object]: + return extract_sections(result, self.output_sections) + + # ------------------------------------------------------------------ + # 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 + + # Upload the full formatted content (markdown + fields + segments), + # not just raw markdown — consistent with what non-file_search mode injects. + formatted = format_result(entry["filename"], result) + if not formatted: + 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", formatted.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(formatted)) + 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) diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_detection.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_detection.py new file mode 100644 index 0000000000..75ee88d7d3 --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_detection.py @@ -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 # type: ignore[reportUnknownMemberType] + 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 diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_extraction.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_extraction.py new file mode 100644 index 0000000000..adef84fb89 --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_extraction.py @@ -0,0 +1,297 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Output extraction and formatting for Azure Content Understanding results. + +Converts CU ``AnalysisResult`` objects into plain Python dicts suitable +for LLM consumption, and formats them as human-readable text. +""" + +from __future__ import annotations + +import json +from typing import Any, cast + +from azure.ai.contentunderstanding.models import AnalysisResult + +from ._models import AnalysisSection + + +def extract_sections( + result: AnalysisResult, + output_sections: list[AnalysisSection], +) -> dict[str, object]: + """Extract configured sections from a CU analysis result. + + For single-segment results (documents, images, short audio), returns a flat + dict with ``markdown`` and ``fields`` at the top level. + + For multi-segment results (e.g. video split into scenes), fields are kept + with their respective segments in a ``segments`` list so the LLM can see + which fields belong to which part of the content: + - ``segments``: list of per-segment dicts with ``markdown``, ``fields``, + ``start_time_s``, and ``end_time_s`` + - ``markdown``: still concatenated at top level for file_search uploads + - ``duration_seconds``: computed from the global time span + - ``kind`` / ``resolution``: taken from the first segment + """ + extracted: dict[str, object] = {} + contents = result.contents + if not contents: + return extracted + + # --- Warnings from the CU service (ODataV4Format with code/message/target) --- + if result.warnings: + warnings_out: list[dict[str, str]] = [] + for w in result.warnings: + entry: dict[str, str] = {} + code = getattr(w, "code", None) + if code: + entry["code"] = code + msg = getattr(w, "message", None) + entry["message"] = msg if msg else str(w) + target = getattr(w, "target", None) + if target: + entry["target"] = target + warnings_out.append(entry) + extracted["warnings"] = warnings_out + + # --- Media metadata (from first segment) --- + first = contents[0] + kind = getattr(first, "kind", None) + if kind: + extracted["kind"] = kind + width = getattr(first, "width", None) + height = getattr(first, "height", None) + if width and height: + extracted["resolution"] = f"{width}x{height}" + + # Compute total duration from the global time span of all segments. + global_start: int | None = None + global_end: int | None = None + for content in contents: + s = getattr(content, "start_time_ms", None) + if s is None: + s = getattr(content, "startTimeMs", None) + e = getattr(content, "end_time_ms", None) + if e is None: + e = getattr(content, "endTimeMs", None) + if s is not None: + global_start = s if global_start is None else min(global_start, s) + if e is not None: + global_end = e if global_end is None else max(global_end, e) + if global_start is not None and global_end is not None: + extracted["duration_seconds"] = round((global_end - global_start) / 1000, 1) + + is_multi_segment = len(contents) > 1 + + # --- Single-segment: flat output (documents, images, short audio) --- + if not is_multi_segment: + if "markdown" in output_sections and contents[0].markdown: + extracted["markdown"] = contents[0].markdown + if "fields" in output_sections and contents[0].fields: + fields: dict[str, object] = {} + for name, field in contents[0].fields.items(): + entry_dict: dict[str, object] = { + "type": getattr(field, "type", None), + "value": extract_field_value(field), + } + confidence = getattr(field, "confidence", None) + if confidence is not None: + entry_dict["confidence"] = confidence + fields[name] = entry_dict + if fields: + extracted["fields"] = fields + # Content-level category (e.g. from classifier analyzers) + category = getattr(contents[0], "category", None) + if category: + extracted["category"] = category + return extracted + + # --- Multi-segment: per-segment output (video scenes, long audio) --- + # Each segment keeps its own markdown + fields together so the LLM can + # see which fields (e.g. Summary) belong to which part of the content. + segments_out: list[dict[str, object]] = [] + md_parts: list[str] = [] # also collect for top-level concatenated markdown + + for content in contents: + seg: dict[str, object] = {} + + # Time range for this segment + s = getattr(content, "start_time_ms", None) + if s is None: + s = getattr(content, "startTimeMs", None) + e = getattr(content, "end_time_ms", None) + if e is None: + e = getattr(content, "endTimeMs", None) + if s is not None: + seg["start_time_s"] = round(s / 1000, 1) + if e is not None: + seg["end_time_s"] = round(e / 1000, 1) + + # Per-segment markdown + if "markdown" in output_sections and content.markdown: + seg["markdown"] = content.markdown + md_parts.append(content.markdown) + + # Per-segment fields + if "fields" in output_sections and content.fields: + seg_fields: dict[str, object] = {} + for name, field in content.fields.items(): + seg_entry: dict[str, object] = { + "type": getattr(field, "type", None), + "value": extract_field_value(field), + } + confidence = getattr(field, "confidence", None) + if confidence is not None: + seg_entry["confidence"] = confidence + seg_fields[name] = seg_entry + if seg_fields: + seg["fields"] = seg_fields + + # Per-segment category (e.g. from classifier analyzers) + category = getattr(content, "category", None) + if category: + seg["category"] = category + + segments_out.append(seg) + + extracted["segments"] = segments_out + + # Top-level concatenated markdown (used by file_search for vector store upload) + if md_parts: + extracted["markdown"] = "\n\n---\n\n".join(md_parts) + + return extracted + + +def extract_field_value(field: Any) -> object: + """Extract the plain Python value from a CU ``ContentField``. + + Uses the SDK's ``.value`` convenience property, which dynamically + reads the correct ``value_*`` attribute for each field type. + Object and array types are recursively flattened so that the + output contains only plain Python primitives (str, int, float, + date, dict, list) -- no SDK model objects or raw wire format + (``valueNumber``, ``spans``, ``source``, etc.). + """ + field_type = getattr(field, "type", None) + raw = getattr(field, "value", None) + + # Object fields -> recursively resolve nested sub-fields + if field_type == "object" and raw is not None and isinstance(raw, dict): + return {str(k): flatten_field(v) for k, v in cast(dict[str, Any], raw).items()} + + # Array fields -> list of flattened items (each with value + optional confidence) + if field_type == "array" and raw is not None and isinstance(raw, list): + return [flatten_field(item) for item in cast(list[Any], raw)] + + # Scalar fields (string, number, date, etc.) -- .value returns native Python type + return raw + + +def flatten_field(field: Any) -> object: + """Flatten a CU ``ContentField`` into a ``{type, value, confidence}`` dict. + + Used for sub-fields inside object and array types to preserve + per-field confidence scores. Confidence is omitted when ``None`` + to reduce token usage. + """ + field_type = getattr(field, "type", None) + value = extract_field_value(field) + confidence = getattr(field, "confidence", None) + + result: dict[str, object] = {"type": field_type, "value": value} + if confidence is not None: + result["confidence"] = confidence + return result + + +def format_result(filename: str, result: dict[str, object]) -> str: + """Format extracted CU result for LLM consumption. + + For multi-segment results (video/audio with ``segments``), each segment's + markdown and fields are grouped together so the LLM can see which fields + belong to which part of the content. + """ + kind = result.get("kind") + is_video = kind == "audioVisual" + is_audio = kind == "audio" + + # Header -- media-aware label + if is_video: + label = "Video analysis" + elif is_audio: + label = "Audio analysis" + else: + label = "Document analysis" + parts: list[str] = [f'{label} of "{filename}":'] + + # Media metadata line (duration, resolution) + meta_items: list[str] = [] + duration = result.get("duration_seconds") + if duration is not None: + mins, secs = divmod(int(duration), 60) # type: ignore[call-overload] + meta_items.append(f"Duration: {mins}:{secs:02d}") + resolution = result.get("resolution") + if resolution: + meta_items.append(f"Resolution: {resolution}") + if meta_items: + parts.append(" | ".join(meta_items)) + + # --- Multi-segment: format each segment with its own content + fields --- + raw_segments = result.get("segments") + segments: list[dict[str, object]] = ( + cast(list[dict[str, object]], raw_segments) if isinstance(raw_segments, list) else [] + ) + if segments: + for i, seg in enumerate(segments): + # Segment header with time range + start = seg.get("start_time_s") + end = seg.get("end_time_s") + if start is not None and end is not None: + s_min, s_sec = divmod(int(start), 60) # type: ignore[call-overload] + e_min, e_sec = divmod(int(end), 60) # type: ignore[call-overload] + parts.append(f"\n### Segment {i + 1} ({s_min}:{s_sec:02d} - {e_min}:{e_sec:02d})") + else: + parts.append(f"\n### Segment {i + 1}") + + # Segment markdown + seg_md = seg.get("markdown") + if seg_md: + parts.append(f"\n```markdown\n{seg_md}\n```") + + # Segment fields + seg_fields = seg.get("fields") + if isinstance(seg_fields, dict) and seg_fields: + fields_json = json.dumps(seg_fields, indent=2, default=str) + parts.append(f"\n**Fields:**\n```json\n{fields_json}\n```") + + return "\n".join(parts) + + # --- Single-segment: flat format --- + fields_raw = result.get("fields") + fields: dict[str, object] = cast(dict[str, object], fields_raw) if isinstance(fields_raw, dict) else {} + + # For audio: promote Summary field as prose before markdown + if is_audio and fields: + summary_field = fields.get("Summary") + if isinstance(summary_field, dict): + sf = cast(dict[str, object], summary_field) + if sf.get("value"): + parts.append(f"\n## Summary\n\n{sf['value']}") + + # Markdown content + markdown = result.get("markdown") + if markdown: + parts.append(f"\n## Content\n\n```markdown\n{markdown}\n```") + + # Fields section + if fields: + remaining = dict(fields) + if is_audio: + remaining = {k: v for k, v in remaining.items() if k != "Summary"} + if remaining: + fields_json = json.dumps(remaining, indent=2, default=str) + parts.append(f"\n## Extracted Fields\n\n```json\n{fields_json}\n```") + + return "\n".join(parts) diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_file_search.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_file_search.py new file mode 100644 index 0000000000..a9526f6ebc --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_file_search.py @@ -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 # type: ignore[no-any-return] + + 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" diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_models.py b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_models.py new file mode 100644 index 0000000000..c938c05f12 --- /dev/null +++ b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/_models.py @@ -0,0 +1,115 @@ +# 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: dict[str, object] | None + 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, + ) diff --git a/python/packages/azure-contentunderstanding/pyproject.toml b/python/packages/azure-contentunderstanding/pyproject.toml new file mode 100644 index 0000000000..f8c83b8d93 --- /dev/null +++ b/python/packages/azure-contentunderstanding/pyproject.toml @@ -0,0 +1,100 @@ +[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.0a260401" +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.0.0,<2", + "azure-ai-contentunderstanding>=1.0.0,<1.1", + "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" diff --git a/python/packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py b/python/packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py new file mode 100644 index 0000000000..5f599e969b --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/01-get-started/01_document_qa.py @@ -0,0 +1,119 @@ +# 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 FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework.foundry import ContentUnderstandingContextProvider + +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] +""" diff --git a/python/packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py b/python/packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py new file mode 100644 index 0000000000..46f01dc999 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/01-get-started/02_multi_turn_session.py @@ -0,0 +1,145 @@ +# 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 FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework.foundry import ContentUnderstandingContextProvider + +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] +""" diff --git a/python/packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py b/python/packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py new file mode 100644 index 0000000000..86062fdd1d --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/01-get-started/03_multimodal_chat.py @@ -0,0 +1,188 @@ +# 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 FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework.foundry import ContentUnderstandingContextProvider + +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) +""" diff --git a/python/packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py b/python/packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py new file mode 100644 index 0000000000..80d00894eb --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/01-get-started/04_invoice_processing.py @@ -0,0 +1,195 @@ +# 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 FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +from agent_framework.foundry import ContentUnderstandingContextProvider + +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... +""" diff --git a/python/packages/azure-contentunderstanding/samples/01-get-started/05_large_doc_file_search.py b/python/packages/azure-contentunderstanding/samples/01-get-started/05_large_doc_file_search.py new file mode 100644 index 0000000000..ba3802bf54 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/01-get-started/05_large_doc_file_search.py @@ -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. +""" diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/README.md b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/README.md new file mode 100644 index 0000000000..12263ce50f --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/README.md @@ -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 diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/__init__.py b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/__init__.py new file mode 100644 index 0000000000..3ca9ea7e09 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft. All rights reserved. +"""DevUI Multi-Modal Agent with Azure Content Understanding.""" + +from .agent import agent + +__all__ = ["agent"] diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/agent.py b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/agent.py new file mode 100644 index 0000000000..018b9232f5 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/01-multimodal_agent/agent.py @@ -0,0 +1,68 @@ +# 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 FoundryChatClient +from azure.core.credentials import AzureKeyCredential +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework.foundry import ContentUnderstandingContextProvider + +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], +) diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/README.md b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/README.md new file mode 100644 index 0000000000..0acb10a0d3 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/README.md @@ -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 | diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/__init__.py b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/__init__.py new file mode 100644 index 0000000000..92f4181db4 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/__init__.py @@ -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"] diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/agent.py b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/agent.py new file mode 100644 index 0000000000..aae1826173 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/azure_openai_backend/agent.py @@ -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], +) diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/README.md b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/README.md new file mode 100644 index 0000000000..b72ed62327 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/README.md @@ -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. diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/__init__.py b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/__init__.py new file mode 100644 index 0000000000..2a50eae894 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/agent.py b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/agent.py new file mode 100644 index 0000000000..818abc2a23 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/02-devui/02-file_search_agent/foundry_backend/agent.py @@ -0,0 +1,110 @@ +# 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 FoundryChatClient +from azure.core.credentials import AzureKeyCredential +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from openai import AzureOpenAI + +from agent_framework.foundry import ( + ContentUnderstandingContextProvider, + FileSearchConfig, +) + +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], +) diff --git a/python/packages/azure-contentunderstanding/samples/README.md b/python/packages/azure-contentunderstanding/samples/README.md new file mode 100644 index 0000000000..0f4460e3f4 --- /dev/null +++ b/python/packages/azure-contentunderstanding/samples/README.md @@ -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 +``` diff --git a/python/packages/azure-contentunderstanding/samples/shared/sample_assets/invoice.pdf b/python/packages/azure-contentunderstanding/samples/shared/sample_assets/invoice.pdf new file mode 100644 index 0000000000000000000000000000000000000000..812bcd9b30f3bce77b4e68fbd66d43a7cbba6bd4 GIT binary patch literal 151363 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 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 diff --git a/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_audio_result.json b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_audio_result.json new file mode 100644 index 0000000000..86227f3a45 --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_audio_result.json @@ -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": {} + } + ] +} diff --git a/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_image_result.json b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_image_result.json new file mode 100644 index 0000000000..0e86ef4354 --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_image_result.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_invoice_result.json b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_invoice_result.json new file mode 100644 index 0000000000..076649f0dd --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_invoice_result.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_pdf_result.json b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_pdf_result.json new file mode 100644 index 0000000000..d5671616f9 --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_pdf_result.json @@ -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\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\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\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\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" + } + ] +} diff --git a/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_video_result.json b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_video_result.json new file mode 100644 index 0000000000..e9834fa955 --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/fixtures/analyze_video_result.json @@ -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\nWelcome to the Contoso Product Demo.\n\n00:05.000 --> 00:14.000\nToday 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\nAs you can see on the dashboard, the system provides real-time monitoring of all deployed resources.\n\n00:25.000 --> 00:35.000\nKey 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\nVisit 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." + } + } + } + ] +} diff --git a/python/packages/azure-contentunderstanding/tests/cu/test_context_provider.py b/python/packages/azure-contentunderstanding/tests/cu/test_context_provider.py new file mode 100644 index 0000000000..0e0dae439f --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/test_context_provider.py @@ -0,0 +1,2049 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import base64 +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from agent_framework import Content, Message, SessionContext +from agent_framework._sessions import AgentSession +from azure.ai.contentunderstanding.models import AnalysisResult + +from agent_framework_azure_contentunderstanding import ( + ContentUnderstandingContextProvider, + DocumentStatus, +) +from agent_framework_azure_contentunderstanding._detection import SUPPORTED_MEDIA_TYPES, derive_doc_key +from agent_framework_azure_contentunderstanding._extraction import format_result + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SAMPLE_PDF_BYTES = b"%PDF-1.4 fake content for testing" + + +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) + 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") + 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 + + +def _make_data_uri(data: bytes, media_type: str) -> str: + return f"data:{media_type};base64,{base64.b64encode(data).decode('ascii')}" + + +def _make_content_from_data(data: bytes, media_type: str, filename: str | None = None) -> Content: + props = {"filename": filename} if filename else None + return Content.from_data(data, media_type, additional_properties=props) + + +def _make_context(messages: list[Message]) -> SessionContext: + return SessionContext(input_messages=messages) + + +def _make_provider( + mock_client: AsyncMock | None = None, + **kwargs: Any, +) -> ContentUnderstandingContextProvider: + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + **kwargs, + ) + if mock_client: + provider._client = mock_client # type: ignore[assignment] + return provider + + +def _make_mock_agent() -> MagicMock: + return MagicMock() + + +# =========================================================================== +# Test Classes +# =========================================================================== + + +class TestInit: + def test_default_values(self) -> None: + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + ) + assert provider.analyzer_id is None + assert provider.max_wait == 5.0 + assert provider.output_sections == ["markdown", "fields"] + assert provider.source_id == "azure_contentunderstanding" + + def test_custom_values(self) -> None: + provider = ContentUnderstandingContextProvider( + endpoint="https://custom.cognitiveservices.azure.com/", + credential=AsyncMock(), + analyzer_id="prebuilt-invoice", + max_wait=10.0, + output_sections=["markdown"], + source_id="custom_cu", + ) + assert provider.analyzer_id == "prebuilt-invoice" + assert provider.max_wait == 10.0 + assert provider.output_sections == ["markdown"] + assert provider.source_id == "custom_cu" + + def test_max_wait_none(self) -> None: + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + max_wait=None, + ) + assert provider.max_wait is None + + def test_endpoint_from_env_var(self, monkeypatch: Any) -> None: + """Endpoint can be loaded from AZURE_CONTENTUNDERSTANDING_ENDPOINT env var.""" + monkeypatch.setenv( + "AZURE_CONTENTUNDERSTANDING_ENDPOINT", + "https://env-test.cognitiveservices.azure.com/", + ) + provider = ContentUnderstandingContextProvider(credential=AsyncMock()) + assert provider._endpoint == "https://env-test.cognitiveservices.azure.com/" + + def test_explicit_endpoint_overrides_env_var(self, monkeypatch: Any) -> None: + """Explicit endpoint kwarg takes priority over env var.""" + monkeypatch.setenv( + "AZURE_CONTENTUNDERSTANDING_ENDPOINT", + "https://env-test.cognitiveservices.azure.com/", + ) + provider = ContentUnderstandingContextProvider( + endpoint="https://explicit.cognitiveservices.azure.com/", + credential=AsyncMock(), + ) + assert provider._endpoint == "https://explicit.cognitiveservices.azure.com/" + + def test_missing_endpoint_raises(self) -> None: + """Missing endpoint (no kwarg, no env var) raises an error.""" + # Clear env var to ensure load_settings raises + import os + + import pytest as _pytest + from agent_framework.exceptions import SettingNotFoundError + + env_key = "AZURE_CONTENTUNDERSTANDING_ENDPOINT" + old_val = os.environ.pop(env_key, None) + try: + with _pytest.raises(SettingNotFoundError, match="endpoint"): + ContentUnderstandingContextProvider(credential=AsyncMock()) + finally: + if old_val is not None: + os.environ[env_key] = old_val + + def test_missing_credential_raises(self) -> None: + """Missing credential raises ValueError.""" + import pytest as _pytest + + with _pytest.raises(ValueError, match="credential is required"): + ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + ) + + +class TestAsyncContextManager: + async def test_aenter_returns_self(self) -> None: + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + ) + result = await provider.__aenter__() + assert result is provider + await provider.__aexit__(None, None, None) + + async def test_aexit_closes_client(self) -> None: + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + ) + mock_client = AsyncMock() + provider._client = mock_client # type: ignore[assignment] + await provider.__aexit__(None, None, None) + mock_client.close.assert_called_once() + + +class TestBeforeRunNewFile: + async def test_single_pdf_analyzed( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("What's on this invoice?"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "invoice.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Document should be in state + assert "documents" in state + assert "invoice.pdf" in state["documents"] + assert state["documents"]["invoice.pdf"]["status"] == DocumentStatus.READY + + # Binary should be stripped from input + for m in context.input_messages: + for c in m.contents: + assert c.media_type != "application/pdf" + + # Context should have messages injected + assert len(context.context_messages) > 0 + + async def test_url_input_analyzed( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this document"), + Content.from_uri("https://example.com/report.pdf", media_type="application/pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # URL input should use begin_analyze + mock_cu_client.begin_analyze.assert_called_once() + assert "report.pdf" in state["documents"] + assert state["documents"]["report.pdf"]["status"] == DocumentStatus.READY + + async def test_text_only_skipped(self, mock_cu_client: AsyncMock) -> None: + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message(role="user", contents=[Content.from_text("What's the weather?")]) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # No CU calls + mock_cu_client.begin_analyze.assert_not_called() + mock_cu_client.begin_analyze_binary.assert_not_called() + # No documents + assert state.get("documents", {}) == {} + + +class TestBeforeRunMultiFile: + async def test_two_files_both_analyzed( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + image_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock( + side_effect=[ + _make_mock_poller(pdf_analysis_result), + _make_mock_poller(image_analysis_result), + ] + ) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Compare these documents"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc1.pdf"), + _make_content_from_data(b"\x89PNG fake", "image/png", "chart.png"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert len(state["documents"]) == 2 + assert state["documents"]["doc1.pdf"]["status"] == DocumentStatus.READY + assert state["documents"]["chart.png"]["status"] == DocumentStatus.READY + + +class TestBeforeRunTimeout: + async def test_exceeds_max_wait_defers_to_background( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_slow_poller(pdf_analysis_result, delay=10.0)) + provider = _make_provider(mock_client=mock_cu_client, max_wait=0.1) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "big_doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["big_doc.pdf"]["status"] == DocumentStatus.ANALYZING + assert "big_doc.pdf" in state.get("_pending_tokens", {}) + token_info = state["_pending_tokens"]["big_doc.pdf"] + assert "continuation_token" in token_info + assert "analyzer_id" in token_info + + # Context messages should mention analyzing + assert any("being analyzed" in m.text for msgs in context.context_messages.values() for m in msgs) + + +class TestBeforeRunPendingResolution: + async def test_pending_completes_on_next_turn( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + # Mock begin_analyze to return a completed poller when called with continuation_token + mock_poller = _make_mock_poller(pdf_analysis_result) + mock_poller.done = MagicMock(return_value=True) + mock_cu_client.begin_analyze = AsyncMock(return_value=mock_poller) + provider = _make_provider(mock_client=mock_cu_client) + + state: dict[str, Any] = { + "_pending_tokens": { + "report.pdf": {"continuation_token": "tok_123", "analyzer_id": "prebuilt-documentSearch"} + }, + "documents": { + "report.pdf": { + "status": DocumentStatus.ANALYZING, + "filename": "report.pdf", + "media_type": "application/pdf", + "analyzer_id": "prebuilt-documentSearch", + "analyzed_at": None, + "analysis_duration_s": None, + "upload_duration_s": None, + "result": None, + "error": None, + }, + }, + } + + msg = Message(role="user", contents=[Content.from_text("Is the report ready?")]) + context = _make_context([msg]) + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["report.pdf"]["status"] == DocumentStatus.READY + assert state["documents"]["report.pdf"]["result"] is not None + assert "report.pdf" not in state.get("_pending_tokens", {}) + + +class TestBeforeRunPendingFailure: + async def test_pending_task_failure_updates_state( + self, + mock_cu_client: AsyncMock, + ) -> None: + # Mock begin_analyze to raise when resuming from continuation token + mock_cu_client.begin_analyze = AsyncMock(side_effect=RuntimeError("CU service unavailable")) + provider = _make_provider(mock_client=mock_cu_client) + + state: dict[str, Any] = { + "_pending_tokens": { + "bad_doc.pdf": {"continuation_token": "tok_fail", "analyzer_id": "prebuilt-documentSearch"} + }, + "documents": { + "bad_doc.pdf": { + "status": DocumentStatus.ANALYZING, + "filename": "bad_doc.pdf", + "media_type": "application/pdf", + "analyzer_id": "prebuilt-documentSearch", + "analyzed_at": None, + "analysis_duration_s": None, + "upload_duration_s": None, + "result": None, + "error": None, + }, + }, + } + + msg = Message(role="user", contents=[Content.from_text("Check status")]) + context = _make_context([msg]) + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["bad_doc.pdf"]["status"] == DocumentStatus.FAILED + assert "CU service unavailable" in (state["documents"]["bad_doc.pdf"]["error"] or "") + + +class TestDocumentKeyDerivation: + def test_filename_from_additional_properties(self) -> None: + content = _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "my_report.pdf") + key = derive_doc_key(content) + assert key == "my_report.pdf" + + def test_url_basename(self) -> None: + content = Content.from_uri("https://example.com/docs/annual_report.pdf", media_type="application/pdf") + key = derive_doc_key(content) + assert key == "annual_report.pdf" + + def test_content_hash_fallback(self) -> None: + content = Content.from_data(_SAMPLE_PDF_BYTES, "application/pdf") + key = derive_doc_key(content) + assert key.startswith("doc_") + assert len(key) == 12 # "doc_" + 8 hex chars + + +class TestSessionState: + async def test_documents_persist_across_turns( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + state: dict[str, Any] = {} + session = AgentSession() + + # Turn 1: upload + msg1 = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + ctx1 = _make_context([msg1]) + await provider.before_run(agent=_make_mock_agent(), session=session, context=ctx1, state=state) + + assert "doc.pdf" in state["documents"] + + # Turn 2: follow-up (no file) + msg2 = Message(role="user", contents=[Content.from_text("What's the total?")]) + ctx2 = _make_context([msg2]) + await provider.before_run(agent=_make_mock_agent(), session=session, context=ctx2, state=state) + + # Document should still be there + assert "doc.pdf" in state["documents"] + assert state["documents"]["doc.pdf"]["status"] == DocumentStatus.READY + + +class TestListDocumentsTool: + async def test_returns_all_docs_with_status( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + state: dict[str, Any] = {} + session = AgentSession() + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "test.pdf"), + ], + ) + context = _make_context([msg]) + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Find the list_documents tool + list_tool = None + for tool in context.tools: + if getattr(tool, "name", None) == "list_documents": + list_tool = tool + break + + assert list_tool is not None + result = list_tool.func() # type: ignore[union-attr] + parsed = json.loads(result) + assert len(parsed) == 1 + assert parsed[0]["name"] == "test.pdf" + assert parsed[0]["status"] == DocumentStatus.READY + + +class TestOutputFiltering: + def test_default_markdown_and_fields(self, pdf_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(pdf_analysis_result) + + assert "markdown" in result + assert "fields" in result + assert "Contoso" in str(result["markdown"]) + + def test_markdown_only(self, pdf_analysis_result: AnalysisResult) -> None: + provider = _make_provider(output_sections=["markdown"]) + result = provider._extract_sections(pdf_analysis_result) + + assert "markdown" in result + assert "fields" not in result + + def test_fields_only(self, invoice_analysis_result: AnalysisResult) -> None: + provider = _make_provider(output_sections=["fields"]) + result = provider._extract_sections(invoice_analysis_result) + + assert "markdown" not in result + assert "fields" in result + fields = result["fields"] + assert isinstance(fields, dict) + assert "VendorName" in fields + + def test_field_values_extracted(self, invoice_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(invoice_analysis_result) + + fields = result.get("fields") + assert isinstance(fields, dict) + assert "VendorName" in fields + assert fields["VendorName"]["value"] is not None + assert fields["VendorName"]["confidence"] is not None + + def test_invoice_field_extraction_matches_expected(self, invoice_analysis_result: AnalysisResult) -> None: + """Full invoice field extraction should match expected JSON structure. + + This test defines the complete expected output for all fields in the + invoice fixture, making it easy to review the extraction behavior at + a glance. Confidence is only present when the CU service provides it. + """ + provider = _make_provider() + result = provider._extract_sections(invoice_analysis_result) + fields = result.get("fields") + + expected_fields = { + "VendorName": { + "type": "string", + "value": "TechServe Global Partners", + "confidence": 0.71, + }, + "DueDate": { + "type": "date", + # SDK .value returns datetime.date for date fields + "value": fields["DueDate"]["value"], # dynamic — date object + "confidence": 0.793, + }, + "InvoiceDate": { + "type": "date", + "value": fields["InvoiceDate"]["value"], + "confidence": 0.693, + }, + "InvoiceId": { + "type": "string", + "value": "INV-100", + "confidence": 0.489, + }, + "AmountDue": { + "type": "object", + # No confidence — object types don't have it + "value": { + "Amount": {"type": "number", "value": 610.0, "confidence": 0.758}, + "CurrencyCode": {"type": "string", "value": "USD"}, + }, + }, + "SubtotalAmount": { + "type": "object", + "value": { + "Amount": {"type": "number", "value": 100.0, "confidence": 0.902}, + "CurrencyCode": {"type": "string", "value": "USD"}, + }, + }, + "LineItems": { + "type": "array", + "value": [ + { + "type": "object", + "value": { + "Description": {"type": "string", "value": "Consulting Services", "confidence": 0.664}, + "Quantity": {"type": "number", "value": 2.0, "confidence": 0.957}, + "UnitPrice": { + "type": "object", + "value": { + "Amount": {"type": "number", "value": 30.0, "confidence": 0.956}, + "CurrencyCode": {"type": "string", "value": "USD"}, + }, + }, + }, + }, + { + "type": "object", + "value": { + "Description": {"type": "string", "value": "Document Fee", "confidence": 0.712}, + "Quantity": {"type": "number", "value": 3.0, "confidence": 0.939}, + }, + }, + ], + }, + } + + assert fields == expected_fields + + +class TestDuplicateDocumentKey: + async def test_duplicate_filename_rejected( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Uploading the same filename twice in the same session should reject the second.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + # Turn 1: upload invoice.pdf + msg1 = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "invoice.pdf"), + ], + ) + context1 = _make_context([msg1]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context1, state=state) + assert "invoice.pdf" in state["documents"] + assert state["documents"]["invoice.pdf"]["status"] == DocumentStatus.READY + + # Turn 2: upload invoice.pdf again (different content but same filename) + msg2 = Message( + role="user", + contents=[ + Content.from_text("Analyze this too"), + _make_content_from_data(b"different-content", "application/pdf", "invoice.pdf"), + ], + ) + context2 = _make_context([msg2]) + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context2, state=state) + + # Should still have only one document, not re-analyzed + assert mock_cu_client.begin_analyze_binary.call_count == 1 + # Context messages should mention duplicate + assert any("already uploaded" in m.text for msgs in context2.context_messages.values() for m in msgs) + + async def test_duplicate_in_same_turn_rejected( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Two files with the same filename in the same turn: first wins, second rejected.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze both"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "report.pdf"), + _make_content_from_data(b"other-content", "application/pdf", "report.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Only analyzed once (first one wins) + assert mock_cu_client.begin_analyze_binary.call_count == 1 + assert "report.pdf" in state["documents"] + assert any("already uploaded" in m.text for msgs in context.context_messages.values() for m in msgs) + + +class TestBinaryStripping: + async def test_supported_files_stripped( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("What's in here?"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # PDF should be stripped; text should remain + for m in context.input_messages: + for c in m.contents: + assert c.media_type != "application/pdf" + assert any(c.text and "What's in here?" in c.text for c in m.contents) + + async def test_unsupported_files_left_in_place(self, mock_cu_client: AsyncMock) -> None: + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("What's in this zip?"), + Content.from_data( + b"PK\x03\x04fake", + "application/zip", + additional_properties={"filename": "archive.zip"}, + ), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Zip should NOT be stripped (unsupported) + found_zip = False + for m in context.input_messages: + for c in m.contents: + if c.media_type == "application/zip": + found_zip = True + assert found_zip + + +# Real magic-byte headers for binary sniffing tests +_MP4_MAGIC = b"\x00\x00\x00\x1cftypisom" + b"\x00" * 250 +_WAV_MAGIC = b"RIFF\x00\x00\x00\x00WAVE" + b"\x00" * 250 +_MP3_MAGIC = b"ID3\x04\x00\x00" + b"\x00" * 250 +_FLAC_MAGIC = b"fLaC\x00\x00\x00\x00" + b"\x00" * 250 +_OGG_MAGIC = b"OggS\x00\x02" + b"\x00" * 250 +_AVI_MAGIC = b"RIFF\x00\x00\x00\x00AVI " + b"\x00" * 250 +_MOV_MAGIC = b"\x00\x00\x00\x14ftypqt " + b"\x00" * 250 + + +class TestMimeSniffing: + """Tests for binary MIME sniffing via filetype when upstream MIME is unreliable.""" + + async def test_octet_stream_mp4_detected_and_stripped( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """MP4 uploaded as application/octet-stream should be sniffed, corrected, and stripped.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("What's in this file?"), + _make_content_from_data(_MP4_MAGIC, "application/octet-stream", "video.mp4"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # MP4 should be stripped from input + for m in context.input_messages: + for c in m.contents: + assert c.media_type != "application/octet-stream", "octet-stream content should be stripped" + + # CU should have been called + assert mock_cu_client.begin_analyze_binary.called + + async def test_octet_stream_wav_detected_via_sniff( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """WAV uploaded as application/octet-stream should be detected via filetype sniffing.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Transcribe"), + _make_content_from_data(_WAV_MAGIC, "application/octet-stream", "audio.wav"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Should be detected and analyzed + assert "audio.wav" in state["documents"] + # The media_type should be corrected to audio/wav (via _MIME_ALIASES) + assert state["documents"]["audio.wav"]["media_type"] == "audio/wav" + + async def test_octet_stream_mp3_detected_via_sniff( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """MP3 uploaded as application/octet-stream should be detected as audio/mpeg.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Transcribe"), + _make_content_from_data(_MP3_MAGIC, "application/octet-stream", "song.mp3"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert "song.mp3" in state["documents"] + assert state["documents"]["song.mp3"]["media_type"] == "audio/mpeg" + + async def test_octet_stream_flac_alias_normalized( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """FLAC sniffed as audio/x-flac should be normalized to audio/flac.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Transcribe"), + _make_content_from_data(_FLAC_MAGIC, "application/octet-stream", "music.flac"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert "music.flac" in state["documents"] + assert state["documents"]["music.flac"]["media_type"] == "audio/flac" + + async def test_octet_stream_unknown_binary_not_stripped( + self, + mock_cu_client: AsyncMock, + ) -> None: + """Unknown binary with application/octet-stream should NOT be stripped.""" + provider = _make_provider(mock_client=mock_cu_client) + + unknown_bytes = b"\x00\x01\x02\x03random garbage" + b"\x00" * 250 + msg = Message( + role="user", + contents=[ + Content.from_text("What is this?"), + _make_content_from_data(unknown_bytes, "application/octet-stream", "mystery.bin"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Unknown file should NOT be stripped + found_octet = False + for m in context.input_messages: + for c in m.contents: + if c.media_type == "application/octet-stream": + found_octet = True + assert found_octet + + async def test_missing_mime_falls_back_to_filename( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Content with empty MIME but a .mp4 filename should be detected via mimetypes fallback.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + # Use garbage binary (filetype won't detect) but filename has .mp4 + garbage = b"\x00" * 300 + content = Content.from_data(garbage, "", additional_properties={"filename": "recording.mp4"}) + msg = Message( + role="user", + contents=[Content.from_text("Analyze"), content], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Should be detected via filename and analyzed + assert "recording.mp4" in state["documents"] + + async def test_correct_mime_not_sniffed( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Files with correct MIME type should go through fast path without sniffing.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert "doc.pdf" in state["documents"] + assert state["documents"]["doc.pdf"]["media_type"] == "application/pdf" + + async def test_sniffed_video_uses_correct_analyzer( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """MP4 sniffed from octet-stream should use prebuilt-videoSearch analyzer.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) # analyzer_id=None → auto-detect + + msg = Message( + role="user", + contents=[ + Content.from_text("What's in this video?"), + _make_content_from_data(_MP4_MAGIC, "application/octet-stream", "demo.mp4"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["demo.mp4"]["analyzer_id"] == "prebuilt-videoSearch" + + +class TestErrorHandling: + async def test_cu_service_error(self, mock_cu_client: AsyncMock) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_failing_poller(RuntimeError("Service unavailable")) + ) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "error.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["error.pdf"]["status"] == DocumentStatus.FAILED + assert "Service unavailable" in (state["documents"]["error.pdf"]["error"] or "") + + async def test_lazy_initialization_on_before_run(self) -> None: + """before_run works with eagerly-initialized client.""" + provider = ContentUnderstandingContextProvider( + endpoint="https://test.cognitiveservices.azure.com/", + credential=AsyncMock(), + ) + assert provider._client is not None + + mock_client = AsyncMock() + mock_client.begin_analyze_binary = AsyncMock( + side_effect=Exception("mock error"), + ) + provider._client = mock_client # type: ignore[assignment] + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + # Client should still be set + assert provider._client is not None + + +class TestMultiModalFixtures: + def test_pdf_fixture_loads(self, pdf_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(pdf_analysis_result) + assert "markdown" in result + assert "Contoso" in str(result["markdown"]) + + def test_audio_fixture_loads(self, audio_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(audio_analysis_result) + assert "markdown" in result + assert "Call Center" in str(result["markdown"]) + + def test_video_fixture_loads(self, video_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(video_analysis_result) + assert "markdown" in result + # All 3 segments should be concatenated at top level (for file_search) + md = str(result["markdown"]) + assert "Contoso Product Demo" in md + assert "real-time monitoring" in md + assert "contoso.com/cloud-manager" in md + # Duration should span all segments: (42000 - 1000) / 1000 = 41.0 + assert result.get("duration_seconds") == 41.0 + # kind from first segment + assert result.get("kind") == "audioVisual" + # resolution from first segment + assert result.get("resolution") == "640x480" + # Multi-segment: fields should be in per-segment list, not merged at top level + assert "fields" not in result # no top-level fields for multi-segment + segments = result.get("segments") + assert isinstance(segments, list) + assert len(segments) == 3 + # Each segment should have its own fields and time range + seg0 = segments[0] + assert "fields" in seg0 + assert "Summary" in seg0["fields"] + assert seg0.get("start_time_s") == 1.0 + assert seg0.get("end_time_s") == 14.0 + seg2 = segments[2] + assert "fields" in seg2 + assert "Summary" in seg2["fields"] + assert seg2.get("start_time_s") == 36.0 + assert seg2.get("end_time_s") == 42.0 + + def test_image_fixture_loads(self, image_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(image_analysis_result) + assert "markdown" in result + + def test_invoice_fixture_loads(self, invoice_analysis_result: AnalysisResult) -> None: + provider = _make_provider() + result = provider._extract_sections(invoice_analysis_result) + assert "markdown" in result + assert "fields" in result + fields = result["fields"] + assert isinstance(fields, dict) + assert "VendorName" in fields + # Single-segment: should NOT have segments key + assert "segments" not in result + + +class TestFormatResult: + def test_format_includes_markdown_and_fields(self) -> None: + result: dict[str, object] = { + "markdown": "# Hello World", + "fields": {"Name": {"type": "string", "value": "Test", "confidence": 0.9}}, + } + formatted = format_result("test.pdf", result) + + assert 'Document analysis of "test.pdf"' in formatted + assert "# Hello World" in formatted + assert "Extracted Fields" in formatted + assert '"Name"' in formatted + + def test_format_markdown_only(self) -> None: + result: dict[str, object] = {"markdown": "# Just Text"} + formatted = format_result("doc.pdf", result) + + assert "# Just Text" in formatted + assert "Extracted Fields" not in formatted + + def test_format_multi_segment_video(self) -> None: + """Multi-segment results should format each segment with its own content + fields.""" + result: dict[str, object] = { + "kind": "audioVisual", + "duration_seconds": 41.0, + "resolution": "640x480", + "markdown": "scene1\n\n---\n\nscene2", # concatenated for file_search + "segments": [ + { + "start_time_s": 1.0, + "end_time_s": 14.0, + "markdown": "Welcome to the Contoso demo.", + "fields": { + "Summary": {"type": "string", "value": "Product intro"}, + "Speakers": { + "type": "object", + "value": {"count": 1, "names": ["Host"]}, + }, + }, + }, + { + "start_time_s": 15.0, + "end_time_s": 31.0, + "markdown": "Here we show real-time monitoring.", + "fields": { + "Summary": {"type": "string", "value": "Feature walkthrough"}, + "Speakers": { + "type": "object", + "value": {"count": 2, "names": ["Host", "Engineer"]}, + }, + }, + }, + ], + } + formatted = format_result("demo.mp4", result) + + expected = ( + 'Video analysis of "demo.mp4":\n' + "Duration: 0:41 | Resolution: 640x480\n" + "\n### Segment 1 (0:01 - 0:14)\n" + "\n```markdown\nWelcome to the Contoso demo.\n```\n" + "\n**Fields:**\n```json\n" + "{\n" + ' "Summary": {\n' + ' "type": "string",\n' + ' "value": "Product intro"\n' + " },\n" + ' "Speakers": {\n' + ' "type": "object",\n' + ' "value": {\n' + ' "count": 1,\n' + ' "names": [\n' + ' "Host"\n' + " ]\n" + " }\n" + " }\n" + "}\n```\n" + "\n### Segment 2 (0:15 - 0:31)\n" + "\n```markdown\nHere we show real-time monitoring.\n```\n" + "\n**Fields:**\n```json\n" + "{\n" + ' "Summary": {\n' + ' "type": "string",\n' + ' "value": "Feature walkthrough"\n' + " },\n" + ' "Speakers": {\n' + ' "type": "object",\n' + ' "value": {\n' + ' "count": 2,\n' + ' "names": [\n' + ' "Host",\n' + ' "Engineer"\n' + " ]\n" + " }\n" + " }\n" + "}\n```" + ) + assert formatted == expected + + # Verify ordering: segment 1 markdown+fields appear before segment 2 + seg1_pos = formatted.index("Segment 1") + seg2_pos = formatted.index("Segment 2") + contoso_pos = formatted.index("Welcome to the Contoso demo.") + monitoring_pos = formatted.index("Here we show real-time monitoring.") + intro_pos = formatted.index("Product intro") + walkthrough_pos = formatted.index("Feature walkthrough") + host_only_pos = formatted.index('"count": 1') + host_engineer_pos = formatted.index('"count": 2') + assert ( + seg1_pos + < contoso_pos + < intro_pos + < host_only_pos + < seg2_pos + < monitoring_pos + < walkthrough_pos + < host_engineer_pos + ) + + def test_format_single_segment_no_segments_key(self) -> None: + """Single-segment results should NOT have segments key — flat format.""" + result: dict[str, object] = { + "kind": "document", + "markdown": "# Invoice content", + "fields": { + "VendorName": {"type": "string", "value": "Contoso", "confidence": 0.95}, + "ShippingAddress": { + "type": "object", + "value": {"street": "123 Main St", "city": "Redmond", "state": "WA"}, + "confidence": 0.88, + }, + }, + } + formatted = format_result("invoice.pdf", result) + + expected = ( + 'Document analysis of "invoice.pdf":\n' + "\n## Content\n\n" + "```markdown\n# Invoice content\n```\n" + "\n## Extracted Fields\n\n" + "```json\n" + "{\n" + ' "VendorName": {\n' + ' "type": "string",\n' + ' "value": "Contoso",\n' + ' "confidence": 0.95\n' + " },\n" + ' "ShippingAddress": {\n' + ' "type": "object",\n' + ' "value": {\n' + ' "street": "123 Main St",\n' + ' "city": "Redmond",\n' + ' "state": "WA"\n' + " },\n" + ' "confidence": 0.88\n' + " }\n" + "}\n" + "```" + ) + assert formatted == expected + + # Verify ordering: header → markdown content → fields + header_pos = formatted.index('Document analysis of "invoice.pdf"') + content_header_pos = formatted.index("## Content") + markdown_pos = formatted.index("# Invoice content") + fields_header_pos = formatted.index("## Extracted Fields") + vendor_pos = formatted.index("Contoso") + address_pos = formatted.index("ShippingAddress") + street_pos = formatted.index("123 Main St") + assert ( + header_pos < content_header_pos < markdown_pos < fields_header_pos < vendor_pos < address_pos < street_pos + ) + + +class TestSupportedMediaTypes: + def test_pdf_supported(self) -> None: + assert "application/pdf" in SUPPORTED_MEDIA_TYPES + + def test_audio_supported(self) -> None: + assert "audio/mp3" in SUPPORTED_MEDIA_TYPES + assert "audio/wav" in SUPPORTED_MEDIA_TYPES + + def test_video_supported(self) -> None: + assert "video/mp4" in SUPPORTED_MEDIA_TYPES + + def test_zip_not_supported(self) -> None: + assert "application/zip" not in SUPPORTED_MEDIA_TYPES + + +class TestAnalyzerAutoDetection: + """Verify _resolve_analyzer_id auto-selects the right analyzer by media type.""" + + def test_explicit_analyzer_always_wins(self) -> None: + provider = _make_provider(analyzer_id="prebuilt-invoice") + assert provider._resolve_analyzer_id("audio/mp3") == "prebuilt-invoice" + assert provider._resolve_analyzer_id("video/mp4") == "prebuilt-invoice" + assert provider._resolve_analyzer_id("application/pdf") == "prebuilt-invoice" + + def test_auto_detect_pdf(self) -> None: + provider = _make_provider() # analyzer_id=None + assert provider._resolve_analyzer_id("application/pdf") == "prebuilt-documentSearch" + + def test_auto_detect_image(self) -> None: + provider = _make_provider() + assert provider._resolve_analyzer_id("image/jpeg") == "prebuilt-documentSearch" + assert provider._resolve_analyzer_id("image/png") == "prebuilt-documentSearch" + + def test_auto_detect_audio(self) -> None: + provider = _make_provider() + assert provider._resolve_analyzer_id("audio/mp3") == "prebuilt-audioSearch" + assert provider._resolve_analyzer_id("audio/wav") == "prebuilt-audioSearch" + assert provider._resolve_analyzer_id("audio/mpeg") == "prebuilt-audioSearch" + + def test_auto_detect_video(self) -> None: + provider = _make_provider() + assert provider._resolve_analyzer_id("video/mp4") == "prebuilt-videoSearch" + assert provider._resolve_analyzer_id("video/webm") == "prebuilt-videoSearch" + + def test_auto_detect_unknown_falls_back_to_document(self) -> None: + provider = _make_provider() + assert provider._resolve_analyzer_id("application/octet-stream") == "prebuilt-documentSearch" + + +class TestFileSearchIntegration: + _FILE_SEARCH_TOOL = {"type": "file_search", "vector_store_ids": ["vs_test123"]} + + def _make_mock_backend(self) -> AsyncMock: + """Create a mock FileSearchBackend.""" + backend = AsyncMock() + backend.upload_file = AsyncMock(return_value="file_test456") + backend.delete_file = AsyncMock() + return backend + + def _make_file_search_config(self, backend: AsyncMock | None = None) -> Any: + from agent_framework_azure_contentunderstanding import FileSearchConfig + + return FileSearchConfig( + backend=backend or self._make_mock_backend(), + vector_store_id="vs_test123", + file_search_tool=self._FILE_SEARCH_TOOL, + ) + + async def test_file_search_uploads_to_vector_store( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_backend = self._make_mock_backend() + config = self._make_file_search_config(mock_backend) + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=config, + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run( + agent=_make_mock_agent(), + session=session, + context=context, + state=state, + ) + + # File should be uploaded via backend + mock_backend.upload_file.assert_called_once() + call_args = mock_backend.upload_file.call_args + assert call_args[0][0] == "vs_test123" # vector_store_id + assert call_args[0][1] == "doc.pdf.md" # filename + # file_search tool should be registered on context + assert self._FILE_SEARCH_TOOL in context.tools + + async def test_file_search_no_content_injection( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """When file_search is enabled, full content should NOT be injected into context.""" + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=self._make_file_search_config(), + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run( + agent=_make_mock_agent(), + session=session, + context=context, + state=state, + ) + + # Context messages should NOT contain full document content + # (file_search handles retrieval instead) + for msgs in context.context_messages.values(): + for m in msgs: + assert "Document Content" not in m.text + + async def test_cleanup_deletes_uploaded_files( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_backend = self._make_mock_backend() + config = self._make_file_search_config(mock_backend) + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=config, + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run( + agent=_make_mock_agent(), + session=session, + context=context, + state=state, + ) + + # Close should clean up uploaded files (not the vector store itself) + await provider.close() + mock_backend.delete_file.assert_called_once_with("file_test456") + + async def test_no_file_search_injects_content( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Without file_search, full content should be injected (default behavior).""" + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run( + agent=_make_mock_agent(), + session=session, + context=context, + state=state, + ) + + # Without file_search, content SHOULD be injected + found_content = False + for msgs in context.context_messages.values(): + for m in msgs: + if "Document Content" in m.text or "Contoso" in m.text: + found_content = True + assert found_content + + async def test_file_search_multiple_files( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + audio_analysis_result: AnalysisResult, + ) -> None: + """Multiple files should each be uploaded to the vector store.""" + mock_backend = self._make_mock_backend() + # Return different file IDs for each upload + mock_backend.upload_file = AsyncMock(side_effect=["file_001", "file_002"]) + config = self._make_file_search_config(mock_backend) + mock_cu_client.begin_analyze_binary = AsyncMock( + side_effect=[ + _make_mock_poller(pdf_analysis_result), + _make_mock_poller(audio_analysis_result), + ], + ) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=config, + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Compare these"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "doc.pdf"), + _make_content_from_data(b"\x00audio-fake", "audio/mp3", "call.mp3"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Two files uploaded via backend + assert mock_backend.upload_file.call_count == 2 + + async def test_file_search_skips_empty_markdown( + self, + mock_cu_client: AsyncMock, + ) -> None: + """Upload should be skipped when CU returns no markdown content.""" + mock_backend = self._make_mock_backend() + config = self._make_file_search_config(mock_backend) + + # Create a result with empty markdown + empty_result = AnalysisResult({"contents": [{"markdown": "", "fields": {}}]}) + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(empty_result), + ) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=config, + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "empty.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # No file should be uploaded (empty markdown) + mock_backend.upload_file.assert_not_called() + + async def test_pending_resolution_uploads_to_vector_store( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """When a background task completes in file_search mode, content should be + uploaded to the vector store — NOT injected into context messages.""" + mock_backend = self._make_mock_backend() + config = self._make_file_search_config(mock_backend) + provider = _make_provider( + mock_client=mock_cu_client, + file_search=config, + ) + + # Simulate a completed background analysis via continuation token + mock_poller = _make_mock_poller(pdf_analysis_result) + mock_poller.done = MagicMock(return_value=True) + mock_cu_client.begin_analyze = AsyncMock(return_value=mock_poller) + + state: dict[str, Any] = { + "_pending_tokens": { + "report.pdf": {"continuation_token": "tok_fs", "analyzer_id": "prebuilt-documentSearch"} + }, + "documents": { + "report.pdf": { + "status": DocumentStatus.ANALYZING, + "filename": "report.pdf", + "media_type": "application/pdf", + "analyzer_id": "prebuilt-documentSearch", + "analyzed_at": None, + "analysis_duration_s": None, + "upload_duration_s": None, + "result": None, + "error": None, + }, + }, + } + + msg = Message(role="user", contents=[Content.from_text("Is the report ready?")]) + context = _make_context([msg]) + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Document should be ready + assert state["documents"]["report.pdf"]["status"] == DocumentStatus.READY + + # Content should NOT be injected into context messages + for msgs in context.context_messages.values(): + for m in msgs: + assert "Document Content" not in m.text + + # Should be uploaded to vector store via backend + mock_backend.upload_file.assert_called_once() + + # Context messages should mention file_search, not "provided above" + all_msg_text = " ".join(m.text for msgs in context.context_messages.values() for m in msgs) + assert "file_search" in all_msg_text or any("file_search" in instr for instr in context.instructions) + assert "provided above" not in all_msg_text + + +class TestCloseCancel: + async def test_close_cleans_up(self) -> None: + """close() should close the CU client.""" + provider = _make_provider(mock_client=AsyncMock()) + + await provider.close() + + # Client should be closed (no tasks to cancel — tokens are just strings) + provider._client.close.assert_called_once() + + +class TestSessionIsolation: + """Verify that per-session state (pending tasks, uploads) is isolated between sessions.""" + + async def test_background_task_isolated_per_session( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """A background task from session A must not leak into session B.""" + mock_cu_client.begin_analyze_binary = AsyncMock(return_value=_make_slow_poller(pdf_analysis_result, delay=10.0)) + provider = _make_provider(mock_client=mock_cu_client, max_wait=0.1) + + # Session A: upload a file that times out → defers to background + msg_a = Message( + role="user", + contents=[ + Content.from_text("Analyze this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "report.pdf"), + ], + ) + state_a: dict[str, Any] = {} + context_a = _make_context([msg_a]) + await provider.before_run(agent=_make_mock_agent(), session=AgentSession(), context=context_a, state=state_a) + + # Session A should have a pending token + assert "report.pdf" in state_a.get("_pending_tokens", {}) + + # Session B: separate state, no pending tokens + state_b: dict[str, Any] = {} + msg_b = Message(role="user", contents=[Content.from_text("Hello")]) + context_b = _make_context([msg_b]) + await provider.before_run(agent=_make_mock_agent(), session=AgentSession(), context=context_b, state=state_b) + + # Session B must NOT see session A's pending token + assert "_pending_tokens" not in state_b or "report.pdf" not in state_b.get("_pending_tokens", {}) + # Session B must NOT have session A's documents + assert "report.pdf" not in state_b.get("documents", {}) + + async def test_completed_task_resolves_in_correct_session( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """A completed background task should only inject content into its own session.""" + provider = _make_provider(mock_client=mock_cu_client) + + # Simulate completed analysis in session A via continuation token + mock_poller = _make_mock_poller(pdf_analysis_result) + mock_poller.done = MagicMock(return_value=True) + mock_cu_client.begin_analyze = AsyncMock(return_value=mock_poller) + + state_a: dict[str, Any] = { + "_pending_tokens": { + "report.pdf": {"continuation_token": "tok_a", "analyzer_id": "prebuilt-documentSearch"} + }, + "documents": { + "report.pdf": { + "status": DocumentStatus.ANALYZING, + "filename": "report.pdf", + "media_type": "application/pdf", + "analyzer_id": "prebuilt-documentSearch", + "analyzed_at": None, + "analysis_duration_s": None, + "upload_duration_s": None, + "result": None, + "error": None, + }, + }, + } + state_b: dict[str, Any] = {} + + # Run session A — should resolve the task + context_a = _make_context([Message(role="user", contents=[Content.from_text("Is it ready?")])]) + await provider.before_run(agent=_make_mock_agent(), session=AgentSession(), context=context_a, state=state_a) + assert state_a["documents"]["report.pdf"]["status"] == DocumentStatus.READY + + # Run session B — must NOT have any documents or resolved content + context_b = _make_context([Message(role="user", contents=[Content.from_text("Hello")])]) + await provider.before_run(agent=_make_mock_agent(), session=AgentSession(), context=context_b, state=state_b) + assert "report.pdf" not in state_b.get("documents", {}) + # Session B context should have no document-related messages + assert not any("report.pdf" in m.text for msgs in context_b.context_messages.values() for m in msgs) + + +class TestAnalyzerAutoDetectionE2E: + """End-to-end: verify _analyze_file stores the resolved analyzer in DocumentEntry.""" + + async def test_audio_file_uses_audio_analyzer( + self, + mock_cu_client: AsyncMock, + audio_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(audio_analysis_result), + ) + provider = _make_provider(mock_client=mock_cu_client) # analyzer_id=None + + msg = Message( + role="user", + contents=[ + Content.from_text("Transcribe this"), + _make_content_from_data(b"\x00audio", "audio/mp3", "call.mp3"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["call.mp3"]["analyzer_id"] == "prebuilt-audioSearch" + # CU client should have been called with the audio analyzer + mock_cu_client.begin_analyze_binary.assert_called_once() + call_args = mock_cu_client.begin_analyze_binary.call_args + assert call_args[0][0] == "prebuilt-audioSearch" + + async def test_video_file_uses_video_analyzer( + self, + mock_cu_client: AsyncMock, + video_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(video_analysis_result), + ) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze this video"), + _make_content_from_data(b"\x00video", "video/mp4", "demo.mp4"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["demo.mp4"]["analyzer_id"] == "prebuilt-videoSearch" + call_args = mock_cu_client.begin_analyze_binary.call_args + assert call_args[0][0] == "prebuilt-videoSearch" + + async def test_pdf_file_uses_document_analyzer( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Read this"), + _make_content_from_data(_SAMPLE_PDF_BYTES, "application/pdf", "report.pdf"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["report.pdf"]["analyzer_id"] == "prebuilt-documentSearch" + call_args = mock_cu_client.begin_analyze_binary.call_args + assert call_args[0][0] == "prebuilt-documentSearch" + + async def test_explicit_override_ignores_media_type( + self, + mock_cu_client: AsyncMock, + audio_analysis_result: AnalysisResult, + ) -> None: + """Explicit analyzer_id should override auto-detection even for audio.""" + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(audio_analysis_result), + ) + provider = _make_provider(mock_client=mock_cu_client, analyzer_id="prebuilt-invoice") + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze"), + _make_content_from_data(b"\x00audio", "audio/mp3", "call.mp3"), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + assert state["documents"]["call.mp3"]["analyzer_id"] == "prebuilt-invoice" + call_args = mock_cu_client.begin_analyze_binary.call_args + assert call_args[0][0] == "prebuilt-invoice" + + async def test_per_file_analyzer_overrides_provider_default( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """Per-file analyzer_id in additional_properties overrides provider-level default.""" + mock_cu_client.begin_analyze_binary = AsyncMock( + return_value=_make_mock_poller(pdf_analysis_result), + ) + # Provider default is prebuilt-documentSearch + provider = _make_provider( + mock_client=mock_cu_client, + analyzer_id="prebuilt-documentSearch", + ) + + msg = Message( + role="user", + contents=[ + Content.from_text("Process this invoice"), + Content.from_data( + _SAMPLE_PDF_BYTES, + "application/pdf", + # Per-file override to prebuilt-invoice + additional_properties={ + "filename": "invoice.pdf", + "analyzer_id": "prebuilt-invoice", + }, + ), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Per-file override should win + assert state["documents"]["invoice.pdf"]["analyzer_id"] == "prebuilt-invoice" + call_args = mock_cu_client.begin_analyze_binary.call_args + assert call_args[0][0] == "prebuilt-invoice" + + +class TestWarningsExtraction: + """Verify that CU analysis warnings are included in extracted output.""" + + def test_warnings_included_when_present(self) -> None: + """Non-empty warnings list should appear with code/message/target (RAI warnings).""" + provider = _make_provider() + fixture = { + "contents": [ + { + "path": "input1", + "markdown": "Some content", + "kind": "document", + } + ], + "warnings": [ + { + "code": "ContentFiltered", + "message": "Content was filtered due to Responsible AI policy.", + "target": "contents/0/markdown", + }, + { + "code": "ContentFiltered", + "message": "Violence content detected and filtered.", + }, + ], + } + result_obj = AnalysisResult(fixture) + extracted = provider._extract_sections(result_obj) + assert "warnings" in extracted + warnings = extracted["warnings"] + assert isinstance(warnings, list) + assert len(warnings) == 2 + # First warning has code + message + target + assert warnings[0]["code"] == "ContentFiltered" + assert warnings[0]["message"] == "Content was filtered due to Responsible AI policy." + assert warnings[0]["target"] == "contents/0/markdown" + # Second warning has code + message but no target + assert warnings[1]["code"] == "ContentFiltered" + assert warnings[1]["message"] == "Violence content detected and filtered." + assert "target" not in warnings[1] + + def test_warnings_omitted_when_empty(self, pdf_analysis_result: AnalysisResult) -> None: + """Empty/None warnings should not appear in extracted result.""" + provider = _make_provider() + extracted = provider._extract_sections(pdf_analysis_result) + assert "warnings" not in extracted + + +class TestCategoryExtraction: + """Verify that content-level category is included in extracted output.""" + + def test_category_included_single_segment(self) -> None: + """Category from classifier analyzer should appear in single-segment output.""" + provider = _make_provider() + fixture = { + "contents": [ + { + "path": "input1", + "markdown": "Contract text...", + "kind": "document", + "category": "Legal Contract", + } + ], + } + result_obj = AnalysisResult(fixture) + extracted = provider._extract_sections(result_obj) + assert extracted.get("category") == "Legal Contract" + + def test_category_in_multi_segment_video(self) -> None: + """Each segment should carry its own category in multi-segment output.""" + provider = _make_provider() + fixture = { + "contents": [ + { + "path": "input1", + "kind": "audioVisual", + "startTimeMs": 0, + "endTimeMs": 30000, + "markdown": "Opening scene with product showcase.", + "category": "ProductDemo", + "fields": { + "Summary": { + "type": "string", + "valueString": "Product demo intro", + } + }, + }, + { + "path": "input1", + "kind": "audioVisual", + "startTimeMs": 30000, + "endTimeMs": 60000, + "markdown": "Customer testimonial segment.", + "category": "Testimonial", + "fields": { + "Summary": { + "type": "string", + "valueString": "Customer feedback", + } + }, + }, + ], + } + result_obj = AnalysisResult(fixture) + extracted = provider._extract_sections(result_obj) + + # Top-level metadata + assert extracted["kind"] == "audioVisual" + assert extracted["duration_seconds"] == 60.0 + + # Segments should have per-segment category + segments = extracted["segments"] + assert isinstance(segments, list) + assert len(segments) == 2 + + # First segment: ProductDemo + assert segments[0]["category"] == "ProductDemo" + assert segments[0]["start_time_s"] == 0.0 + assert segments[0]["end_time_s"] == 30.0 + assert segments[0]["markdown"] == "Opening scene with product showcase." + assert "Summary" in segments[0]["fields"] + + # Second segment: Testimonial + assert segments[1]["category"] == "Testimonial" + assert segments[1]["start_time_s"] == 30.0 + assert segments[1]["end_time_s"] == 60.0 + assert segments[1]["markdown"] == "Customer testimonial segment." + + # Top-level concatenated markdown for file_search + assert "Opening scene" in extracted["markdown"] + assert "Customer testimonial" in extracted["markdown"] + + def test_category_omitted_when_none(self, pdf_analysis_result: AnalysisResult) -> None: + """No category should be in output when analyzer doesn't classify.""" + provider = _make_provider() + extracted = provider._extract_sections(pdf_analysis_result) + assert "category" not in extracted + + +class TestContentRangeSupport: + """Verify that content_range from additional_properties is passed to CU.""" + + async def test_content_range_passed_to_begin_analyze( + self, + mock_cu_client: AsyncMock, + pdf_analysis_result: AnalysisResult, + ) -> None: + """content_range in additional_properties should be forwarded to AnalysisInput.""" + from azure.ai.contentunderstanding.models import AnalysisInput + + mock_cu_client.begin_analyze = AsyncMock(return_value=_make_mock_poller(pdf_analysis_result)) + provider = _make_provider(mock_client=mock_cu_client) + + msg = Message( + role="user", + contents=[ + Content.from_text("Analyze pages 1-3"), + Content.from_uri( + "https://example.com/report.pdf", + media_type="application/pdf", + additional_properties={"filename": "report.pdf", "content_range": "1-3"}, + ), + ], + ) + context = _make_context([msg]) + state: dict[str, Any] = {} + session = AgentSession() + + await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state) + + # Verify begin_analyze was called with AnalysisInput containing content_range + mock_cu_client.begin_analyze.assert_called_once() + call_kwargs = mock_cu_client.begin_analyze.call_args + inputs_arg = call_kwargs.kwargs.get("inputs") or call_kwargs[1].get("inputs") + assert inputs_arg is not None + assert len(inputs_arg) == 1 + assert isinstance(inputs_arg[0], AnalysisInput) + assert inputs_arg[0].content_range == "1-3" + assert inputs_arg[0].url == "https://example.com/report.pdf" diff --git a/python/packages/azure-contentunderstanding/tests/cu/test_integration.py b/python/packages/azure-contentunderstanding/tests/cu/test_integration.py new file mode 100644 index 0000000000..0e204e2507 --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/test_integration.py @@ -0,0 +1,312 @@ +# 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 + +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: + poller = await client.begin_analyze_binary( + analyzer_id, + binary_input=pdf_bytes, + content_type="application/pdf", + ) + 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, + 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 = state.get("documents", {}) + assert isinstance(docs, dict) + assert "invoice.pdf" in docs + doc_entry = docs["invoice.pdf"] + assert doc_entry["status"] == "ready" + assert doc_entry["result"] is not None + assert doc_entry["result"].get("markdown") + assert len(doc_entry["result"]["markdown"]) > 10 + assert "CONTOSO LTD." in doc_entry["result"]["markdown"] + + +# 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, + 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 = state.get("documents", {}) + assert isinstance(docs, dict) + assert "invoice.pdf" in docs + + doc_entry = docs["invoice.pdf"] + assert doc_entry["status"] == "ready" + assert doc_entry["result"] is not None + assert doc_entry["result"].get("markdown") + assert len(doc_entry["result"]["markdown"]) > 10 + assert "CONTOSO LTD." in doc_entry["result"]["markdown"] + + +@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, + 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 = state.get("documents", {}) + assert isinstance(docs, dict) + assert "invoice_b64.pdf" in docs + + doc_entry = docs["invoice_b64.pdf"] + assert doc_entry["status"] == "ready" + assert doc_entry["result"] is not None + assert doc_entry["result"].get("markdown") + assert len(doc_entry["result"]["markdown"]) > 10 + assert "CONTOSO LTD." in doc_entry["result"]["markdown"] + + +@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, + 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 = 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" + assert docs["invoice.pdf"]["result"] is not None + assert docs["invoice.pdf"]["result"].get("markdown") + assert "CONTOSO LTD." in docs["invoice.pdf"]["result"]["markdown"] diff --git a/python/packages/azure-contentunderstanding/tests/cu/test_models.py b/python/packages/azure-contentunderstanding/tests/cu/test_models.py new file mode 100644 index 0000000000..484645f09a --- /dev/null +++ b/python/packages/azure-contentunderstanding/tests/cu/test_models.py @@ -0,0 +1,67 @@ +# 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": {"markdown": "# 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 + + 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 diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index 79736b5ca7..38354f4c13 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -4,6 +4,7 @@ This module lazily re-exports objects from: - ``agent-framework-anthropic`` +- ``agent-framework-azure-contentunderstanding`` - ``agent-framework-foundry`` - ``agent-framework-foundry-local`` """ @@ -12,7 +13,12 @@ import importlib from typing import Any _IMPORTS: dict[str, tuple[str, str]] = { + "AnalysisSection": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), "AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), + "ContentUnderstandingContextProvider": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), + "DocumentStatus": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), + "FileSearchBackend": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), + "FileSearchConfig": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"), "FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryAgentOptions": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), diff --git a/python/packages/core/agent_framework/foundry/__init__.pyi b/python/packages/core/agent_framework/foundry/__init__.pyi index 87cc7a3bda..7f26d0305d 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -4,6 +4,13 @@ # Install the relevant packages for full type support. from agent_framework_anthropic import AnthropicFoundryClient, RawAnthropicFoundryClient +from agent_framework_azure_contentunderstanding import ( + AnalysisSection, + ContentUnderstandingContextProvider, + DocumentStatus, + FileSearchBackend, + FileSearchConfig, +) from agent_framework_foundry import ( FoundryAgent, FoundryChatClient, @@ -31,7 +38,12 @@ from agent_framework_foundry_local import ( ) __all__ = [ + "AnalysisSection", "AnthropicFoundryClient", + "ContentUnderstandingContextProvider", + "DocumentStatus", + "FileSearchBackend", + "FileSearchConfig", "FoundryAgent", "FoundryChatClient", "FoundryChatOptions", diff --git a/python/pyproject.toml b/python/pyproject.toml index b2ce2ea571..ef611bf4a5 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -92,6 +92,7 @@ agent-framework-openai = { workspace = true } agent-framework-orchestrations = { workspace = true } agent-framework-purview = { workspace = true } agent-framework-redis = { workspace = true } +agent-framework-azure-contentunderstanding = { workspace = true } litellm = { url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl" } [tool.ruff] @@ -190,6 +191,7 @@ executionEnvironments = [ { root = "packages/ag-ui/tests", reportPrivateUsage = "none" }, { root = "packages/anthropic/tests", reportPrivateUsage = "none" }, { root = "packages/azure-ai-search/tests", reportPrivateUsage = "none" }, + { root = "packages/azure-contentunderstanding/tests", reportPrivateUsage = "none" }, { root = "packages/azure-cosmos/tests", reportPrivateUsage = "none" }, { root = "packages/azurefunctions/tests", reportPrivateUsage = "none" }, { root = "packages/bedrock/tests", reportPrivateUsage = "none" }, diff --git a/python/uv.lock b/python/uv.lock index a8ee0ab7f5..4e60e5cfd6 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -31,6 +31,7 @@ members = [ "agent-framework-ag-ui", "agent-framework-anthropic", "agent-framework-azure-ai-search", + "agent-framework-azure-contentunderstanding", "agent-framework-azure-cosmos", "agent-framework-azurefunctions", "agent-framework-bedrock", @@ -222,6 +223,25 @@ requires-dist = [ { name = "azure-search-documents", specifier = ">=11.7.0b2,<11.7.0b3" }, ] +[[package]] +name = "agent-framework-azure-contentunderstanding" +version = "1.0.0a260401" +source = { editable = "packages/azure-contentunderstanding" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-contentunderstanding", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "filetype", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "aiohttp", specifier = ">=3.9,<4" }, + { name = "azure-ai-contentunderstanding", specifier = ">=1.0.0,<1.1" }, + { name = "filetype", specifier = ">=1.2,<2" }, +] + [[package]] name = "agent-framework-azure-cosmos" version = "1.0.0b260428" @@ -1110,6 +1130,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/91/1e5c0d7ce95ca8b022e69e4ca6b23e413fc2d57f0191429c4633e02213d2/azure_ai_agentserver_responses-1.0.0b5-py3-none-any.whl", hash = "sha256:4c2a6ab56e71eeb330aa52b7cb2cc71b8ec6b5bbe0e7dc84310f2c7fbda393a3", size = 268362, upload-time = "2026-04-23T04:31:17.014Z" }, ] +[[package]] +name = "azure-ai-contentunderstanding" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/97/6696d3fecb5650213c4b29dd45a306cc1da954e70e168605a5d372c51c3e/azure_ai_contentunderstanding-1.0.1.tar.gz", hash = "sha256:f653ea85a73df7d377ab55e39d7f02e271c66765f5fa5a3a56b59798bcb01e2c", size = 214634, upload-time = "2026-03-10T02:01:20.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/f4/bb26c5b347f18fc85a066b4360a93204466ef7026d28585f3bf77c1a73ed/azure_ai_contentunderstanding-1.0.1-py3-none-any.whl", hash = "sha256:8d34246482691229ef75fe25f18c066d5f6adfe03b638c47f9b784c2992e6611", size = 101275, upload-time = "2026-03-10T02:01:22.181Z" }, +] + [[package]] name = "azure-ai-inference" version = "1.0.0b9" @@ -2187,6 +2221,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + [[package]] name = "flask" version = "3.1.3"