diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index 59e2876ef..d3880036a 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -1093,6 +1093,55 @@ }, "group": {"id": "connection", "label": "Connection", "variant": "connection"}, }, + # Declared here because the fallback scan matched "search" and filed the + # switch under Web Search, which reaches the public internet. This cache sits + # in front of the workspace indexes configured above. + { + "key": "enable_search_result_caching", + "type": "switch", + "label": "Cache workspace search results", + "help": ( + "Reuses the results of a recent identical search, so a repeated question " + "returns faster, cites the same sources, and skips the query embedding and " + "the semantic-ranked Azure AI Search queries. Results are reused only while " + "the query, scope, filters, embedding model and the documents in scope, " + "including their versions, still match, and they are re-checked against the " + "requesting user's access before they are returned. Leave it on; turn it off " + "only to make every search run fresh while you troubleshoot relevance." + ), + "default": True, + "group": { + "id": "search-result-cache", + "label": "Search result cache", + "variant": "behavior", + "help": ( + "Applies to the workspace document searches behind grounded chat, " + "agents and workflows, not to web search. Cached results are kept in " + "the search_cache Cosmos DB container, so Redis is not required." + ), + }, + }, + { + "key": "search_cache_ttl_seconds", + "type": "number", + "label": "Cache lifetime (seconds)", + "help": ( + "How long a cached result set can be reused. Adding, deleting, re-sharing " + "or re-versioning a document forces a fresh search straight away. Other " + "changes, such as a document that finishes processing after the search ran " + "or an edit made directly in Azure AI Search, can take up to this long to " + "appear. 300 seconds (5 minutes) by default." + ), + "default": 300, + "min": 60, + "max": 3600, + "group": { + "id": "search-result-cache", + "label": "Search result cache", + "variant": "behavior", + }, + "depends_on": {"key": "enable_search_result_caching", "equals": True}, + }, ], # ------------------------------------------------------------------ # Knowledge / Web & Research @@ -6241,6 +6290,17 @@ "Same as enable_app_maintenance: no server-rendered control, and misfiled " "into Security by the fallback scan until it was declared." ), + "enable_search_result_caching": ( + "The classic Azure AI Search card showed this switch briefly in late 2025, " + "but the classic save handler never stored it and the control was removed. " + "Declared under Azure AI Search so the fallback scan stops filing it under " + "Web Search, which it matched on the shared word stem 'search'." + ), + "search_cache_ttl_seconds": ( + "Read by utils_cache.get_cache_settings() but editable in neither interface " + "since the classic control was removed alongside the caching switch. " + "Declared with that switch so the cache lifetime can be tuned." + ), } diff --git a/application/single_app/config.py b/application/single_app/config.py index 7ac2402a5..a032926d5 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.262" +VERSION = "0.261.263" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/docs/admin/index.md b/docs/admin/index.md index 7edc0fff4..7367a5b55 100644 --- a/docs/admin/index.md +++ b/docs/admin/index.md @@ -88,6 +88,6 @@ This layout was implemented in **0.261.258**. | Governance | Governance controls review policy for personal, group, and global endpoints, agents, actions, and MCP destinations. | `feature-governance`, `governance-policies`, `mcp-governance` | [Governance settings]({{ '/admin/governance/' | relative_url }}) | | Data Lifecycle | Data Lifecycle groups retention, classification, and conversation archiving decisions. | `retention`, `classification`, `archiving` | [Data Lifecycle settings]({{ '/admin/data-lifecycle/' | relative_url }}) | | Backup & Recovery | Backup & Recovery contains backup readiness, scheduled backups, migration, restore, backup inventory, job history, and Cosmos JSON repair tools. | `backup`, `migrate`, `restore`, `cosmos-editor`, `jobs` | [Backup & Recovery settings]({{ '/admin/backup-recovery/' | relative_url }}) | -| Scale | Scale covers Redis, conversation and search caches, document access indexing, Cosmos maintenance, and Cosmos throughput automation. | `redis-caching`, `cosmos` | [Scale settings]({{ '/admin/scale/' | relative_url }}) | +| Scale | Scale covers Redis, the conversation and document list caches, document access indexing, Cosmos maintenance, and Cosmos throughput automation. | `redis-caching`, `cosmos` | [Scale settings]({{ '/admin/scale/' | relative_url }}) | | Operations | Operations collects Control Center access, refresh behavior, Application Insights, debug logging, file-processing logs, health checks, and Swagger documentation. | `control-center-config`, `logging` | [Operations settings]({{ '/admin/operations/' | relative_url }}) | | Help | Help controls end-user support navigation, Send Feedback destinations, and Latest Features cards. | `support-menu`, `send-feedback`, `latest-features` | [Help settings]({{ '/admin/help/' | relative_url }}) | diff --git a/docs/admin/knowledge.md b/docs/admin/knowledge.md index a261c74b3..134ab9632 100644 --- a/docs/admin/knowledge.md +++ b/docs/admin/knowledge.md @@ -107,6 +107,16 @@ In the classic interface, opening Admin Settings checks the three indexes in seq Connection and permission failures stay visible rather than being hidden as if the index were healthy. Configure either the direct Search endpoint and authentication method, or the APIM endpoint and subscription key. Public Azure uses the Search audience `https://search.azure.com`; government and custom-cloud audiences remain separate. See [Admin settings troubleshooting]({{ '/troubleshooting/#admin-settings-saves-and-connection-tests' | relative_url }}). +#### Search result cache + +Workspace document searches, the retrieval step behind grounded chat answers, agents, and workflows, are cached in the `search_cache` Cosmos DB container. When the same search runs again against the same documents, SimpleChat reuses the earlier results instead of embedding the query and running the semantic-ranked hybrid queries against each workspace index again. Repeated questions come back faster and cite the same sources, and fewer embedding and semantic ranker requests are billed or counted against quota. The cache does not need Redis, and it does not apply to web search. + +A cached result is reused only while the query, scope, selected documents, tag filters, embedding model, and the documents in scope, including their versions and content screening state, all still match. Adding, deleting, re-sharing, or re-versioning a document therefore forces a fresh search straight away. Before cached results are returned, each one is re-checked against the requesting user's access and the document's current availability, and if any no longer qualifies the search runs fresh. + +Caching is on by default and should stay on. Turn it off only while troubleshooting search relevance, when every search needs to run fresh. The cache lifetime bounds how long other changes can take to appear, such as a document that finishes processing after a search ran, or an edit made directly in Azure AI Search outside SimpleChat. + +Both settings are edited in the V2 admin settings, in the **Search result cache** group of this section. The classic admin page has no control for them. + #### Settings | Setting | What it does | Default | Notes | @@ -117,6 +127,8 @@ Connection and permission failures stay visible rather than being hidden as if t | Search Key | Provides the secret credential used when the selected authentication mode requires one. | Empty | `azure_ai_search_key` | | Azure APIM AI Search Endpoint | Provides the endpoint or route SimpleChat uses for this service. | Empty | `azure_apim_ai_search_endpoint` | | Azure APIM AI Search Subscription Key | Provides the secret credential used when the selected authentication mode requires one. | Empty | `azure_apim_ai_search_subscription_key` | +| Cache workspace search results | Reuses a recent identical workspace search instead of embedding the query and querying the indexes again. Cached results are re-checked against the user's access before they are returned. | On | `enable_search_result_caching`; V2 admin only | +| Cache lifetime (seconds) | How long cached results can be reused, from 60 to 3,600 seconds. Shown while caching is on. | 300 | `search_cache_ttl_seconds`; V2 admin only | ## Document Extraction {#extraction} @@ -426,6 +438,7 @@ The Public Workspace Sync section belongs to the File Sync tab. Use it with the | Symptom | Likely cause | Fix | | --- | --- | --- | | A synced file is not searchable | The source ran but extraction or indexing failed later. | Check sync state, extraction settings, and Azure AI Search before rerunning. | +| A repeated question still returns the old sources after a change made directly in Azure AI Search | The search result cache is reusing results from before the change, which it cannot detect. | Wait for the cache lifetime to pass. To check the change sooner, rephrase the question, because different wording is a separate cache entry. | ## Related diff --git a/docs/admin/scale.md b/docs/admin/scale.md index f9c033ab8..258f48890 100644 --- a/docs/admin/scale.md +++ b/docs/admin/scale.md @@ -1,7 +1,7 @@ --- layout: page title: "Scale settings" -description: "Scale covers Redis, conversation and search caches, document access indexing, Cosmos maintenance, and Cosmos throughput automation." +description: "Scale covers Redis, the conversation and document list caches, document access indexing, Cosmos maintenance, and Cosmos throughput automation." section: "Administration" audience: admin admin_tab: scale @@ -12,7 +12,7 @@ admin_tab: scale ## What this group controls -Scale covers Redis, conversation and search caches, document access indexing, Cosmos maintenance, and Cosmos throughput automation. +Scale covers Redis, the conversation and document list caches, document access indexing, Cosmos maintenance, and Cosmos throughput automation. ## Why it matters @@ -30,6 +30,8 @@ Scale settings trade latency, freshness, and Azure spend. Caches can make the ap ## Redis & Caching {#redis-caching} +The workspace search result cache is not configured here. It is part of Azure AI Search on the [Knowledge settings]({{ '/admin/knowledge/#azure-ai-search-section' | relative_url }}) page, and it is stored in Cosmos DB rather than Redis. + ### Redis Cache {#redis-cache-section} SimpleChat connects to either Azure Managed Redis or Azure Cache for Redis. The two services @@ -156,7 +158,6 @@ The Cosmos Metrics section belongs to the Cosmos tab. Use it with the adjacent s | --- | --- | --- | --- | | Key Filter | Blank filter browses all keys. Filters are case sensitive. Redis SCAN order is server-defined, so use Next Page to keep browsing. | N/A (runtime control) | Runtime UI control | | Page Size | Defines behavior for the related admin workflow; verify the affected feature after saving. | Not specified in defaults | Runtime UI control | -| Search result caching | Caches workspace search-result payloads with document-set fingerprints so repeated personal, group, public, or all-scope searches can reuse results until document changes or the TTL invalidate them. | On | `enable_search_result_caching`; no visible field in `admin_settings.html` | | Write-through projection | Exposes the capability after required services, permissions, and rollout policy are ready. | On | `enable_document_access_index_write_through`; capability toggle | | Automatic repair/backfill | Exposes the capability after required services, permissions, and rollout policy are ready. | On | `enable_startup_document_access_index_backfill`; capability toggle | | Enable shadow validation | Exposes the capability after required services, permissions, and rollout policy are ready. | Off | `enable_document_access_index_shadow_validation`; capability toggle | diff --git a/docs/explanation/features/V2_ADMIN_KNOWLEDGE_SETTINGS.md b/docs/explanation/features/V2_ADMIN_KNOWLEDGE_SETTINGS.md index 5b41ea28b..39a8a0017 100644 --- a/docs/explanation/features/V2_ADMIN_KNOWLEDGE_SETTINGS.md +++ b/docs/explanation/features/V2_ADMIN_KNOWLEDGE_SETTINGS.md @@ -138,6 +138,12 @@ that the two interfaces order or group them identically. The intentional differe `get_source_review_config` rewrites any other value on read. - The Video Indexer cloud selector not reproduced. It has no stored setting of its own, existing only to compute `video_indexer_endpoint`, which V2 edits directly. +- The search result cache is V2-only. Added in **0.261.263**, `enable_search_result_caching` + and `search_cache_ttl_seconds` form a **Search result cache** group after the Azure AI + Search connection, with an explanation of what is cached, what forces a fresh search, and + why to leave it on. The classic page dropped its short-lived control, which never saved, + in late 2025. Until the switch was declared, the fallback scan matched "search" and filed + it under Web Search as a bare toggle. Both keys are recorded in `V2_ONLY_FIELDS`. ## Testing @@ -151,6 +157,8 @@ that the two interfaces order or group them identically. The intentional differe | `test_v2_admin_knowledge_extraction.py` | Parity, connection-first ordering, chunk size storage | | `test_v2_admin_knowledge_audio_video.py` | Parity, audio cue relocation, shared Speech disclosure | | `test_v2_admin_knowledge_file_sync.py` | Parity, Redis prerequisite, GB/bytes conversion, assignments | +| `test_search_result_cache_admin_setting.py` | Search result cache placement, defaults against the runtime, lifetime bounds, V2-only record | +| `ui_tests/test_v2_admin_search_result_cache.py` | The cache group renders under Azure AI Search, hides its lifetime while off, and saves through the normalizer | | `test_model_vision_capability_resolution.py` | Three-tier resolution and its precedence | ## Known limitations diff --git a/docs/explanation/fixes/SEARCH_RESULT_CACHE_ADMIN_SETTING_FIX.md b/docs/explanation/fixes/SEARCH_RESULT_CACHE_ADMIN_SETTING_FIX.md new file mode 100644 index 000000000..b5e05fc57 --- /dev/null +++ b/docs/explanation/fixes/SEARCH_RESULT_CACHE_ADMIN_SETTING_FIX.md @@ -0,0 +1,105 @@ +# Search Result Cache Admin Setting Fix + +## Issue + +V2 Admin Settings showed **Search result caching** as a bare switch under +**Knowledge › Web & Research › Web Search**. It had no description, only the label built +from its key and the raw key `enable_search_result_caching`. Administrators could not tell +what it cached, whether it should be on, or why it sat beside a setting that sends messages +to the public internet. The cache's lifetime, `search_cache_ttl_seconds`, had no control in +either admin interface. + +## Root cause + +The key had no field in `admin_settings_fields.py`, so the V2 surface found it through its +`enable_*` fallback scan (`buildCapabilityIndex` in `AdminSettingsPage.tsx`). That scan files +a key under the section whose id shares the most word stems with it. "search" scored one +point in both `web-search-section` and `azure-ai-search-section`. Web Search comes first in +navigation order and the first section to reach the top score keeps it, so the cache landed +in Web Search. A switch found that way can only show the key's name. + +The setting caches workspace document searches against Azure AI Search, not web search. The +classic Azure AI Search card briefly offered both controls in late 2025, but the classic save +handler never stored them and they were removed, so neither key has had a working control +since. + +## Fixed in version: **0.261.263** + +The application version is maintained in `application/single_app/config.py`. + +## Technical details + +### Files modified + +| File | Change | +| --- | --- | +| `application/single_app/admin_settings_fields.py` | Declares both settings in a **Search result cache** group of `azure-ai-search-section`, and records them in `V2_ONLY_FIELDS`. | +| `application/single_app/config.py` | Version 0.261.263. | +| `functional_tests/test_v2_admin_capability_placement.py` | Pins the relocation in `RELOCATED_CAPABILITIES_WITHOUT_V1_FIELD`. | +| `functional_tests/test_search_result_cache_admin_setting.py` | New. | +| `ui_tests/test_v2_admin_search_result_cache.py` | New. | +| `docs/admin/knowledge.md`, `docs/admin/scale.md`, `docs/admin/index.md` | Explain the cache with Azure AI Search, and remove the row that described it under Scale. | + +### Code changes + +- **Cache workspace search results** (`enable_search_result_caching`, switch, default on) + explains that a repeated identical search reuses the earlier results and skips the query + embedding and the semantic-ranked Azure AI Search queries. It also says what has to match + for a cached result to be reused, that cached results are re-checked against the + requesting user's access, and that the only reason to turn it off is to force fresh + searches while troubleshooting relevance. +- **Cache lifetime (seconds)** (`search_cache_ttl_seconds`, default 300, 60 to 3,600) is + shown only while caching is on. Saves are clamped to that range by the existing number + normalizer. +- The group follows the Connection group, so the section still leads with its endpoint and + credentials. Its description says the cache covers the searches behind grounded chat, + agents and workflows, not web search, and that it uses Cosmos DB rather than Redis. +- Declaring the key takes it out of the fallback scan, so the Web Search row is gone. + +### Default + +No default changed. Caching has defaulted to on since it was introduced, in both +`functions_settings.py` and the fallback in `utils_cache.get_cache_settings()`, and it should +stay on: + +- A cache hit saves an embedding call and one semantic-ranked hybrid query per workspace + index in scope, and the semantic ranker is billed and quota-limited per query. +- A hit returns the same sources for the same question. The semantic ranker alone does not + guarantee that. +- It is safe to reuse: the cache key includes the documents in scope with their versions and + content screening state, and every cached result is re-authorized for the requesting user + before it is returned. + +A value an administrator explicitly saved as off is left as is. + +## Validation + +- `functional_tests/test_search_result_cache_admin_setting.py` checks placement and field + order, the labels and explanations, the lifetime's bounds and visibility, that the schema + defaults match `functions_settings.py` and `utils_cache.py`, PATCH clamping, and that no + classic form submits either key. +- `functional_tests/test_v2_admin_capability_placement.py` fails if the switch is undeclared + and falls back to the stem-matched guess. +- `ui_tests/test_v2_admin_search_result_cache.py` renders the built V2 page with the real + schema. It checks that the group explains the cache with the switch on and the lifetime at + 300, that the Web Search card shows nothing of the old row, that turning caching off hides + the lifetime and saves `false`, and that a lifetime of 5 saves as 60. + +Before: a key-named switch under Web Search, and no way to change the lifetime. After: an +explained **Search result cache** group inside Azure AI Search, with the lifetime beside it. + +## Known follow-ups + +These existed before this change and were not altered by it: + +- `invalidate_group_search_cache` and `invalidate_public_workspace_search_cache` filter on + `CONTAINS(c.doc_scope, )`, but `doc_scope` stores only the scope name. Unless the group + call passes `document_id`, they delete nothing. Document fingerprints still keep cached + results correct, so the cache lifetime is what bounds changes that do not alter a + document's version. +- The `search_cache` container has no Cosmos TTL. Expired entries are deleted only when the + same search is read again or a personal invalidation runs, so entries for searches that are + never repeated accumulate. + +Related: [Knowledge Settings in the V2 Admin Surface](../features/V2_ADMIN_KNOWLEDGE_SETTINGS.md), +[Search Result Caching](../features/v0.235.001/SEARCH_RESULT_CACHING.md). diff --git a/docs/explanation/fixes/index.md b/docs/explanation/fixes/index.md index a3ca6cf0d..4d021f0ba 100644 --- a/docs/explanation/fixes/index.md +++ b/docs/explanation/fixes/index.md @@ -6,6 +6,7 @@ order: 120 category: Version History --- +- [Search Result Cache Admin Setting Fix](SEARCH_RESULT_CACHE_ADMIN_SETTING_FIX.md) - [Deleted Messages Shown as Masked Fix](SOFT_DELETED_MESSAGES_SHOWN_AS_MASKED_FIX.md) - [Shared Conversation Diagram Editing Fix](SHARED_CONVERSATION_DIAGRAM_EDITING_FIX.md) - [Chat Document Search File Name and Divider Artifact Fix](CHAT_DOCUMENT_SEARCH_FILENAME_AND_DIVIDER_FIX.md) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index f2d9ce564..0bb0dde14 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,15 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.263)** + +#### User Interface Enhancements + +* **Search Result Cache Explained Under Azure AI Search** + * V2 Admin Settings now shows the workspace search result cache in a **Search result cache** group under **Knowledge › Search Index › Azure AI Search**. The group explains what is cached, what forces a fresh search, and why the cache should stay on. Previously the fallback scan matched the word "search" and placed it under Web Search as a bare switch labelled with its key, `enable_search_result_caching`. + * **Cache lifetime (seconds)** can now be changed, from 60 to 3,600 seconds with a default of 300. It appears only while caching is on. Caching stays on by default, and a value an administrator saved as off is kept. + * (Ref: `admin_settings_fields.py`, `test_search_result_cache_admin_setting.py`, `test_v2_admin_search_result_cache.py`, [Search Result Cache Admin Setting Fix](fixes/SEARCH_RESULT_CACHE_ADMIN_SETTING_FIX.md)) + ### **(v0.261.262)** #### User Interface Enhancements diff --git a/functional_tests/test_search_result_cache_admin_setting.py b/functional_tests/test_search_result_cache_admin_setting.py new file mode 100644 index 000000000..7b27c3692 --- /dev/null +++ b/functional_tests/test_search_result_cache_admin_setting.py @@ -0,0 +1,298 @@ +# test_search_result_cache_admin_setting.py +#!/usr/bin/env python3 +""" +Functional test for the search result cache admin setting. +Version: 0.261.263 +Implemented in: 0.261.263 + +``enable_search_result_caching`` had no declared field, so the V2 admin +surface's ``enable_*`` fallback scan drew it as a bare switch labelled from its +key. The scan matched "search" in both Web Search and Azure AI Search, and Web +Search won the tie, so a cache that sits in front of the workspace indexes was +filed beside the setting that reaches the public internet. Its lifetime, +``search_cache_ttl_seconds``, was read at runtime but editable nowhere. + +This test ensures both settings are declared together under Azure AI Search, +explain what they do, start from the same defaults the application applies, +save within safe bounds, and are recorded as V2-only rather than appearing in +one interface by accident. +""" + +import ast +import re +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent)) + +from test_support.app_stubs import import_app_module +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = REPO_ROOT / "application" / "single_app" +SETTINGS_MODULE = APP_ROOT / "functions_settings.py" +CACHE_MODULE = APP_ROOT / "utils_cache.py" +TEMPLATES_ROOT = APP_ROOT / "templates" + +SECTION_ID = "azure-ai-search-section" +GROUP_ID = "search-result-cache" +SWITCH_KEY = "enable_search_result_caching" +LIFETIME_KEY = "search_cache_ttl_seconds" +EXPECTED_DEFAULTS = {SWITCH_KEY: True, LIFETIME_KEY: 300} + +APP_DEFAULT_RE = re.compile( + r"^\s*'(?P[a-z0-9_]+)'\s*:\s*(?PTrue|False|-?\d+)\s*,", + re.MULTILINE, +) + +fields_module = import_app_module("admin_settings_fields") +normalize = fields_module.normalize_admin_settings_updates + + +def section_fields(section_id): + return [ + field + for declared_section, field in fields_module.iter_fields() + if declared_section == section_id + ] + + +def group_id_of(field): + group = field.get("group") + if isinstance(group, str): + return group + return (group or {}).get("id") + + +def declared_field(key): + matches = [ + (section_id, field) + for section_id, field in fields_module.iter_fields() + if field.get("key") == key + ] + assert len(matches) == 1, f"{key} must be declared exactly once, found {len(matches)}." + return matches[0] + + +def read_runtime_fallbacks(): + """Return the defaults ``utils_cache.get_cache_settings`` applies. + + ``utils_cache`` imports ``config`` and a live Cosmos client, so the function + is read from source rather than imported. + """ + tree = ast.parse(CACHE_MODULE.read_text(encoding="utf-8")) + function = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "get_cache_settings" + ) + + lookups = {} + for node in ast.walk(function): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and len(node.args) == 2 + and all(isinstance(arg, ast.Constant) for arg in node.args) + ): + lookups[node.args[0].value] = node.args[1].value + + handler_returns = [ + tuple(element.value for element in node.value.elts) + for handler in ast.walk(function) + if isinstance(handler, ast.ExceptHandler) + for node in ast.walk(handler) + if isinstance(node, ast.Return) + and isinstance(node.value, ast.Tuple) + and all(isinstance(element, ast.Constant) for element in node.value.elts) + ] + return lookups, handler_returns + + +def test_both_settings_are_declared_together_under_azure_ai_search(): + """Declaring the key is what takes it out of the fallback scan's guess.""" + print("Testing where the search result cache is declared...") + + assert_app_version_at_least("0.261.263") + + for key in (SWITCH_KEY, LIFETIME_KEY): + section_id, field = declared_field(key) + assert section_id == SECTION_ID, f"{key} is declared under {section_id!r}" + assert group_id_of(field) == GROUP_ID, f"{key} is not in the {GROUP_ID!r} group" + + declared = fields_module.get_declared_setting_keys() + suppressed = set(fields_module.get_suppressed_capability_keys()) + assert SWITCH_KEY in declared, "The fallback scan would still guess a home for the switch." + assert SWITCH_KEY not in suppressed, "The switch is an editable setting, not a suppressed one." + + print(f" Both keys are declared under {SECTION_ID} in the {GROUP_ID} group.") + return True + + +def test_the_connection_stays_first_and_the_cache_follows_it(): + """An administrator configuring search needs the endpoint before the tuning.""" + print("\nTesting the Azure AI Search field order...") + + fields = section_fields(SECTION_ID) + groups = [group_id_of(field) for field in fields] + keys = [field.get("key") or field.get("component") for field in fields] + + connection = [index for index, group in enumerate(groups) if group == "connection"] + cache = [index for index, group in enumerate(groups) if group == GROUP_ID] + assert connection == list(range(len(connection))), ( + f"The connection group is no longer contiguous at the top.\n groups: {groups}" + ) + assert cache == list(range(len(connection), len(connection) + 2)), ( + f"The cache group should directly follow the connection group.\n groups: {groups}" + ) + assert keys[cache[0]] == SWITCH_KEY and keys[cache[1]] == LIFETIME_KEY, ( + f"The switch should lead its lifetime.\n order: {keys}" + ) + + print(" Connection first, then the cache switch, then its lifetime.") + return True + + +def test_each_control_explains_itself(): + """A bare key-named switch is what this change replaces.""" + print("\nTesting labels, help and the group description...") + + _section_id, switch = declared_field(SWITCH_KEY) + _section_id, lifetime = declared_field(LIFETIME_KEY) + + assert switch["type"] == "switch", switch["type"] + assert switch["label"] == "Cache workspace search results", switch["label"] + assert lifetime["type"] == "number", lifetime["type"] + assert lifetime["label"] == "Cache lifetime (seconds)", lifetime["label"] + + group = switch["group"] + assert isinstance(group, dict), "The group's first field must declare it in full." + assert group.get("variant") == "behavior", group + assert group.get("label") == "Search result cache", group + + for owner, text in ( + ("switch help", switch.get("help")), + ("lifetime help", lifetime.get("help")), + ("group help", group.get("help")), + ): + assert text and len(text.split()) >= 20, f"The {owner} is missing or too thin: {text!r}" + + print(" Every control carries a real explanation.") + return True + + +def test_the_lifetime_is_bounded_and_only_shown_while_caching_is_on(): + """A lifetime under a switched-off cache describes nothing.""" + print("\nTesting the lifetime field's bounds and visibility...") + + _section_id, lifetime = declared_field(LIFETIME_KEY) + assert lifetime["min"] == 60 and lifetime["max"] == 3600, lifetime + assert lifetime["depends_on"] == {"key": SWITCH_KEY, "equals": True}, lifetime["depends_on"] + + shown = fields_module.field_dependencies_are_satisfied(lifetime, {SWITCH_KEY: True}) + hidden = fields_module.field_dependencies_are_satisfied(lifetime, {SWITCH_KEY: False}) + assert shown is True and hidden is False, (shown, hidden) + + print(" The lifetime is 60 to 3,600 seconds and follows the switch.") + return True + + +def test_declared_defaults_match_what_the_application_applies(): + """The admin page must show the state the cache is actually in.""" + print("\nTesting defaults against functions_settings.py and utils_cache.py...") + + source = SETTINGS_MODULE.read_text(encoding="utf-8") + app_defaults = { + match.group("key"): ast.literal_eval(match.group("value")) + for match in APP_DEFAULT_RE.finditer(source) + if match.group("key") in EXPECTED_DEFAULTS + } + assert app_defaults == EXPECTED_DEFAULTS, app_defaults + + lookups, handler_returns = read_runtime_fallbacks() + assert lookups == EXPECTED_DEFAULTS, lookups + assert handler_returns == [(True, 300)], handler_returns + + for key, expected in EXPECTED_DEFAULTS.items(): + _section_id, field = declared_field(key) + assert field["default"] == expected, (key, field["default"]) + + print(" On, with a 300 second lifetime, in all three places.") + return True + + +def test_saves_are_coerced_and_clamped(): + """A lifetime of zero or hours would make the cache useless or stale.""" + print("\nTesting PATCH normalization...") + + for raw, expected in ((5, 60), (99999, 3600), ("600", 600), (300, 300)): + result, errors, _ = normalize({LIFETIME_KEY: raw}) + assert not errors, errors + assert result[LIFETIME_KEY] == expected, (raw, result) + + _result, errors, _ = normalize({LIFETIME_KEY: "five minutes"}) + assert LIFETIME_KEY in errors, errors + + for raw, expected in ((False, False), ("false", False), ("on", True)): + result, errors, _ = normalize({SWITCH_KEY: raw}) + assert not errors, errors + assert result[SWITCH_KEY] is expected, (raw, result) + + print(" The lifetime clamps to its bounds and the switch saves a real boolean.") + return True + + +def test_the_v2_only_record_is_explained_and_still_true(): + """A V2-only field must say why, and must not quietly gain a classic control.""" + print("\nTesting the V2-only record...") + + for key in (SWITCH_KEY, LIFETIME_KEY): + reason = fields_module.V2_ONLY_FIELDS.get(key) + assert reason and reason.strip(), f"{key} has no V2_ONLY_FIELDS reason." + + templates = [ + TEMPLATES_ROOT / "admin_settings.html", + *TEMPLATES_ROOT.joinpath("admin").rglob("*.html"), + ] + offenders = [] + for path in templates: + markup = path.read_text(encoding="utf-8") + for key in (SWITCH_KEY, LIFETIME_KEY): + if re.search(rf"\bname=[\"']{key}[\"']", markup): + offenders.append(f"{path.relative_to(REPO_ROOT)}: {key}") + + assert not offenders, ( + "A classic form now submits these keys. Wire the classic save handler and " + "remove the V2_ONLY_FIELDS entries:\n " + "\n ".join(offenders) + ) + + print(" Both keys are recorded as V2-only, and no classic form submits them.") + return True + + +if __name__ == "__main__": + tests = [ + test_both_settings_are_declared_together_under_azure_ai_search, + test_the_connection_stays_first_and_the_cache_follows_it, + test_each_control_explains_itself, + test_the_lifetime_is_bounded_and_only_shown_while_caching_is_on, + test_declared_defaults_match_what_the_application_applies, + test_saves_are_coerced_and_clamped, + test_the_v2_only_record_is_explained_and_still_true, + ] + + 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_capability_placement.py b/functional_tests/test_v2_admin_capability_placement.py index 5125048a0..6002f7d6d 100644 --- a/functional_tests/test_v2_admin_capability_placement.py +++ b/functional_tests/test_v2_admin_capability_placement.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test pinning where the V2 admin surface files each capability toggle. -Version: 0.261.261 +Version: 0.261.263 Implemented in: 0.261.047 Settings that ``admin_settings_fields.py`` does not describe are still shown in the @@ -60,6 +60,12 @@ ``enable_tabular_analyze_durable_preflight`` or ``enable_tabular_hierarchical_analysis`` (which are forced the same way and were filed under "Other capabilities"), saved and then reverted, so all three are suppressed. + +``enable_search_result_caching`` matched "search" in both ``web-search-section`` +and ``azure-ai-search-section``. Web Search comes first in navigation order, so the +tie put a workspace-index cache under the setting that reaches the public internet, +as a bare switch with no description. It is now declared under +``azure-ai-search-section``, with its cache lifetime beside it. """ import ast @@ -153,12 +159,14 @@ # default on every read. Every enable_* key in it must be suppressed. FORCED_TABULAR_PARITY_DEFAULTS = "TABULAR_PARITY_DURABLE_PREFLIGHT_ACTIVE_DEFAULTS" -# Relocations with no server-rendered counterpart to check against. Both are +# Relocations with no server-rendered counterpart to check against. All are # documented in ``V2_ONLY_FIELDS``, which is what the section assertion below reads # instead of a pane. RELOCATED_CAPABILITIES_WITHOUT_V1_FIELD = { "enable_app_maintenance": "cosmos-maintenance-section", "enable_startup_app_maintenance": "cosmos-maintenance-section", + # Guessed into Web Search, which wins the tie on "search" by navigation order. + "enable_search_result_caching": "azure-ai-search-section", } # The rules the ported heuristic depends on. If the renderer stops doing any of diff --git a/ui_tests/test_v2_admin_search_result_cache.py b/ui_tests/test_v2_admin_search_result_cache.py new file mode 100644 index 000000000..025ed21fb --- /dev/null +++ b/ui_tests/test_v2_admin_search_result_cache.py @@ -0,0 +1,128 @@ +# test_v2_admin_search_result_cache.py +""" +UI test for the search result cache settings in V2 Admin Settings. + +Version: 0.261.263 +Implemented in: 0.261.263 + +Serve the built V2 SPA with the real field schema for the Web Search and Azure AI +Search sections, through the schema-backed fixture, so no application server, +signed-in account or live settings write is needed. Check that the cache is +explained inside Azure AI Search instead of being drawn as a bare key-named switch +under Web Search, that its lifetime is only offered while caching is on, and that +both save through the production field normalizer. +""" + +import re +import sys +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +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": ("web-search-section", "azure-ai-search-section")} +SWITCH_KEY = "enable_search_result_caching" +LIFETIME_KEY = "search_cache_ttl_seconds" +SWITCH_LABEL = "Cache workspace search results" +LIFETIME_LABEL = "Cache lifetime (seconds)" +ARTIFACTS = "v2_admin_search_result_cache" + + +@pytest.fixture +def cache_ui(page): + fixture = AdminSettingsFixture(page, validate_updates=True, sections=SECTIONS) + yield fixture + fixture.assert_clean() + + +def _search_region(page): + return page.get_by_role("region", name="Azure AI Search", exact=True) + + +def _expand_cache_group(page): + toggle = _search_region(page).get_by_role("button", name=re.compile("^Search result cache")) + if toggle.get_attribute("aria-expanded") != "true": + toggle.click() + expect(toggle).to_have_attribute("aria-expanded", "true") + + +def _switch(page): + name = re.compile(f"^{re.escape(SWITCH_LABEL)}") + return _search_region(page).get_by_role("checkbox", name=name) + + +def _lifetime(page): + return _search_region(page).get_by_label(LIFETIME_LABEL, exact=True) + + +def _save(page): + page.get_by_role("button", name="Save changes", exact=True).click() + expect(page.get_by_role("button", name="Save changes", exact=True)).to_have_count(0) + + +def test_the_cache_is_explained_under_azure_ai_search(cache_ui): + cache_ui.open(width=1440, ready_region="Azure AI Search") + page = cache_ui.page + _expand_cache_group(page) + + region = _search_region(page) + expect(region.get_by_text("not to web search", exact=False)).to_be_visible() + access_check = "re-checked against the requesting user's access" + expect(region.get_by_text(access_check, exact=False)).to_be_visible() + expect(_switch(page)).to_be_checked() + lifetime = _lifetime(page) + expect(lifetime).to_have_value("300") + expect(lifetime).to_have_attribute("min", "60") + expect(lifetime).to_have_attribute("max", "3600") + + # The fallback scan drew the bare key under Web Search; nothing of it may remain. + web_search = page.get_by_role("region", name="Web Search", exact=True) + expect(web_search.get_by_text(SWITCH_LABEL, exact=False)).to_have_count(0) + expect(page.get_by_text(SWITCH_KEY, exact=True)).to_have_count(0) + expect(page.get_by_text("Search result caching", exact=True)).to_have_count(0) + cache_ui.capture("explained", ARTIFACTS) + + +def test_turning_the_cache_off_hides_its_lifetime_and_saves_a_boolean(cache_ui): + cache_ui.open(width=1440, ready_region="Azure AI Search") + page = cache_ui.page + _expand_cache_group(page) + + _search_region(page).get_by_text(SWITCH_LABEL, exact=True).click() + expect(_switch(page)).not_to_be_checked() + expect(_lifetime(page)).to_have_count(0) + + _save(page) + assert cache_ui.patches == [{SWITCH_KEY: False}] + assert cache_ui.settings[SWITCH_KEY] is False + assert cache_ui.settings[LIFETIME_KEY] == 300 + + page.reload(wait_until="networkidle") + _expand_cache_group(page) + expect(_switch(page)).not_to_be_checked() + expect(_lifetime(page)).to_have_count(0) + + +def test_the_lifetime_saves_within_its_bounds(cache_ui): + cache_ui.open(width=390, ready_region="Azure AI Search") + page = cache_ui.page + _expand_cache_group(page) + + _lifetime(page).fill("5") + _save(page) + assert cache_ui.patches == [{LIFETIME_KEY: 5}] + assert cache_ui.settings[LIFETIME_KEY] == 60 + expect(_lifetime(page)).to_have_value("60") + + _lifetime(page).fill("900") + _save(page) + assert cache_ui.patches[-1] == {LIFETIME_KEY: 900} + assert cache_ui.settings[LIFETIME_KEY] == 900 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")