From 97c9d0e7027d44e54bfaaa3ccbf9a5e2de361969 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 6 Oct 2026 14:58:00 -0400 Subject: [PATCH] Lead Enhanced extraction with its own V2 admin section In V2 Admin Settings, "Enable Enhanced extraction" was buried in a collapsed group of the Document Intelligence card, and Content Understanding was a separate card that could be filled in while Enhanced was off, when it is never called. Turning Enhanced on in V2 also left the mode on Standard, so Content Understanding never ran. - New Enhanced Extraction section (replaces content-understanding-section), led by the switch as its capability. Mode, Auto sample pages, formula extraction and the Content Understanding connection sit beneath it and are hidden while it is off. Document Intelligence keeps only its connection. - Engine notice names Content Understanding or the Document Intelligence Layout fallback (and why) from the settings on screen. - Schema vocabulary: on_enable (switch companions; moves Standard to Auto in the draft and on the server), group open_until_set (opens the optional Content Understanding connection while blank), flag support in Python evaluate_dependency. - content_understanding_supported runtime flag hides Content Understanding in clouds without it; SettingsSection now receives runtime flags, so flag-gated fields (Inbound MCP included) are no longer dropped. - Document Intelligence connection test sends the mode only while Enhanced is on. - Classic pane gains the enhanced-extraction-section anchor only. - Functional, TS logic and Playwright tests; docs, inventory and release notes; version 0.261.260. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../single_app/admin_settings_fields.py | 342 +++++++++++++---- application/single_app/admin_settings_nav.py | 14 +- application/single_app/config.py | 2 +- application/single_app/route_backend_v2.py | 10 +- .../templates/admin/_panes/extraction.html | 3 +- .../admin/EnhancedExtractionEngine.tsx | 126 ++++++ .../src/components/admin/SettingsSection.tsx | 21 +- application/v2_ui/src/lib/adminFields.ts | 73 ++++ application/v2_ui/src/lib/adminSections.ts | 9 + .../v2_ui/src/lib/enhancedExtraction.ts | 69 ++++ .../v2_ui/src/pages/AdminSettingsPage.tsx | 42 +- docs/_data/app_surface.yml | 8 +- docs/admin/knowledge.md | 66 ++-- ...NTENT_UNDERSTANDING_ENHANCED_EXTRACTION.md | 13 +- .../features/V2_ADMIN_KNOWLEDGE_SETTINGS.md | 5 + .../V2_ENHANCED_EXTRACTION_ADMIN_SECTION.md | 147 +++++++ docs/explanation/release_notes.md | 17 + .../test_v2_admin_capability_placement.py | 6 +- ...st_v2_admin_enhanced_extraction_section.py | 361 ++++++++++++++++++ .../test_v2_admin_knowledge_extraction.py | 126 +++--- .../test_v2_admin_schema_vocabulary.py | 96 ++++- .../test_v2_admin_section_logic.ts | 313 ++++++++++++++- .../test_v2_admin_settings_schema.py | 8 +- ...st_v2_admin_enhanced_extraction_section.py | 197 ++++++++++ 24 files changed, 1901 insertions(+), 173 deletions(-) create mode 100644 application/v2_ui/src/components/admin/EnhancedExtractionEngine.tsx create mode 100644 application/v2_ui/src/lib/enhancedExtraction.ts create mode 100644 docs/explanation/features/V2_ENHANCED_EXTRACTION_ADMIN_SECTION.md create mode 100644 functional_tests/test_v2_admin_enhanced_extraction_section.py create mode 100644 ui_tests/test_v2_admin_enhanced_extraction_section.py diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index 374c9632f..ac81bc432 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -30,7 +30,7 @@ to the same normalizers the server-rendered form uses. Both interfaces therefore agree on what a valid value is. -Beyond a field's type, four optional descriptors shape how a section reads: +Beyond a field's type, five optional descriptors shape how a section reads: ``group`` Names the cluster a field belongs to, either as a label or with a variant @@ -40,6 +40,12 @@ Intelligence is a flat run of forty controls in which the credential that makes the rest work is simply the last one. + A group may also name ``open_until_set``: a key in the same group whose + blank value opens the group while the section's capability is on. That is + for an optional connection the administrator most likely wants next -- + Content Understanding under Enhanced extraction -- which cannot be marked + ``required`` because the capability works without it. + ``depends_on`` Shows a field only while a condition holds. See ``evaluate_dependency`` for the supported shapes; ``any_of`` exists because the Speech resource block is @@ -59,6 +65,17 @@ input would otherwise save to a top-level key nothing reads. See ``_apply_nested_paths``. +``on_enable`` + Companion values a switch sets when it is turned on, as + ``{"set": {key: value}, "when": }``. Enhanced extraction is the + case: the extraction mode only matters while it is on, and turning it on + with the mode still on Standard would change nothing, so the mode moves to + Auto, which is what the server-rendered form has always done. The browser + applies it to the draft as the switch flips, so the new value is visible + before saving; ``_apply_enable_defaults`` applies it to a save that turns + the switch on without naming the companion, so an API client gets the same + result. A value named in the same save always wins. + The Appearance, Agents & Actions, Chat, Knowledge, Workflow, Workspaces and Security groups are described in full. Sections with no entry here fall back to the V2 surface's ``enable_*`` scan, so undescribed groups keep working exactly as they did. A @@ -1795,9 +1812,11 @@ # and then scrolls past everything that depends on the connection before # reaching the connection itself. # - # Here the connection comes first and the behaviour that needs it follows, - # declaring `requires` so a toggle flipped without a configured endpoint says - # so rather than silently doing nothing. + # Here Document Intelligence is the connection and nothing else. It is needed + # whatever else is configured -- Standard extraction is its Read model, and + # Enhanced falls back to its Layout model -- so it comes first, and the + # settings that only matter once Enhanced extraction is on live in the + # section after it. # ------------------------------------------------------------------ "document-intelligence-section": [ { @@ -1815,8 +1834,9 @@ "label": "Connection", "variant": "connection", "help": ( - "Document Intelligence reads PDFs and images. Nothing else in this " - "tab works until it is reachable." + "Document Intelligence reads PDFs and images. Standard extraction " + "is its Read model and Enhanced extraction falls back to its Layout " + "model, so nothing else in this tab works until it is reachable." ), }, }, @@ -1886,11 +1906,15 @@ "test_type": "azure_doc_intelligence", "test_payload": { "enable_apim": {"key": "enable_document_intelligence_apim"}, + # With Enhanced extraction off every document uses Standard, + # whatever mode is stored, so the test exercises Read too. "document_intelligence_pdf_image_extraction_mode": { - "key": "document_intelligence_pdf_image_extraction_mode" + "key": "document_intelligence_pdf_image_extraction_mode", + "when": {"key": "enable_enhanced_extraction", "equals": True}, }, "document_intelligence_auto_sample_pages": { - "key": "document_intelligence_auto_sample_pages" + "key": "document_intelligence_auto_sample_pages", + "when": {"key": "enable_enhanced_extraction", "equals": True}, }, "direct.endpoint": { "key": "azure_document_intelligence_endpoint", @@ -1915,15 +1939,61 @@ }, "group": {"id": "connection", "label": "Connection", "variant": "connection"}, }, + ], + # ------------------------------------------------------------------ + # Knowledge / Document Extraction / Enhanced Extraction + # + # One card for the capability and everything it governs. Enhanced extraction + # used to be a switch inside the collapsed Extraction group of Document + # Intelligence, while Content Understanding -- the engine behind it -- was a + # card of its own that could be filled in with Enhanced off, where it is + # never called. The switch now leads this card, and every setting that only + # means something while it is on sits beneath it and is hidden while it is + # off, so the relationship is shown rather than remembered. + # + # Content Understanding stays optional: without it Enhanced uses Document + # Intelligence Layout, which still captures tables and checkbox states but + # does not describe figures. The engine notice says which applies. Its + # fields are also gated on the cloud, because the service is not offered in + # Azure Government or custom clouds; the server-rendered pane does the same + # with ``content_understanding_supported``. + # ------------------------------------------------------------------ + "enhanced-extraction-section": [ + { + "key": "enable_enhanced_extraction", + "type": "switch", + "label": "Enable Enhanced extraction", + "help": ( + "Extracts tables, page structure, forms and checkbox states from PDFs " + "and images, and with Azure AI Content Understanding connected, " + "descriptions of figures and charts. The extraction mode and the " + "Content Understanding connection appear here once it is on. While it " + "is off, every document uses Standard extraction (Document " + "Intelligence Read)." + ), + "default": False, + "role": "capability", + "on_enable": { + # Enhanced with the mode still on Standard would change nothing. + # The server-rendered form moves to Auto at this point as well. + "set": {"document_intelligence_pdf_image_extraction_mode": "auto"}, + "when": { + "key": "document_intelligence_pdf_image_extraction_mode", + "equals": "read", + }, + }, + }, { "key": "document_intelligence_pdf_image_extraction_mode", "type": "select", "label": "PDF and Image Extraction Mode", "help": ( - "Standard is Document Intelligence Read: fastest and cheapest for plain " - "text. Enhanced captures tables, page structure, forms and checkbox " - "states, at roughly six times the cost per thousand pages. Auto samples " - "the first pages and picks." + "What new PDF and image uploads use. Standard is Document Intelligence " + "Read: fastest and cheapest for plain text. Enhanced captures tables, " + "page structure, forms and checkbox states, at roughly six times the " + "cost per thousand pages. Auto samples the first pages and picks. With " + "Standard selected, a document can still be extracted again as " + "Enhanced from its workspace." ), "default": "read", "options": [ @@ -1937,7 +2007,7 @@ "label": "Auto — sample first pages, then choose", }, ], - "group": {"id": "extraction", "label": "Extraction", "variant": "behavior"}, + "depends_on": {"key": "enable_enhanced_extraction", "equals": True}, }, { "key": "document_intelligence_auto_sample_pages", @@ -1951,53 +2021,63 @@ "default": 3, "min": 1, "max": 20, - "group": {"id": "extraction", "label": "Extraction", "variant": "behavior"}, - "depends_on": { - "key": "document_intelligence_pdf_image_extraction_mode", - "equals": "auto", - }, + "depends_on": [ + {"key": "enable_enhanced_extraction", "equals": True}, + {"key": "document_intelligence_pdf_image_extraction_mode", "equals": "auto"}, + ], }, { - "key": "enable_enhanced_extraction", + "key": "enable_document_intelligence_formula_extraction", "type": "switch", - "label": "Enable Enhanced extraction", + "label": "Extract mathematical formulas", "help": ( - "Uses Azure AI Content Understanding, which returns tables, page " - "structure, checkbox states and descriptions of figures and charts. " - "Falls back to Document Intelligence Layout where Content Understanding " - "is unavailable or unconfigured." + "Captures equations as LaTeX rather than approximate OCR text. It is a " + "billed add-on to the Document Intelligence Layout model, so it adds " + "per-page cost wherever Layout runs: Auto's page sampling, and Enhanced " + "extraction without Content Understanding. Standard and Content " + "Understanding extractions do not use it." ), "default": False, - "group": {"id": "extraction", "label": "Extraction", "variant": "behavior"}, + "depends_on": {"key": "enable_enhanced_extraction", "equals": True}, }, { - "key": "enable_document_intelligence_formula_extraction", - "type": "switch", - "label": "Extract mathematical formulas", + "type": "component", + "component": "enhanced-extraction-engine", + "label": "Extraction engine", "help": ( - "Captures equations as LaTeX rather than approximate OCR text. This is a " - "billed Document Intelligence add-on that adds per-page cost to every " - "Enhanced extraction, and it has no effect while extraction is Standard." + "Which engine runs when a document is extracted as Enhanced, judged " + "from the settings on this page. Test the Content Understanding " + "connection to confirm it is reachable." ), - "default": False, - "group": {"id": "extraction", "label": "Extraction", "variant": "behavior"}, + "depends_on": {"key": "enable_enhanced_extraction", "equals": True}, }, - ], - # Its own section now. The card has always been in the extraction pane but - # was missing from ADMIN_NAV, so neither interface could navigate to it. - "content-understanding-section": [ { "key": "azure_content_understanding_endpoint", "type": "text", "label": "Foundry Endpoint", "help": ( "The Microsoft Foundry resource endpoint, with no trailing path. Leave " - "blank and Enhanced extraction falls back to Document Intelligence " - "Layout." + "it blank to use Document Intelligence Layout instead." ), "default": "", "placeholder": "https://your-resource.services.ai.azure.com", - "group": {"id": "connection", "label": "Connection", "variant": "connection"}, + "group": { + "id": "content-understanding", + "label": "Content Understanding connection", + "variant": "connection", + "help": ( + "Optional. Azure AI Content Understanding backs Enhanced extraction " + "and adds descriptions of figures and charts. Without it, Enhanced " + "uses Document Intelligence Layout." + ), + # Starts open while unconnected, because it is the step most + # administrators want next after turning Enhanced on. + "open_until_set": "azure_content_understanding_endpoint", + }, + "depends_on": [ + {"key": "enable_enhanced_extraction", "equals": True}, + {"flag": "content_understanding_supported", "equals": True}, + ], }, { "key": "azure_content_understanding_authentication_type", @@ -2012,18 +2092,58 @@ {"value": "key", "label": "Key"}, {"value": "managed_identity", "label": "Managed Identity"}, ], - "group": {"id": "connection", "label": "Connection", "variant": "connection"}, + "group": { + "id": "content-understanding", + "label": "Content Understanding connection", + "variant": "connection", + }, + "depends_on": [ + {"key": "enable_enhanced_extraction", "equals": True}, + {"flag": "content_understanding_supported", "equals": True}, + ], }, { "key": "azure_content_understanding_key", "type": "secret", "default": "", "label": "Content Understanding Key", - "group": {"id": "connection", "label": "Connection", "variant": "connection"}, - "depends_on": { - "key": "azure_content_understanding_authentication_type", - "equals": "key", + "group": { + "id": "content-understanding", + "label": "Content Understanding connection", + "variant": "connection", + }, + "depends_on": [ + {"key": "enable_enhanced_extraction", "equals": True}, + {"flag": "content_understanding_supported", "equals": True}, + {"key": "azure_content_understanding_authentication_type", "equals": "key"}, + ], + }, + { + "type": "component", + "component": "connection-test", + "label": "Test Content Understanding connection", + "test_type": "content_understanding", + "test_payload": { + "endpoint": {"key": "azure_content_understanding_endpoint"}, + "authentication_type": { + "key": "azure_content_understanding_authentication_type" + }, + "key": {"key": "azure_content_understanding_key"}, + "api_version": {"key": "azure_content_understanding_api_version"}, + "analyzer_id": {"key": "azure_content_understanding_analyzer_id"}, + "image_analyzer_id": { + "key": "azure_content_understanding_image_analyzer_id" + }, }, + "group": { + "id": "content-understanding", + "label": "Content Understanding connection", + "variant": "connection", + }, + "depends_on": [ + {"key": "enable_enhanced_extraction", "equals": True}, + {"flag": "content_understanding_supported", "equals": True}, + ], }, { "key": "azure_content_understanding_api_version", @@ -2031,7 +2151,15 @@ "label": "API Version", "default": "", "fallback_when_empty": True, - "group": {"id": "analyzers", "label": "Analyzers", "variant": "advanced"}, + "group": { + "id": "analyzers", + "label": "Content Understanding analyzers", + "variant": "advanced", + }, + "depends_on": [ + {"key": "enable_enhanced_extraction", "equals": True}, + {"flag": "content_understanding_supported", "equals": True}, + ], }, { "key": "azure_content_understanding_analyzer_id", @@ -2039,7 +2167,15 @@ "label": "Document Analyzer", "default": "", "fallback_when_empty": True, - "group": {"id": "analyzers", "label": "Analyzers", "variant": "advanced"}, + "group": { + "id": "analyzers", + "label": "Content Understanding analyzers", + "variant": "advanced", + }, + "depends_on": [ + {"key": "enable_enhanced_extraction", "equals": True}, + {"flag": "content_understanding_supported", "equals": True}, + ], }, { "key": "azure_content_understanding_image_analyzer_id", @@ -2047,29 +2183,18 @@ "label": "Image Analyzer", "default": "", "fallback_when_empty": True, - "group": {"id": "analyzers", "label": "Analyzers", "variant": "advanced"}, - }, - { - "type": "component", - "component": "connection-test", - "label": "Test Content Understanding connection", - "test_type": "content_understanding", - "test_payload": { - "endpoint": {"key": "azure_content_understanding_endpoint"}, - "authentication_type": { - "key": "azure_content_understanding_authentication_type" - }, - "key": {"key": "azure_content_understanding_key"}, - "api_version": {"key": "azure_content_understanding_api_version"}, - "analyzer_id": {"key": "azure_content_understanding_analyzer_id"}, - "image_analyzer_id": { - "key": "azure_content_understanding_image_analyzer_id" - }, + "group": { + "id": "analyzers", + "label": "Content Understanding analyzers", + "variant": "advanced", }, - "group": {"id": "connection", "label": "Connection", "variant": "connection"}, + "depends_on": [ + {"key": "enable_enhanced_extraction", "equals": True}, + {"flag": "content_understanding_supported", "equals": True}, + ], }, ], - # Also promoted out of the extraction card into a section of its own. It is + # Promoted out of the extraction card into a section of its own. It is # independent of Enhanced extraction despite sitting inside it in the V1 # markup, which is what made it read as part of that feature. "office-embedded-image-section": [ @@ -6495,23 +6620,30 @@ def get_nested_path_fields(): } -def evaluate_dependency(dependency, read_value): +def evaluate_dependency(dependency, read_value, runtime_flags=None): """Whether a ``depends_on`` condition holds, given a value reader. ``read_value`` takes a settings key and returns its current value, so the same rules apply whether the caller is reading a stored document or a draft that has not been saved yet. - Four shapes are supported, and they compose: + Five shapes are supported, and they compose: ``{"key": k, "equals": v}`` k currently equals v ``{"key": k, "not_equals": v}`` k currently differs from v + ``{"flag": f, "equals": b}`` runtime flag f is b ``{"any_of": [...]}`` at least one nested condition holds ``{"all_of": [...]}`` every nested condition holds ``equals`` against a boolean compares truthiness rather than identity, because a settings document written by the server-rendered form stores checkbox state as the string ``"on"``. + + A runtime flag is resolved by the server rather than read from the settings + document -- whether Content Understanding is offered in this cloud, say. + ``runtime_flags`` supplies them. Without it a flag condition counts as met, + as ``_dependency_is_satisfied`` treats it, so validation is never suppressed + for a field behind a gate the caller cannot see. """ if not dependency: return True @@ -6519,18 +6651,28 @@ def evaluate_dependency(dependency, read_value): # A list means every condition has to hold. It is the shorthand the Security # and Workspaces sections declare, and is equivalent to ``all_of``. if isinstance(dependency, list): - return all(evaluate_dependency(nested, read_value) for nested in dependency) + return all( + evaluate_dependency(nested, read_value, runtime_flags) for nested in dependency + ) if "any_of" in dependency: return any( - evaluate_dependency(nested, read_value) for nested in dependency["any_of"] + evaluate_dependency(nested, read_value, runtime_flags) + for nested in dependency["any_of"] ) if "all_of" in dependency: return all( - evaluate_dependency(nested, read_value) for nested in dependency["all_of"] + evaluate_dependency(nested, read_value, runtime_flags) + for nested in dependency["all_of"] ) + if dependency.get("flag"): + if runtime_flags is None: + return True + expected = _coerce_bool(dependency.get("equals", True)) + return bool(runtime_flags.get(dependency["flag"])) is expected + current = read_value(dependency["key"]) if "not_equals" in dependency: @@ -7481,6 +7623,10 @@ def normalize_admin_settings_updates(updates, current_settings=None): _check_acknowledgements(updates, current, errors) + # A switch turned on may bring companion values with it. Applied before the + # checks below so they judge the state that will actually be stored. + _apply_enable_defaults(normalized, current, warnings) + # "At least one" style constraints can only be judged once the whole payload # is known, because the capability toggle and its selection may arrive apart. _check_minimum_selections(normalized, current, errors) @@ -7578,6 +7724,58 @@ def _check_planner_model_selection(normalized, current_settings, errors): errors[key] = message +def _apply_enable_defaults(normalized, current_settings, warnings): + """Set the companion values a switch declares through ``on_enable``. + + The browser does this to its draft as the switch flips. Doing it here as + well means a save that turns Enhanced extraction on through the API without + naming a mode still moves Standard to Auto, rather than enabling a + capability that then changes nothing. + + Only a transition counts: re-saving a switch that is already on leaves its + companions alone, and a companion named in the same save is the + administrator's explicit choice and always wins. + """ + def merged(key): + if key in normalized: + return normalized[key] + if key in current_settings: + return current_settings[key] + # A key that has never been saved reads as its declared default, which + # is what get_settings would have merged in. + definition = get_field_definition(key) or {} + return definition.get("default") + + for _section_id, field in iter_fields(): + key = field.get("key") + effect = field.get("on_enable") + if not key or not effect or field.get("readonly"): + continue + if key not in normalized or not _coerce_bool(normalized[key]): + continue + if _coerce_bool(current_settings.get(key)): + continue + if not evaluate_dependency(effect.get("when"), merged): + continue + + for companion, value in (effect.get("set") or {}).items(): + if companion in normalized: + continue + normalized[companion] = value + companion_field = get_field_definition(companion) or {} + shown = next( + ( + option.get("label", value) + for option in companion_field.get("options", []) + if option.get("value") == value + ), + value, + ) + warnings[companion] = ( + f'Set to "{shown}" because {field.get("label", key)} was turned on.' + ) + + def _apply_cross_field_rules(normalized, current_settings, warnings): """Reconcile settings whose valid range depends on another setting. diff --git a/application/single_app/admin_settings_nav.py b/application/single_app/admin_settings_nav.py index f63b48806..110461cb3 100644 --- a/application/single_app/admin_settings_nav.py +++ b/application/single_app/admin_settings_nav.py @@ -278,11 +278,15 @@ "icon": "bi-file-earmark-text", "sections": [ {"id": "document-intelligence-section", "label": "Document Intelligence", "icon": "bi-file-earmark-text"}, - # These two cards have always existed in the extraction pane but - # were absent from this list, so neither interface could - # navigate to them. Their ids are the ones already in the - # markup, so the server-rendered sidebar resolves them as-is. - {"id": "content-understanding-section", "label": "Content Understanding", "icon": "bi-stars"}, + # Enhanced extraction and the Content Understanding connection + # that backs it are one section, led by the switch. Content + # Understanding used to be a section of its own, which let it be + # configured while Enhanced was off and it could never run. + {"id": "enhanced-extraction-section", "label": "Enhanced Extraction", "icon": "bi-stars"}, + # This card has always existed in the extraction pane but was + # absent from this list, so neither interface could navigate to + # it. Its id is the one already in the markup, so the + # server-rendered sidebar resolves it as-is. {"id": "office-embedded-image-section", "label": "Images Inside Office Files", "icon": "bi-images"}, {"id": "chunk-size-section", "label": "Chunk Sizes", "icon": "bi-collection"}, # The upload ceiling belongs with the extraction pipeline it diff --git a/application/single_app/config.py b/application/single_app/config.py index 670ab4808..3a890675e 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.259" +VERSION = "0.261.260" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index 424079ae1..cb4e9f7ab 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -140,6 +140,7 @@ is_admin_settings_redacted_secret, is_chat_file_upload_enabled_for_user, is_chat_workflow_results_enabled_for_user, + is_content_understanding_supported_environment, is_user_workflows_enabled_for_user, is_workflow_assistant_enabled_for_user, merge_model_endpoint_payload, @@ -1409,7 +1410,14 @@ def v2_admin_get_settings(): # rather than a stored setting. Inbound MCP is gated by an # App Service application setting, so its value cannot be # read out of the settings document the SPA already holds. - "runtime_flags": {"mcp_ui_enabled": is_mcp_ui_enabled()}, + # Content Understanding is not offered in every Azure cloud, + # which only the server's AZURE_ENVIRONMENT can say. + "runtime_flags": { + "mcp_ui_enabled": is_mcp_ui_enabled(), + "content_understanding_supported": ( + is_content_understanding_supported_environment() + ), + }, "suppressed_capabilities": get_suppressed_capability_keys(), "version": VERSION, } diff --git a/application/single_app/templates/admin/_panes/extraction.html b/application/single_app/templates/admin/_panes/extraction.html index 5030744d6..857acb69f 100644 --- a/application/single_app/templates/admin/_panes/extraction.html +++ b/application/single_app/templates/admin/_panes/extraction.html @@ -11,7 +11,8 @@
Document Intelligence
Configure Azure Document Intelligence settings. Document Intelligence always powers Standard extraction and is required for workspaces and chat file uploads.

