diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index d3880036a..96bf8ce13 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 @@ -1844,9 +1861,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": [ { @@ -1864,8 +1883,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." ), }, }, @@ -1935,11 +1955,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", @@ -1964,15 +1988,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": [ @@ -1986,7 +2056,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", @@ -2000,53 +2070,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", @@ -2061,18 +2141,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", @@ -2080,7 +2200,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", @@ -2088,7 +2216,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", @@ -2096,29 +2232,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": [ @@ -6587,23 +6712,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 @@ -6611,18 +6743,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: @@ -7573,6 +7715,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) @@ -7670,6 +7816,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 bb746dac7..6bd64619f 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.264" +VERSION = "0.261.265" 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 b6ee42140..8c76a5f5d 100644 --- a/application/v2_ui/src/components/admin/SettingsSection.tsx +++ b/application/v2_ui/src/components/admin/SettingsSection.tsx @@ -114,6 +114,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; } @@ -296,6 +303,7 @@ export function SettingsSection({ renderCapability, forceExpanded, appearance, + runtimeFlags, children, }: SettingsSectionProps) { const capability = useMemo(() => findCapabilityField(fields), [fields]); @@ -328,8 +336,10 @@ export function SettingsSection({ ); const groups = useMemo( - () => groupFields(bodyFields.filter((field) => isFieldVisible(field, settings, draft, fieldsByKey))), - [bodyFields, settings, draft, fieldsByKey], + () => groupFields( + bodyFields.filter((field) => isFieldVisible(field, settings, draft, fieldsByKey, runtimeFlags)), + ), + [bodyFields, settings, draft, fieldsByKey, runtimeFlags], ); const presentation = presentSectionStatus(status); @@ -458,7 +468,12 @@ export function SettingsSection({ readSectionValue(settings, draft, key, fieldsByKey), + )} forceExpanded={forceExpanded} renderFields={renderFields} /> diff --git a/application/v2_ui/src/lib/adminFields.ts b/application/v2_ui/src/lib/adminFields.ts index 083110b64..0f1a41450 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`. */ @@ -482,6 +507,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[]; } @@ -509,10 +536,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); } @@ -794,6 +826,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 = readSettingValue(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 e0005ff7a..496866c39 100644 --- a/application/v2_ui/src/lib/adminSections.ts +++ b/application/v2_ui/src/lib/adminSections.ts @@ -314,11 +314,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 @@ -333,5 +339,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 1f92be731..359f91189 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, @@ -96,6 +98,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'; @@ -590,6 +596,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) => { @@ -617,8 +627,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(() => { @@ -1075,6 +1088,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..c630dfe6d 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.265 Implemented in: 0.261.084 +Enhanced Extraction section added in: 0.261.265 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.265") 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..64e137318 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.265 Implemented in: 0.261.084 +Runtime flag evaluation, open_until_set and on_enable added in: 0.261.265 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 d3682cb3d..ddbe852c1 100644 --- a/functional_tests/test_v2_admin_section_logic.ts +++ b/functional_tests/test_v2_admin_section_logic.ts @@ -1,11 +1,12 @@ // test_v2_admin_section_logic.ts // // Runtime test for the Admin Settings section shell's presentation decisions. -// Version: 0.261.260 +// Version: 0.261.265 // 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 // Values saved at a nested path covered in: 0.261.260 +// Enhanced extraction engine, open_until_set and on_enable coverage added in: 0.261.265 // // 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 @@ -29,6 +30,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, @@ -46,13 +48,16 @@ import { shouldGroupStartOpen, } from '../application/v2_ui/src/lib/adminSections'; import { + applyEnableEffect, buildFieldIndex, 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) { @@ -688,6 +693,312 @@ check('the shell shows a field gated on a value saved inside a container', () => ); }); +// --- 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..bfe31ea70 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.265 Implemented in: 0.261.039 +Content Understanding runtime flag recognised in: 0.261.265 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..c3372a9ed --- /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.265 +Implemented in: 0.261.265 + +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)