-
+ +
+
{label}
+ {help ? ( +

{help}

+ ) : null} +
+
+
+
+
+ ); +} diff --git a/application/v2_ui/src/components/admin/SettingsSection.tsx b/application/v2_ui/src/components/admin/SettingsSection.tsx index aee198871..1600edefa 100644 --- a/application/v2_ui/src/components/admin/SettingsSection.tsx +++ b/application/v2_ui/src/components/admin/SettingsSection.tsx @@ -106,6 +106,13 @@ export interface SettingsSectionProps { forceExpanded?: boolean; /** Opt-in presentation overrides; never changes the schema's behavior. */ appearance?: SettingsSectionAppearance; + /** + * Server-resolved runtime flags, such as whether Content Understanding is offered in + * this cloud. A field gated on one has to be judged the way the page judged it; + * without the flags its condition reads as unmet and the field is dropped from the + * card even though the page decided to show it. + */ + runtimeFlags?: Record; children?: ReactNode; } @@ -287,6 +294,7 @@ export function SettingsSection({ renderCapability, forceExpanded, appearance, + runtimeFlags, children, }: SettingsSectionProps) { const capability = useMemo(() => findCapabilityField(fields), [fields]); @@ -316,8 +324,10 @@ export function SettingsSection({ const requirements = useMemo(() => collectRequirements(fields, settings, draft), [fields, settings, draft]); const groups = useMemo( - () => groupFields(bodyFields.filter((field) => isFieldVisible(field, settings, draft))), - [bodyFields, settings, draft], + () => groupFields( + bodyFields.filter((field) => isFieldVisible(field, settings, draft, undefined, runtimeFlags)), + ), + [bodyFields, settings, draft, runtimeFlags], ); const presentation = presentSectionStatus(status); @@ -446,7 +456,12 @@ export function SettingsSection({ readSectionValue(settings, draft, key), + )} forceExpanded={forceExpanded} renderFields={renderFields} /> diff --git a/application/v2_ui/src/lib/adminFields.ts b/application/v2_ui/src/lib/adminFields.ts index 308b9bc5b..deae9f74e 100644 --- a/application/v2_ui/src/lib/adminFields.ts +++ b/application/v2_ui/src/lib/adminFields.ts @@ -97,6 +97,15 @@ export interface AdminFieldGroup { label?: string; variant?: 'connection' | 'behavior' | 'limits' | 'access' | 'advanced'; help?: string; + /** + * A key in this group whose blank value opens the group while the section's + * capability is on. + * + * For an optional connection an administrator most likely wants next -- Content + * Understanding under Enhanced extraction -- which cannot be marked `required` + * because the capability works without it. + */ + open_until_set?: string; } /** Normalise either declared group shape into the object form. */ @@ -140,6 +149,20 @@ export interface AdminFieldAcknowledgement { message: string; } +/** + * Companion values a switch sets when it is turned on. + * + * Mirrors `on_enable` in `admin_settings_fields.py`. Turning Enhanced extraction on + * while the extraction mode still reads Standard would change nothing, so the mode + * moves to Auto. The page applies this to the draft as the switch flips, so the new + * value is on screen before saving, and only while `when` holds against the current + * values. The server applies the same rule to a save that omits the companion. + */ +export interface AdminFieldEnableEffect { + set: Record; + when?: AdminFieldDependency; +} + export interface AdminField { key?: string; type: AdminFieldType; @@ -262,6 +285,8 @@ export interface AdminField { */ scale?: number; requires_acknowledgement?: AdminFieldAcknowledgement; + /** Switch fields only: companion values to set when it is turned on. */ + on_enable?: AdminFieldEnableEffect; } /** Section id -> ordered fields. Section ids come from `admin_settings_nav.py`. */ @@ -472,6 +497,8 @@ export interface RenderedFieldGroup { * always-on built-in actions out of the way. */ collapsed?: boolean; + /** A key whose blank value opens the group while the capability is on. */ + openUntilSet?: string; fields: AdminField[]; } @@ -499,10 +526,15 @@ export function groupFields(fields: AdminField[]): RenderedFieldGroup[] { variant: declared?.variant, help: declared?.help, collapsed: Boolean(field.collapsed), + openUntilSet: declared?.open_until_set, fields: [], }; byId.set(id, group); groups.push(group); + } else if (!group.openUntilSet && declared?.open_until_set) { + // Declared once, usually on the group's first field, which may be the one + // a dependency is hiding. + group.openUntilSet = declared.open_until_set; } group.fields.push(field); } @@ -725,6 +757,47 @@ export function isRequirementSatisfied( return asBoolean(current); } +/** + * Apply a switch's `on_enable` companions to a draft, returning the new draft. + * + * Only a transition counts, as on the server: a switch that is already on when saved + * brings nothing with it. Turning it on sets each companion that the administrator has not + * already edited, while `when` holds against the values on screen. Turning it back off + * before saving takes back any companion still holding the value it set, so the draft only + * carries what the administrator chose. A key that has never been saved reads as its + * declared default, which is what the server would have merged in. + */ +export function applyEnableEffect( + draft: Json, + settings: Json, + field: AdminField, + next: boolean, + fieldsByKey?: Map, +): Json { + const effect = field.on_enable; + if (!field.key || !effect || asBoolean(settings[field.key])) { + return draft; + } + + const read = (key: string): unknown => { + const value = readDependencyValue(key, settings, draft, fieldsByKey); + return value === undefined ? fieldsByKey?.get(key)?.default : value; + }; + + const updated: Json = { ...draft }; + for (const [companion, value] of Object.entries(effect.set ?? {})) { + const edited = Object.prototype.hasOwnProperty.call(draft, companion); + if (next) { + if (!edited && evaluateDependency(effect.when, read)) { + updated[companion] = value; + } + } else if (edited && draft[companion] === value) { + delete updated[companion]; + } + } + return updated; +} + /** * Whether a navigation section's `condition` holds. * diff --git a/application/v2_ui/src/lib/adminSections.ts b/application/v2_ui/src/lib/adminSections.ts index b79baa42e..2d4d39f33 100644 --- a/application/v2_ui/src/lib/adminSections.ts +++ b/application/v2_ui/src/lib/adminSections.ts @@ -296,11 +296,17 @@ export function deriveFieldHierarchy(fields: AdminField[]): FieldHierarchy { * the capability is on and something required is still blank; everything else stays shut. * Turning a capability on therefore reveals the next step rather than forty controls, and * a section that is already working stays a summary. + * + * An optional connection cannot be `required` -- Enhanced extraction works without + * Content Understanding -- so a group may instead name the key it waits on with + * `open_until_set`. It opens while that key is blank and the capability is on, which is + * when an administrator has just turned the capability on and is choosing an engine. */ export function shouldGroupStartOpen( group: RenderedFieldGroup, status: SectionStatus, capabilityOn: boolean, + read?: (key: string) => unknown, ): boolean { if (!group.id) { // Ungrouped fields are the section's own preamble; collapsing them would hide @@ -315,5 +321,8 @@ export function shouldGroupStartOpen( if (!capabilityOn) { return false; } + if (group.openUntilSet && read && !hasValue(read(group.openUntilSet))) { + return true; + } return group.variant === 'connection' && status === 'incomplete'; } diff --git a/application/v2_ui/src/lib/enhancedExtraction.ts b/application/v2_ui/src/lib/enhancedExtraction.ts new file mode 100644 index 000000000..298d85a26 --- /dev/null +++ b/application/v2_ui/src/lib/enhancedExtraction.ts @@ -0,0 +1,69 @@ +// enhancedExtraction.ts +// Which engine backs Enhanced extraction, read from the admin settings on screen. +// +// Enhanced extraction is served by Azure AI Content Understanding when the service is +// offered in this cloud and connected, and by Document Intelligence Layout otherwise. Only +// Content Understanding describes figures and charts, so which one is in force is the thing +// an administrator turning Enhanced on needs to know -- and nothing on the card used to +// say. This mirrors `resolve_enhanced_extraction_engine` and +// `is_content_understanding_configured` in functions_settings.py so the card can say it +// before anything is uploaded. +// +// It judges configuration, not connectivity. The server stays authoritative, and the +// connection test is what proves the service answers. + +import { asString } from './adminFields'; + +export type EnhancedExtractionEngine = 'content_understanding' | 'document_intelligence'; + +/** Why Enhanced extraction uses Document Intelligence Layout, when it does. */ +export type EnhancedExtractionFallbackReason = + | 'unsupported_cloud' + | 'missing_endpoint' + | 'missing_key'; + +export interface EnhancedExtractionEngineReading { + engine: EnhancedExtractionEngine; + reason: EnhancedExtractionFallbackReason | null; +} + +export const CONTENT_UNDERSTANDING_ENDPOINT_KEY = 'azure_content_understanding_endpoint'; +export const CONTENT_UNDERSTANDING_AUTHENTICATION_TYPE_KEY = + 'azure_content_understanding_authentication_type'; +export const CONTENT_UNDERSTANDING_KEY_KEY = 'azure_content_understanding_key'; + +/** The runtime flag the settings API sends for clouds that offer Content Understanding. */ +export const CONTENT_UNDERSTANDING_SUPPORTED_FLAG = 'content_understanding_supported'; + +/** + * Resolve the engine a document extracted as Enhanced would use. + * + * `read` returns a setting's current value, preferring an unsaved edit. A stored key reaches + * the browser as the redaction placeholder, which is a value, so an untouched credential + * still counts as configured. + */ +export function resolveEnhancedExtractionEngine( + read: (key: string) => unknown, + contentUnderstandingSupported: boolean, +): EnhancedExtractionEngineReading { + if (!contentUnderstandingSupported) { + return { engine: 'document_intelligence', reason: 'unsupported_cloud' }; + } + + // The server trims whitespace and trailing slashes before deciding, so an endpoint of + // only those is blank there too. + const endpoint = asString(read(CONTENT_UNDERSTANDING_ENDPOINT_KEY)).trim().replace(/\/+$/, ''); + if (!endpoint) { + return { engine: 'document_intelligence', reason: 'missing_endpoint' }; + } + + // Anything other than managed identity is normalised to key authentication. + const authenticationType = asString(read(CONTENT_UNDERSTANDING_AUTHENTICATION_TYPE_KEY)) + .trim() + .toLowerCase(); + if (authenticationType !== 'managed_identity' && !asString(read(CONTENT_UNDERSTANDING_KEY_KEY)).trim()) { + return { engine: 'document_intelligence', reason: 'missing_key' }; + } + + return { engine: 'content_understanding', reason: null }; +} diff --git a/application/v2_ui/src/pages/AdminSettingsPage.tsx b/application/v2_ui/src/pages/AdminSettingsPage.tsx index f5354af7b..5b873e28f 100644 --- a/application/v2_ui/src/pages/AdminSettingsPage.tsx +++ b/application/v2_ui/src/pages/AdminSettingsPage.tsx @@ -42,6 +42,7 @@ import { ChatModeNotice } from '../components/admin/ChatModeNotice'; import { ConnectionTest } from '../components/admin/ConnectionTest'; import { CustomPagesTable } from '../components/admin/CustomPagesTable'; import { EnhancedCitationsStorageTest } from '../components/admin/EnhancedCitationsStorageTest'; +import { EnhancedExtractionEngine } from '../components/admin/EnhancedExtractionEngine'; import { EntryListEditor } from '../components/admin/EntryListEditor'; import { ExternalLinksEditor } from '../components/admin/ExternalLinksEditor'; import { FrontDoorRedirectPreview } from '../components/admin/FrontDoorRedirectPreview'; @@ -73,6 +74,7 @@ import { UserAgreementPreview, } from '../components/admin/previews'; import { + applyEnableEffect, asBoolean, asNumber, asString, @@ -95,6 +97,10 @@ import { } from '../lib/adminFields'; import { toast } from '../stores/toastStore'; import { computeSectionStatus, type SectionStatus } from '../lib/adminSections'; +import { + CONTENT_UNDERSTANDING_SUPPORTED_FLAG, + resolveEnhancedExtractionEngine, +} from '../lib/enhancedExtraction'; import { hasUnsavedDiscoveryEdits } from '../lib/modelSelection'; import { PLANNER_MODEL_KEYS } from '../lib/orchestrationPlannerModel'; import { modelConnectionsChanged, requestConnectionFocus } from '../stores/modelConnectionsStore'; @@ -588,6 +594,10 @@ export function AdminSettingsPage() { * * Custom Pages does not take full effect until the App Service restarts, so an * administrator has to be told before the toggle can be turned on. + * + * A switch may also declare `on_enable` companions. Turning Enhanced extraction on + * moves the extraction mode from Standard to Auto in the draft, so the administrator + * sees the mode that will be saved rather than learning about it afterwards. */ const onSwitchChange = useCallback( (field: AdminField, next: boolean) => { @@ -615,8 +625,11 @@ export function AdminSettingsPage() { } setValue(field.key, next); + if (field.on_enable) { + setDraft((current) => applyEnableEffect(current, settings, field, next, fieldsByKey)); + } }, - [settings, setValue], + [settings, setValue, fieldsByKey], ); const discard = useCallback(() => { @@ -1070,6 +1083,32 @@ export function AdminSettingsPage() { return ( ); + case 'enhanced-extraction-engine': { + // Read twice: once as the page stands, and once as saved, so the + // notice can say when what it describes is not in force yet. + const supported = Boolean(runtimeFlags[CONTENT_UNDERSTANDING_SUPPORTED_FLAG]); + const reading = resolveEnhancedExtractionEngine( + (settingKey) => readFieldValue(READ_ONLY_REF(settingKey), settings, draft), + supported, + ); + const saved = resolveEnhancedExtractionEngine( + (settingKey) => readFieldValue(READ_ONLY_REF(settingKey), settings, {}), + supported, + ); + const pending = + !asBoolean(settings['enable_enhanced_extraction']) || + saved.engine !== reading.engine || + saved.reason !== reading.reason; + return ( + + ); + } case 'front-door-redirect-preview': return (
', + markup, + re.DOTALL, + ) + assert match, "No element in extraction.html carries id=\"enhanced-extraction-section\"." + assert 'id="enable_enhanced_extraction"' in match.group("body"), ( + "The Enhanced Extraction destination should be the row holding the switch." + ) + + print(" The classic sidebar link scrolls to the Enhanced switch.") + return True + + +if __name__ == "__main__": + tests = [ + test_enhanced_extraction_follows_document_intelligence_in_navigation, + test_enhanced_extraction_leads_its_section, + test_document_intelligence_is_the_connection_alone, + test_everything_but_the_switch_is_hidden_while_enhanced_is_off, + test_content_understanding_follows_the_cloud, + test_the_connection_group_waits_on_the_endpoint, + test_turning_enhanced_on_moves_standard_to_auto, + test_document_intelligence_test_sends_the_mode_only_while_enhanced_is_on, + test_the_settings_api_and_page_carry_the_cloud_flag, + test_the_classic_sidebar_lands_on_the_enhanced_switch, + ] + + results = [] + for test in tests: + try: + results.append(bool(test())) + except Exception as exc: + print(f"FAILED {test.__name__}: {exc}") + import traceback + + traceback.print_exc() + results.append(False) + + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/functional_tests/test_v2_admin_knowledge_extraction.py b/functional_tests/test_v2_admin_knowledge_extraction.py index 96952e3d1..ba7084b62 100644 --- a/functional_tests/test_v2_admin_knowledge_extraction.py +++ b/functional_tests/test_v2_admin_knowledge_extraction.py @@ -2,8 +2,9 @@ # test_v2_admin_knowledge_extraction.py """ Functional test for the Knowledge group's Document Extraction tab in V2. -Version: 0.261.122 +Version: 0.261.260 Implemented in: 0.261.084 +Enhanced Extraction section added in: 0.261.260 The server-rendered extraction pane is inside out. "Enable Enhanced extraction" is its first control, at line 13, and the Document Intelligence endpoint and key @@ -12,20 +13,21 @@ administrator turns a feature on and then scrolls past everything that depends on the connection before reaching the connection itself. -Two of those cards were also missing from ``ADMIN_NAV`` entirely, so neither -interface could navigate to Content Understanding or to the Office embedded -image options even though both have existed in the markup for some time. +The Office embedded image card was also missing from ``ADMIN_NAV`` entirely, so +neither interface could navigate to it even though it has existed in the markup +for some time. The checks here pin the corrected shape: ordering - The connection group is declared before the behaviour that depends on it. - This is the whole reason the section was described, so it is asserted rather - than left to review. + Document Intelligence is the connection and nothing else, and the Enhanced + Extraction section that depends on it follows it. This is the whole reason + the tab was described, so it is asserted rather than left to review. navigation - Both previously unreachable cards are sections, with the ids the existing - markup already uses so the server-rendered sidebar resolves them. + The previously unreachable Office card is a section, with the id the + existing markup already uses, and the Enhanced Extraction section resolves + to the Enhanced switch in the server-rendered pane. storage Chunk sizes live inside one ``chunk_size`` object and are clamped to what an @@ -57,7 +59,7 @@ EXTRACTION_SECTIONS = ( "document-intelligence-section", - "content-understanding-section", + "enhanced-extraction-section", "office-embedded-image-section", "chunk-size-section", # Both workspace upload paths feed the document extraction pipeline. @@ -69,7 +71,7 @@ # Described so far. Multi-modal vision arrives with the model capability work. DESCRIBED_SECTIONS = ( "document-intelligence-section", - "content-understanding-section", + "enhanced-extraction-section", "office-embedded-image-section", "chunk-size-section", "metadata-extraction-section", @@ -96,10 +98,10 @@ def section_fields(section_id): def test_the_previously_unreachable_cards_are_navigable(): - """Two cards existed in the markup but in no navigation, in either interface.""" + """Every extraction section resolves to an element in the server-rendered pane.""" print("Testing Document Extraction sections against ADMIN_NAV...") - assert_app_version_at_least("0.261.084") + assert_app_version_at_least("0.261.260") nav_sections = [ section["id"] @@ -115,22 +117,23 @@ def test_the_previously_unreachable_cards_are_navigable(): f" ADMIN_NAV: {nav_sections}\n test: {list(EXTRACTION_SECTIONS)}" ) - # The ids have to be the ones already on the cards, or the server-rendered - # sidebar links resolve to nothing. + # The ids have to be on elements in the pane, or the classic page's sidebar + # links resolve to nothing. Enhanced Extraction lands on the Enhanced switch, + # which is where that section starts in the server-rendered layout. markup = PANE.read_text(encoding="utf-8") - for section_id in ("content-understanding-section", "office-embedded-image-section"): + for section_id in ("enhanced-extraction-section", "office-embedded-image-section"): assert f'id="{section_id}"' in markup, ( - f"{section_id} is in ADMIN_NAV but no card in extraction.html carries " + f"{section_id} is in ADMIN_NAV but no element in extraction.html carries " "that id, so the classic page would link to nothing." ) - print(f" All {len(nav_sections)} section(s) navigable, including the two added.") + print(f" All {len(nav_sections)} section(s) navigable.") return True def test_the_connection_is_declared_before_what_depends_on_it(): - """This ordering is the reason the section was described at all.""" - print("\nTesting Document Intelligence field order...") + """This ordering is the reason the tab was described at all.""" + print("\nTesting Document Intelligence and Enhanced Extraction order...") fields = section_fields("document-intelligence-section") assert fields, "document-intelligence-section declares no fields." @@ -138,27 +141,28 @@ def test_the_connection_is_declared_before_what_depends_on_it(): order = [field.get("key") or field.get("component") for field in fields] groups = [(field.get("group") or {}).get("id") for field in fields] - endpoint = order.index("azure_document_intelligence_endpoint") - enhanced = order.index("enable_enhanced_extraction") - formula = order.index("enable_document_intelligence_formula_extraction") - mode = order.index("document_intelligence_pdf_image_extraction_mode") - - assert endpoint < enhanced, ( - "Enhanced extraction is declared before the Document Intelligence " - "endpoint it needs. That is the V1 ordering, and it is what this " - f"section exists to fix.\n order: {order}" - ) - assert endpoint < formula, f"Formula extraction precedes the endpoint.\n order: {order}" - assert endpoint < mode, f"The extraction mode precedes the endpoint.\n order: {order}" - - # The connection group is contiguous and first, so it collapses as one unit. - connection_positions = [index for index, group in enumerate(groups) if group == "connection"] - assert connection_positions == list(range(len(connection_positions))), ( - "The connection fields are not contiguous at the top of the section, so " - f"they cannot be disclosed as one group.\n groups: {groups}" + # Document Intelligence is the connection alone, so the whole section + # collapses as one unit and nothing that needs it is declared ahead of it. + assert set(groups) == {"connection"}, ( + "Document Intelligence should hold its connection and nothing else; the " + f"settings that need Enhanced extraction belong in the next section.\n groups: {groups}" ) + for behaviour in ( + "enable_enhanced_extraction", + "enable_document_intelligence_formula_extraction", + "document_intelligence_pdf_image_extraction_mode", + ): + assert behaviour not in order, ( + f"{behaviour} is back in Document Intelligence, where it sat in a " + f"collapsed group away from what it governs.\n order: {order}" + ) + + sections = list(EXTRACTION_SECTIONS) + assert sections.index("document-intelligence-section") < sections.index( + "enhanced-extraction-section" + ), "Enhanced Extraction is navigated to before the connection it falls back to." - print(" The connection is declared first, contiguously, before its dependants.") + print(" Document Intelligence is the connection, and Enhanced Extraction follows it.") return True @@ -258,20 +262,29 @@ def visible(key, state): def test_auto_sample_pages_appears_only_for_auto(): - """A sample-page count is meaningless unless Auto is selected.""" + """A sample-page count is meaningless unless Enhanced is on and Auto is selected.""" print("\nTesting the auto sample pages dependency...") field = fields_module.get_field_definition("document_intelligence_auto_sample_pages") assert field, "document_intelligence_auto_sample_pages is not declared." evaluate = fields_module.evaluate_dependency - for mode, expected in (("auto", True), ("read", False), ("layout", False)): - state = {"document_intelligence_pdf_image_extraction_mode": mode} + for enhanced, mode, expected in ( + (True, "auto", True), + (True, "read", False), + (True, "layout", False), + # Enhanced off means Standard, whatever mode is stored. + (False, "auto", False), + ): + state = { + "enable_enhanced_extraction": enhanced, + "document_intelligence_pdf_image_extraction_mode": mode, + } assert evaluate(field["depends_on"], state.get) is expected, ( - f"Sample pages visibility is wrong for mode {mode!r}." + f"Sample pages visibility is wrong for Enhanced={enhanced}, mode {mode!r}." ) - print(" Sample pages show only in Auto mode.") + print(" Sample pages show only for Auto while Enhanced is on.") return True @@ -348,12 +361,12 @@ def test_every_chunk_size_field_declares_its_path(): return True -def test_content_understanding_is_its_own_section(): - """It was a nested card, which read as part of Enhanced extraction.""" - print("\nTesting the Content Understanding section...") +def test_content_understanding_is_nested_under_enhanced_extraction(): + """It was a card of its own, which could be filled in while it could never run.""" + print("\nTesting where Content Understanding lives...") - fields = section_fields("content-understanding-section") - keys = {field.get("key") for field in fields} + fields = section_fields("enhanced-extraction-section") + by_key = {field.get("key"): field for field in fields if field.get("key")} for expected in ( "azure_content_understanding_endpoint", @@ -363,7 +376,16 @@ def test_content_understanding_is_its_own_section(): "azure_content_understanding_analyzer_id", "azure_content_understanding_image_analyzer_id", ): - assert expected in keys, f"{expected} is not declared in its own section." + assert expected in by_key, f"{expected} is not declared under Enhanced Extraction." + conditions = list(fields_module.iter_field_dependencies(by_key[expected])) + assert {"key": "enable_enhanced_extraction", "equals": True} in conditions, ( + f"{expected} can be edited while Enhanced extraction is off, which is " + "the only time Content Understanding is never called." + ) + + assert "content-understanding-section" not in fields_module.get_admin_settings_fields(), ( + "Content Understanding is declared as its own section again." + ) secret = fields_module.get_field_definition("azure_content_understanding_key") assert secret["type"] == "secret", ( @@ -371,7 +393,7 @@ def test_content_understanding_is_its_own_section(): "browser in plain text." ) - print(f" {len(keys)} field(s) declared in the Content Understanding section.") + print(f" {len(by_key)} field(s) declared under Enhanced Extraction, all gated on it.") return True @@ -385,7 +407,7 @@ def test_content_understanding_is_its_own_section(): test_auto_sample_pages_appears_only_for_auto, test_chunk_sizes_are_written_into_the_chunk_size_object, test_every_chunk_size_field_declares_its_path, - test_content_understanding_is_its_own_section, + test_content_understanding_is_nested_under_enhanced_extraction, ] results = [] diff --git a/functional_tests/test_v2_admin_schema_vocabulary.py b/functional_tests/test_v2_admin_schema_vocabulary.py index 427a49312..858d3fc43 100644 --- a/functional_tests/test_v2_admin_schema_vocabulary.py +++ b/functional_tests/test_v2_admin_schema_vocabulary.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for the Admin Settings schema vocabulary added for Knowledge. -Version: 0.261.122 +Version: 0.261.260 Implemented in: 0.261.084 +Runtime flag evaluation, open_until_set and on_enable added in: 0.261.260 The Knowledge group needs control kinds the schema could not previously express: credentials, domain allow lists, workspace assignment lists, server-computed @@ -303,6 +304,12 @@ def test_declared_groups_and_requires_are_well_formed(): print("\nTesting group and requires descriptors...") problems = [] + group_keys = {} + for section_id, field in fields_module.iter_fields(): + group = field.get("group") + if isinstance(group, dict) and field.get("key"): + group_keys.setdefault((section_id, group.get("id")), set()).add(field["key"]) + for section_id, field in fields_module.iter_fields(): group = field.get("group") if isinstance(group, dict): @@ -312,6 +319,15 @@ def test_declared_groups_and_requires_are_well_formed(): f"{section_id}.{field.get('key')}: group variant {variant!r} " f"is not one of {fields_module.GROUP_VARIANTS}" ) + # The renderer opens the group while this key is blank, so it has to + # be a key the group itself holds; anything else never opens or + # never closes. + waits_on = group.get("open_until_set") + if waits_on and waits_on not in group_keys.get((section_id, group.get("id")), set()): + problems.append( + f"{section_id}.{field.get('key')}: open_until_set {waits_on!r} is not " + f"a field of group {group.get('id')!r}" + ) requires = field.get("requires") if isinstance(requires, dict): @@ -332,6 +348,82 @@ def test_declared_groups_and_requires_are_well_formed(): return True +def test_on_enable_effects_are_well_formed(): + """A companion naming a missing key or option would be saved and never read.""" + print("\nTesting on_enable descriptors...") + + declared = fields_module.get_declared_setting_keys() + problems = [] + checked = 0 + + for section_id, field in fields_module.iter_fields(): + effect = field.get("on_enable") + if not effect: + continue + checked += 1 + identity = f"{section_id}.{field.get('key')}" + + # Only a switch has an "on", and a mirror cannot be turned on from here. + if field.get("type") != "switch" or field.get("readonly"): + problems.append(f"{identity}: on_enable needs an editable switch") + # The acknowledgement path sets the switch after a dialog, past the place + # the page applies companions, so the two are not combined. + if field.get("requires_acknowledgement"): + problems.append(f"{identity}: on_enable cannot be combined with an acknowledgement") + + companions = effect.get("set") or {} + if not companions: + problems.append(f"{identity}: on_enable sets nothing") + for companion, value in companions.items(): + target = fields_module.get_field_definition(companion) + if companion not in declared or target is None: + problems.append(f"{identity}: sets undeclared key {companion!r}") + continue + options = [option["value"] for option in target.get("options", [])] + if options and value not in options: + problems.append( + f"{identity}: sets {companion}={value!r}, which is not one of {options}" + ) + + when = effect.get("when") + for condition in ([when] if isinstance(when, dict) else when or []): + if condition.get("key") and condition["key"] not in declared: + problems.append(f"{identity}: when reads undeclared key {condition['key']!r}") + + assert not problems, "\n ".join(["Malformed on_enable descriptors:"] + problems) + assert checked, "No on_enable descriptor was checked; Enhanced extraction should declare one." + + print(f" {checked} on_enable descriptor(s) are well formed.") + return True + + +def test_runtime_flag_conditions_follow_the_flags_given(): + """The server must judge a flag the way the browser does, or not at all.""" + print("\nTesting runtime flag evaluation...") + + evaluate = fields_module.evaluate_dependency + gated = [ + {"key": "enable_thing", "equals": True}, + {"flag": "content_understanding_supported", "equals": True}, + ] + read = {"enable_thing": True}.get + + assert evaluate(gated, read, {"content_understanding_supported": True}) is True + assert evaluate(gated, read, {"content_understanding_supported": False}) is False + assert evaluate(gated, read, {}) is False, "an absent flag reads as off, as in the browser" + assert evaluate( + {"flag": "content_understanding_supported", "equals": False}, read, {} + ) is True + + # Without flags the server cannot judge the condition, so it does not let it + # suppress anything -- the same answer _dependency_is_satisfied gives. + assert evaluate(gated, read) is True + assert evaluate({"any_of": [{"flag": "mcp_ui_enabled", "equals": True}]}, read) is True + + print(" Flag conditions follow the flags given, and count as met without them.") + return True + + def test_secret_keys_are_discoverable_for_redaction(): """The settings endpoint needs the list to redact before it responds.""" print("\nTesting secret key discovery...") @@ -373,6 +465,8 @@ def test_undeclared_keys_still_pass_through(): test_id_list_accepts_both_stored_and_edited_shapes, test_dependency_evaluation_supports_every_declared_shape, test_declared_groups_and_requires_are_well_formed, + test_on_enable_effects_are_well_formed, + test_runtime_flag_conditions_follow_the_flags_given, test_secret_keys_are_discoverable_for_redaction, test_undeclared_keys_still_pass_through, ] diff --git a/functional_tests/test_v2_admin_section_logic.ts b/functional_tests/test_v2_admin_section_logic.ts index 6a5dec64b..16572015d 100644 --- a/functional_tests/test_v2_admin_section_logic.ts +++ b/functional_tests/test_v2_admin_section_logic.ts @@ -1,10 +1,11 @@ // test_v2_admin_section_logic.ts // // Runtime test for the Admin Settings section shell's presentation decisions. -// Version: 0.261.258 +// Version: 0.261.260 // Implemented in: 0.261.084 // Agents-only visual hierarchy coverage added in: 0.261.093 // Every-section presentation and schema-derived hierarchy added in: 0.261.258 +// Enhanced extraction engine, open_until_set and on_enable coverage added in: 0.261.260 // // The V2 admin surface used to render a section as a flat run of controls in declaration // order. That is fine for Appearance. It is not fine for Knowledge, where Document @@ -28,6 +29,7 @@ import { SettingsSection, type SettingsSectionProps, } from '../application/v2_ui/src/components/admin/SettingsSection'; +import { EnhancedExtractionEngine } from '../application/v2_ui/src/components/admin/EnhancedExtractionEngine'; import { agentSectionAppearances } from '../application/v2_ui/src/components/admin/agentSectionAppearance'; import { ADMIN_NAV_ICONS, @@ -45,12 +47,15 @@ import { shouldGroupStartOpen, } from '../application/v2_ui/src/lib/adminSections'; import { + applyEnableEffect, groupFields, evaluateDependency, + SECRET_PLACEHOLDER, type AdminField, type AdminFieldDependency, type RenderedFieldGroup, } from '../application/v2_ui/src/lib/adminFields'; +import { resolveEnhancedExtractionEngine } from '../application/v2_ui/src/lib/enhancedExtraction'; const checks: [string, () => void][] = []; function check(name: string, fn: () => void) { @@ -594,6 +599,312 @@ check('distinct subsection presentation preserves collapsed defaults and counts' assert.deepEqual(calls, []); }); +// --- Enhanced Extraction ------------------------------------------------------------ +// +// Enhanced extraction and the Content Understanding connection behind it are one card, +// led by the switch. These checks hold the three decisions that make that card read as +// one capability: an optional connection opens while it is blank, the engine in force is +// named, and turning the switch on brings the extraction mode with it. + +const MODE_KEY = 'document_intelligence_pdf_image_extraction_mode'; +const CU_ENDPOINT = 'azure_content_understanding_endpoint'; +const CU_AUTH = 'azure_content_understanding_authentication_type'; +const CU_KEY = 'azure_content_understanding_key'; +const CU_FLAG = { flag: 'content_understanding_supported', equals: true } as const; +const ENHANCED_ON = { key: 'enable_enhanced_extraction', equals: true } as const; + +const ENHANCED_SWITCH: AdminField = { + key: 'enable_enhanced_extraction', + type: 'switch', + label: 'Enable Enhanced extraction', + default: false, + role: 'capability', + on_enable: { set: { [MODE_KEY]: 'auto' }, when: { key: MODE_KEY, equals: 'read' } }, +}; + +const MODE_FIELD: AdminField = { + key: MODE_KEY, + type: 'select', + label: 'PDF and Image Extraction Mode', + default: 'read', + options: [ + { value: 'read', label: 'Standard' }, + { value: 'layout', label: 'Enhanced' }, + { value: 'auto', label: 'Auto' }, + ], + depends_on: ENHANCED_ON, +}; + +const CU_GROUP = { + id: 'content-understanding', + label: 'Content Understanding connection', + variant: 'connection' as const, +}; + +/** The shape of the declared section, down to the gates each field carries. */ +const ENHANCED_EXTRACTION_FIELDS: AdminField[] = [ + ENHANCED_SWITCH, + MODE_FIELD, + { + type: 'component', + component: 'enhanced-extraction-engine', + label: 'Extraction engine', + depends_on: ENHANCED_ON, + }, + { + key: CU_ENDPOINT, + type: 'text', + label: 'Foundry Endpoint', + group: { ...CU_GROUP, open_until_set: CU_ENDPOINT }, + depends_on: [ENHANCED_ON, CU_FLAG], + }, + { + key: CU_AUTH, + type: 'select', + label: 'Authentication Type', + group: CU_GROUP, + depends_on: [ENHANCED_ON, CU_FLAG], + }, + { + key: 'azure_content_understanding_analyzer_id', + type: 'text', + label: 'Document Analyzer', + group: { id: 'analyzers', label: 'Content Understanding analyzers', variant: 'advanced' }, + depends_on: [ENHANCED_ON, CU_FLAG], + }, +]; + +function renderEnhancedExtraction( + settings: Record, + runtimeFlags: Record | undefined, + calls: string[], +) { + return renderToStaticMarkup(createElement(SettingsSection, { + sectionId: 'enhanced-extraction-section', + label: 'Enhanced Extraction', + groupLabel: 'Knowledge', + tabLabel: 'Document Extraction', + fields: ENHANCED_EXTRACTION_FIELDS, + settings, + draft: {}, + runtimeFlags, + renderField: (field) => { + calls.push(`field:${field.key ?? field.component}`); + return createElement('span', { key: field.key ?? field.component }, field.label); + }, + renderCapability: (field) => { + calls.push(`capability:${field.key}`); + return createElement('span', { key: field.key }, field.label); + }, + })); +} + +check('an optional connection opens while the key it waits on is blank', () => { + const group: RenderedFieldGroup = { + ...renderedGroup('content-understanding', 'connection'), + openUntilSet: CU_ENDPOINT, + }; + const read = (values: Record) => (key: string) => values[key]; + + // Enhanced works without Content Understanding, so the section is ready rather than + // incomplete; the group still opens because connecting it is the likely next step. + assert.equal(shouldGroupStartOpen(group, 'ready', true, read({ [CU_ENDPOINT]: '' })), true); + assert.equal( + shouldGroupStartOpen(group, 'ready', true, read({ [CU_ENDPOINT]: 'https://cu.example.invalid' })), + false, + 'a connected service reads as a summary', + ); + assert.equal(shouldGroupStartOpen(group, 'off', false, read({})), false, 'not while the switch is off'); + assert.equal(shouldGroupStartOpen(group, 'ready', true), false, 'no reader, no guess'); + assert.equal( + shouldGroupStartOpen({ ...group, collapsed: true }, 'ready', true, read({})), + false, + 'a group the schema asks to keep closed stays closed', + ); +}); + +check('open_until_set survives when the field declaring it is not the first one shown', () => { + const fields: AdminField[] = [ + { key: CU_AUTH, type: 'select', label: 'Authentication Type', group: CU_GROUP }, + { key: CU_ENDPOINT, type: 'text', label: 'Foundry Endpoint', group: { ...CU_GROUP, open_until_set: CU_ENDPOINT } }, + ]; + assert.equal(groupFields(fields)[0].openUntilSet, CU_ENDPOINT); +}); + +check('the engine is Content Understanding only where it can actually run', () => { + const read = (values: Record) => (key: string) => values[key]; + const endpoint = 'https://cu.example.invalid/'; + + assert.deepEqual( + resolveEnhancedExtractionEngine(read({ [CU_ENDPOINT]: endpoint, [CU_KEY]: 'k' }), false), + { engine: 'document_intelligence', reason: 'unsupported_cloud' }, + 'a cloud without the service never uses it, however it is configured', + ); + assert.deepEqual( + resolveEnhancedExtractionEngine(read({}), true), + { engine: 'document_intelligence', reason: 'missing_endpoint' }, + ); + assert.equal( + resolveEnhancedExtractionEngine(read({ [CU_ENDPOINT]: ' // ' }), true).reason, + 'missing_endpoint', + 'slashes alone are trimmed to nothing on the server too', + ); + assert.equal( + resolveEnhancedExtractionEngine(read({ [CU_ENDPOINT]: endpoint, [CU_AUTH]: 'key', [CU_KEY]: '' }), true).reason, + 'missing_key', + ); + assert.equal( + resolveEnhancedExtractionEngine(read({ [CU_ENDPOINT]: endpoint, [CU_AUTH]: 'key', [CU_KEY]: SECRET_PLACEHOLDER }), true).engine, + 'content_understanding', + 'a stored key arrives as the placeholder and still counts', + ); + assert.equal( + resolveEnhancedExtractionEngine(read({ [CU_ENDPOINT]: endpoint, [CU_AUTH]: 'managed_identity' }), true).engine, + 'content_understanding', + ); + assert.equal( + resolveEnhancedExtractionEngine(read({ [CU_ENDPOINT]: endpoint, [CU_AUTH]: 'something-else' }), true).reason, + 'missing_key', + 'the server treats an unrecognised authentication type as key authentication', + ); +}); + +check('turning Enhanced on moves Standard to Auto, and turning it back off takes that back', () => { + const index = new Map([ + ['enable_enhanced_extraction', ENHANCED_SWITCH], + [MODE_KEY, MODE_FIELD], + ]); + const saved = { enable_enhanced_extraction: false, [MODE_KEY]: 'read' }; + + const on = applyEnableEffect({ enable_enhanced_extraction: true }, saved, ENHANCED_SWITCH, true, index); + assert.equal(on[MODE_KEY], 'auto'); + + const off = applyEnableEffect({ ...on, enable_enhanced_extraction: false }, saved, ENHANCED_SWITCH, false, index); + assert.equal(Object.prototype.hasOwnProperty.call(off, MODE_KEY), false, 'the draft only keeps what was chosen'); + + const chosen = applyEnableEffect( + { enable_enhanced_extraction: true, [MODE_KEY]: 'layout' }, saved, ENHANCED_SWITCH, true, index, + ); + assert.equal(chosen[MODE_KEY], 'layout', 'an edit already made wins'); + + const keptOff = applyEnableEffect( + { enable_enhanced_extraction: false, [MODE_KEY]: 'layout' }, saved, ENHANCED_SWITCH, false, index, + ); + assert.equal(keptOff[MODE_KEY], 'layout', 'switching off only takes back the value it set'); + + const enhancedSaved = { enable_enhanced_extraction: false, [MODE_KEY]: 'layout' }; + const kept = applyEnableEffect({ enable_enhanced_extraction: true }, enhancedSaved, ENHANCED_SWITCH, true, index); + assert.equal(Object.prototype.hasOwnProperty.call(kept, MODE_KEY), false, 'a stored Enhanced or Auto choice returns as it was'); + + const alreadyOn = applyEnableEffect( + { enable_enhanced_extraction: true }, { enable_enhanced_extraction: true, [MODE_KEY]: 'read' }, ENHANCED_SWITCH, true, index, + ); + assert.equal(Object.prototype.hasOwnProperty.call(alreadyOn, MODE_KEY), false, 'only a transition counts, as on the server'); + + const fresh = applyEnableEffect({ enable_enhanced_extraction: true }, {}, ENHANCED_SWITCH, true, index); + assert.equal(fresh[MODE_KEY], 'auto', 'a never-saved mode reads as its declared default'); + + const plain: AdminField = { key: 'enable_other', type: 'switch', label: 'Other' }; + const untouched = { enable_other: true }; + assert.equal(applyEnableEffect(untouched, {}, plain, true, index), untouched, 'no effect, no new draft'); +}); + +check('the engine notice names the engine and says when it is not in force yet', () => { + const connected = renderToStaticMarkup(createElement(EnhancedExtractionEngine, { + label: 'Extraction engine', + reading: { engine: 'content_understanding', reason: null }, + })); + assert.match(connected, /data-engine="content_understanding"/); + assert.match(connected, /role="status"/); + assert.match(connected, /Azure AI Content Understanding/); + assert.match(connected, /descriptions of figures and charts/); + assert.doesNotMatch(connected, /Takes effect when you save/); + + const fallback = renderToStaticMarkup(createElement(EnhancedExtractionEngine, { + label: 'Extraction engine', + reading: { engine: 'document_intelligence', reason: 'missing_endpoint' }, + pending: true, + })); + assert.match(fallback, /data-engine="document_intelligence"/); + assert.match(fallback, /Document Intelligence Layout/); + assert.match(fallback, /Add a Foundry endpoint/); + assert.match(fallback, /Takes effect when you save/); + + const keyless = renderToStaticMarkup(createElement(EnhancedExtractionEngine, { + label: 'Extraction engine', + reading: { engine: 'document_intelligence', reason: 'missing_key' }, + })); + assert.match(keyless, /an endpoint but no key/); + + const sovereign = renderToStaticMarkup(createElement(EnhancedExtractionEngine, { + label: 'Extraction engine', + reading: { engine: 'document_intelligence', reason: 'unsupported_cloud' }, + })); + assert.match(sovereign, /not offered in this Azure cloud/); + assert.match(sovereign, /nothing more to configure/); +}); + +check('with Enhanced off the card is the switch and nothing that depends on it', () => { + const calls: string[] = []; + const markup = renderEnhancedExtraction( + { enable_enhanced_extraction: false, [MODE_KEY]: 'read', [CU_ENDPOINT]: '' }, + { content_understanding_supported: true }, + calls, + ); + assert.deepEqual(calls, ['capability:enable_enhanced_extraction']); + assert.match(markup, />Off { + const calls: string[] = []; + const markup = renderEnhancedExtraction( + { enable_enhanced_extraction: true, [MODE_KEY]: 'auto', [CU_ENDPOINT]: '' }, + { content_understanding_supported: true }, + calls, + ); + assert.deepEqual(calls, [ + 'capability:enable_enhanced_extraction', + `field:${MODE_KEY}`, + 'field:enhanced-extraction-engine', + `field:${CU_ENDPOINT}`, + `field:${CU_AUTH}`, + ], 'the connection renders; the analyzers stay folded away'); + assert.match(markup, /aria-expanded="true"[^>]*>[\s\S]*?Content Understanding connection/); + assert.match(markup, /aria-expanded="false"[^>]*>[\s\S]*?Content Understanding analyzers/); + assert.match(markup, /Configured/, 'Layout is a working setup, so the chip does not cry wolf'); +}); + +check('a connected Content Understanding folds into a summary', () => { + const calls: string[] = []; + const markup = renderEnhancedExtraction( + { enable_enhanced_extraction: true, [MODE_KEY]: 'auto', [CU_ENDPOINT]: 'https://cu.example.invalid' }, + { content_understanding_supported: true }, + calls, + ); + assert.doesNotMatch(markup, /aria-expanded="true"/); + assert.deepEqual(calls, [ + 'capability:enable_enhanced_extraction', + `field:${MODE_KEY}`, + 'field:enhanced-extraction-engine', + ]); +}); + +check('runtime flags reach the card, so a cloud without the service never shows it', () => { + const settings = { enable_enhanced_extraction: true, [MODE_KEY]: 'auto', [CU_ENDPOINT]: '' }; + + const sovereign = renderEnhancedExtraction(settings, { content_understanding_supported: false }, []); + assert.doesNotMatch(sovereign, /Content Understanding connection|Content Understanding analyzers/); + + // Without the flags every flag-gated field reads as unmet and is dropped, which is + // why the page has to hand them to the card rather than only filtering itself. + const unflagged = renderEnhancedExtraction(settings, undefined, []); + assert.doesNotMatch(unflagged, /Content Understanding connection/); + + const supported = renderEnhancedExtraction(settings, { content_understanding_supported: true }, []); + assert.match(supported, /Content Understanding connection/); +}); + let passed = 0; for (const [name, fn] of checks) { try { diff --git a/functional_tests/test_v2_admin_settings_schema.py b/functional_tests/test_v2_admin_settings_schema.py index db7cd4614..b0ed77593 100644 --- a/functional_tests/test_v2_admin_settings_schema.py +++ b/functional_tests/test_v2_admin_settings_schema.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for the Admin Settings field schema shape. -Version: 0.261.105 +Version: 0.261.260 Implemented in: 0.261.039 +Content Understanding runtime flag recognised in: 0.261.260 The V2 admin surface renders whatever ``admin_settings_fields.py`` declares. A malformed entry does not raise anything server-side; it produces a control that @@ -43,8 +44,9 @@ # Runtime flags the settings API sends alongside the schema. A field may depend on # one of these instead of on another field, for a capability gated outside the -# settings document. -RUNTIME_FLAGS = {"mcp_ui_enabled"} +# settings document: Inbound MCP by an App Service setting, Content Understanding +# by whether the Azure cloud offers it. +RUNTIME_FLAGS = {"mcp_ui_enabled", "content_understanding_supported"} # Properties every field type must carry beyond the common ones, because the # renderer cannot draw the control without them. diff --git a/ui_tests/test_v2_admin_enhanced_extraction_section.py b/ui_tests/test_v2_admin_enhanced_extraction_section.py new file mode 100644 index 000000000..c7feefbf4 --- /dev/null +++ b/ui_tests/test_v2_admin_enhanced_extraction_section.py @@ -0,0 +1,197 @@ +# test_v2_admin_enhanced_extraction_section.py +""" +Browser coverage for the V2 Admin Settings Enhanced Extraction section. +Version: 0.261.260 +Implemented in: 0.261.260 + +Exercise the built application with the real field schema and intercepted APIs. +Check that Enhanced extraction is the switch that leads its own card rather than a +control inside a collapsed Document Intelligence group, that nothing it governs -- +Content Understanding included -- can be edited while it is off, that turning it on +moves the mode to Auto and opens the Content Understanding connection, that the card +names the engine in force as the connection is filled in, and that a cloud without +Content Understanding shows the Layout fallback instead. No live settings are written. +""" + +import re +import sys +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +# Fixtures stay under ui_tests; the import also registers Azure connect_options. +sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures")) + +from v2_admin_settings import AdminSettingsFixture, connect_options # noqa: E402,F401 + + +pytestmark = pytest.mark.ui + +SECTIONS = {"knowledge": ("document-intelligence-section", "enhanced-extraction-section")} +REGION = "Enhanced Extraction" +SWITCH = "Enable Enhanced extraction" +MODE = "document_intelligence_pdf_image_extraction_mode" +ARTIFACTS = "v2_admin_enhanced_extraction" + + +@pytest.fixture +def extraction_ui(page): + fixture = AdminSettingsFixture(page, sections=SECTIONS, validate_updates=True) + fixture.payload["runtime_flags"] = {"content_understanding_supported": True} + yield fixture + fixture.assert_clean() + + +def _region(page): + return page.get_by_role("region", name=REGION, exact=True) + + +def _switch(scope): + return scope.get_by_role("checkbox", name=re.compile(f"^{re.escape(SWITCH)}")) + + +def _set_switch(page, checked): + switch = _switch(_region(page)) + if switch.is_checked() != checked: + _region(page).get_by_text(SWITCH, exact=True).click() + expect(switch).to_be_checked(checked=checked) + + +def _group(page, label): + return _region(page).get_by_role("button", name=re.compile(f"^{re.escape(label)}")) + + +def _engine(page): + return _region(page).get_by_test_id("enhanced-extraction-engine") + + +def _save(fixture): + fixture.page.get_by_role("button", name="Save changes", exact=True).click() + expect(fixture.page.get_by_role("button", name="Save changes", exact=True)).to_have_count(0) + + +def test_with_enhanced_off_the_card_is_only_its_switch(extraction_ui): + """Content Understanding cannot be filled in while Enhanced extraction is off.""" + extraction_ui.open(ready_region=REGION) + page = extraction_ui.page + region = _region(page) + + expect(_switch(region)).not_to_be_checked() + expect(region.get_by_text("Off", exact=True)).to_be_visible() + expect(region.get_by_role("combobox")).to_have_count(0) + expect(region.get_by_role("textbox")).to_have_count(0) + expect(_group(page, "Content Understanding connection")).to_have_count(0) + # The switch now leads its own card instead of hiding in a Document + # Intelligence group that had to be opened to find it. + intelligence = page.get_by_role("region", name="Document Intelligence", exact=True) + expect(_switch(intelligence)).to_have_count(0) + extraction_ui.capture("off", folder=ARTIFACTS) + + +def test_turning_enhanced_on_brings_auto_and_the_connection(extraction_ui): + """The next steps appear with the switch, and the engine follows the connection.""" + extraction_ui.open(ready_region=REGION) + page = extraction_ui.page + region = _region(page) + + _set_switch(page, True) + + expect(region.get_by_role("combobox", name="PDF and Image Extraction Mode")).to_have_value("auto") + expect(_group(page, "Content Understanding connection")).to_have_attribute("aria-expanded", "true") + expect(_group(page, "Content Understanding analyzers")).to_have_attribute("aria-expanded", "false") + engine = _engine(page) + expect(engine).to_have_attribute("data-engine", "document_intelligence") + expect(engine).to_contain_text("Add a Foundry endpoint") + expect(engine).to_contain_text("Takes effect when you save.") + + region.get_by_role("textbox", name="Foundry Endpoint").fill("https://contoso.services.ai.azure.com") + expect(engine).to_contain_text("an endpoint but no key") + + region.get_by_role("combobox", name="Authentication Type").select_option("managed_identity") + expect(engine).to_have_attribute("data-engine", "content_understanding") + expect(engine).to_contain_text("Azure AI Content Understanding") + extraction_ui.capture("on-connected-unsaved", folder=ARTIFACTS) + + _save(extraction_ui) + assert extraction_ui.patches == [{ + "enable_enhanced_extraction": True, + MODE: "auto", + "azure_content_understanding_endpoint": "https://contoso.services.ai.azure.com", + "azure_content_understanding_authentication_type": "managed_identity", + }], extraction_ui.patches + expect(engine).not_to_contain_text("Takes effect when you save.") + expect(region.get_by_text("Configured", exact=True)).to_be_visible() + + +def test_turning_enhanced_back_off_before_saving_takes_the_mode_back(extraction_ui): + """The draft only carries what the administrator chose.""" + extraction_ui.open(ready_region=REGION) + page = extraction_ui.page + + _set_switch(page, True) + expect(_region(page).get_by_role("combobox", name="PDF and Image Extraction Mode")).to_have_value("auto") + _set_switch(page, False) + expect(_region(page).get_by_role("combobox")).to_have_count(0) + + _save(extraction_ui) + assert extraction_ui.patches == [{"enable_enhanced_extraction": False}], extraction_ui.patches + + +def test_a_connected_content_understanding_reads_as_a_summary(extraction_ui): + """A working connection folds away, and the card says what it gives.""" + extraction_ui.settings.update({ + "enable_enhanced_extraction": True, + MODE: "auto", + "azure_content_understanding_endpoint": "https://contoso.services.ai.azure.com", + "azure_content_understanding_authentication_type": "managed_identity", + }) + extraction_ui.open(ready_region=REGION) + page = extraction_ui.page + + engine = _engine(page) + expect(engine).to_have_attribute("data-engine", "content_understanding") + expect(engine).to_contain_text("descriptions of figures and charts") + expect(engine).not_to_contain_text("Takes effect when you save.") + expect(_group(page, "Content Understanding connection")).to_have_attribute("aria-expanded", "false") + + _group(page, "Content Understanding connection").click() + expect(_region(page).get_by_role("textbox", name="Foundry Endpoint")).to_have_value( + "https://contoso.services.ai.azure.com" + ) + + +def test_a_cloud_without_content_understanding_shows_the_layout_fallback(extraction_ui): + """Azure Government and custom clouds have nothing to configure beyond the switch.""" + extraction_ui.payload["runtime_flags"] = {"content_understanding_supported": False} + extraction_ui.settings.update({"enable_enhanced_extraction": True, MODE: "auto"}) + extraction_ui.open(ready_region=REGION) + page = extraction_ui.page + + engine = _engine(page) + expect(engine).to_have_attribute("data-engine", "document_intelligence") + expect(engine).to_contain_text("not offered in this Azure cloud") + expect(engine).to_contain_text("nothing more to configure") + expect(_group(page, "Content Understanding connection")).to_have_count(0) + expect(_group(page, "Content Understanding analyzers")).to_have_count(0) + expect(_region(page).get_by_role("combobox", name="PDF and Image Extraction Mode")).to_be_visible() + + +@pytest.mark.parametrize("width", [390, 1440]) +def test_the_card_fits_phone_and_desktop_widths(extraction_ui, width): + """The switch leads its settings, and nothing runs past the card at either width.""" + extraction_ui.settings.update({"enable_enhanced_extraction": True, MODE: "auto"}) + extraction_ui.open(width=width, ready_region=REGION) + page = extraction_ui.page + + region = _region(page) + expect(region.locator('[data-setting-emphasis="primary"]')).to_contain_text(SWITCH) + # Mode, Auto's sample pages and formulas sit beneath the switch they depend on. + expect(region.locator('[data-setting-emphasis="dependent"]')).to_have_count(3) + expect(_engine(page)).to_be_visible() + expect(_group(page, "Content Understanding connection")).to_have_attribute("aria-expanded", "true") + + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + overflow = region.evaluate("element => element.scrollWidth - element.clientWidth") + assert overflow <= 1, f"The card overflows by {overflow}px at {width}px" + extraction_ui.capture(f"on-unconnected-{width}", folder=ARTIFACTS)