From 62d7ad7e9ec8ff6f8239199bc5b69a737cfba49a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:00:22 +0000 Subject: [PATCH] feat(settings): add dynamic autocomplete for AWS/Azure regions, OCR langs, and embedding models Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/settings.py | 36 +++ app/utils/settings_service.py | 17 +- app/utils/suggestion_providers.py | 450 +++++++++++++++++++++++++++++ frontend/templates/settings.html | 144 +++++++++ tests/test_suggestion_providers.py | 299 +++++++++++++++++++ 5 files changed, 939 insertions(+), 7 deletions(-) create mode 100644 app/utils/suggestion_providers.py create mode 100644 tests/test_suggestion_providers.py diff --git a/app/api/settings.py b/app/api/settings.py index 1431b0cf..ffe1dfcf 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -446,6 +446,42 @@ async def install_ocr_languages(request: Request, admin: AdminUser): ) +@router.get("/{key}/suggestions") +async def get_setting_suggestions( + key: str, + request: Request, + q: str = "", + limit: int = 10, +): + """ + Return autocomplete suggestions for a setting key. + + Fetches values dynamically from cloud SDKs, installed tools, or + curated static lists depending on the setting. Results are filtered + by case-insensitive substring match on the ``q`` parameter. + + This endpoint does **not** require admin privileges so that the + autocomplete widget works for any authenticated user viewing settings. + """ + from app.utils.suggestion_providers import SUGGESTION_PROVIDERS, get_suggestions # noqa: PLC0415 + + if key not in SUGGESTION_PROVIDERS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No suggestions available for setting '{key}'", + ) + + try: + suggestions = get_suggestions(key, query=q, limit=max(1, min(limit, 50))) + return {"key": key, "suggestions": suggestions} + except Exception as e: + logger.error(f"Error fetching suggestions for {key}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to fetch suggestions", + ) + + @router.get("/{key}/history") async def get_key_history(key: str, request: Request, db: DbSession, admin: AdminUser): """ diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index eee9701d..c324c84d 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -360,7 +360,7 @@ SETTING_METADATA = { "azure_region": { "category": "AI Services", "description": "Azure region for Document Intelligence services (e.g., eastus)", - "type": "string", + "type": "autocomplete", "sensitive": False, "required": False, "restart_required": False, @@ -412,8 +412,11 @@ SETTING_METADATA = { }, "tesseract_language": { "category": "OCR Engines", - "description": "Tesseract language code(s), e.g. 'eng' or 'eng+deu'. Default: eng+deu (English + German).", - "type": "string", + "description": ( + "Tesseract language code(s), e.g. 'eng' or 'eng+deu'. " + "Combine multiple with '+'. Default: eng+deu (English + German)." + ), + "type": "autocomplete", "sensitive": False, "required": False, "restart_required": False, @@ -421,8 +424,8 @@ SETTING_METADATA = { # OCR – EasyOCR "easyocr_languages": { "category": "OCR Engines", - "description": "Comma-separated EasyOCR language codes, e.g. 'en,de,fr'. Default: en,de (English + German).", - "type": "string", + "description": ("Comma-separated EasyOCR language codes, e.g. 'en,de,fr'. Default: en,de (English + German)."), + "type": "autocomplete", "sensitive": False, "required": False, "restart_required": False, @@ -853,7 +856,7 @@ SETTING_METADATA = { "aws_region": { "category": "Storage Providers", "description": "AWS region for S3 bucket (default: us-east-1)", - "type": "string", + "type": "autocomplete", "sensitive": False, "required": False, "restart_required": False, @@ -1476,7 +1479,7 @@ SETTING_METADATA = { "Model name used for generating text embeddings via the OpenAI-compatible API. " "Embeddings drive the document similarity feature. Default: text-embedding-3-small." ), - "type": "string", + "type": "autocomplete", "sensitive": False, "required": False, "restart_required": False, diff --git a/app/utils/suggestion_providers.py b/app/utils/suggestion_providers.py new file mode 100644 index 00000000..68c6fb70 --- /dev/null +++ b/app/utils/suggestion_providers.py @@ -0,0 +1,450 @@ +""" +Dynamic suggestion providers for autocomplete-enabled settings. + +Each provider function returns a list of strings that are valid values +for a particular setting. Providers try to resolve values dynamically +(e.g. by querying cloud SDKs or scanning installed software) and fall +back to curated static lists when the runtime environment lacks the +required libraries, credentials, or connectivity. + +**Fallback guarantee**: Every provider wraps its dynamic resolution in a +``try/except Exception`` so that it *always* returns a usable list. +Missing libraries (``ImportError``), missing credentials, network +failures, or unexpected SDK errors all trigger a graceful fallback to +the bundled static list. +""" + +import logging +import subprocess # noqa: S404 — only used with fixed args, no user input + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# AWS regions — fetched from boto3 if available +# --------------------------------------------------------------------------- + +_AWS_REGIONS_STATIC: list[str] = [ + "af-south-1", + "ap-east-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-1", + "ap-south-2", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ca-central-1", + "ca-west-1", + "eu-central-1", + "eu-central-2", + "eu-north-1", + "eu-south-1", + "eu-south-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "il-central-1", + "me-central-1", + "me-south-1", + "sa-east-1", + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", +] + + +def get_aws_regions() -> list[str]: + """Return available AWS S3 regions via boto3, falling back to a static list.""" + try: + import boto3 # noqa: PLC0415 + + session = boto3.session.Session() + regions = sorted(session.get_available_regions("s3")) + if regions: + return regions + except Exception: + logger.debug("boto3 not available or failed; using static AWS region list") + return _AWS_REGIONS_STATIC + + +# --------------------------------------------------------------------------- +# Azure regions — resolved from known Cognitive Services locations +# --------------------------------------------------------------------------- + +_AZURE_REGIONS_STATIC: list[str] = [ + "australiacentral", + "australiaeast", + "australiasoutheast", + "brazilsouth", + "canadacentral", + "canadaeast", + "centralindia", + "centralus", + "eastasia", + "eastus", + "eastus2", + "francecentral", + "germanywestcentral", + "japaneast", + "japanwest", + "koreacentral", + "koreasouth", + "northcentralus", + "northeurope", + "norwayeast", + "polandcentral", + "qatarcentral", + "southafricanorth", + "southcentralus", + "southeastasia", + "swedencentral", + "switzerlandnorth", + "uaenorth", + "uksouth", + "ukwest", + "westcentralus", + "westeurope", + "westus", + "westus2", + "westus3", +] + + +def get_azure_regions() -> list[str]: + """Return Azure Cognitive Services regions. + + Falls back to a curated static list because there is no + unauthenticated public endpoint to enumerate regions. + """ + return _AZURE_REGIONS_STATIC + + +# --------------------------------------------------------------------------- +# Tesseract languages — probed from `tesseract --list-langs` +# --------------------------------------------------------------------------- + +_TESSERACT_LANGS_STATIC: list[str] = [ + "afr", + "amh", + "ara", + "asm", + "aze", + "bel", + "ben", + "bod", + "bos", + "bre", + "bul", + "cat", + "ceb", + "ces", + "chi_sim", + "chi_tra", + "chr", + "cos", + "cym", + "dan", + "deu", + "div", + "ell", + "eng", + "enm", + "epo", + "est", + "eus", + "fao", + "fas", + "fil", + "fin", + "fra", + "frk", + "frm", + "fry", + "gla", + "gle", + "glg", + "grc", + "guj", + "hat", + "heb", + "hin", + "hrv", + "hun", + "hye", + "iku", + "ind", + "isl", + "ita", + "jav", + "jpn", + "kan", + "kat", + "kaz", + "khm", + "kir", + "kor", + "lao", + "lat", + "lav", + "lit", + "ltz", + "mal", + "mar", + "mkd", + "mlt", + "mon", + "mri", + "msa", + "mya", + "nep", + "nld", + "nor", + "oci", + "ori", + "pan", + "pol", + "por", + "pus", + "que", + "ron", + "rus", + "san", + "sin", + "slk", + "slv", + "snd", + "spa", + "sqi", + "srp", + "sun", + "swa", + "swe", + "syr", + "tam", + "tat", + "tel", + "tgk", + "tha", + "tir", + "ton", + "tur", + "uig", + "ukr", + "urd", + "uzb", + "vie", + "yid", + "yor", +] + + +def get_tesseract_languages() -> list[str]: + """Return installed Tesseract language codes, falling back to a static list.""" + try: + result = subprocess.run( # noqa: S603, S607 + ["tesseract", "--list-langs"], # noqa: S607 + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode == 0: + lines = result.stdout.strip().splitlines() + # First line is the header ("List of available languages ...") + langs = sorted(line.strip() for line in lines[1:] if line.strip()) + if langs: + return langs + except Exception: + logger.debug("tesseract not available; using static language list") + return _TESSERACT_LANGS_STATIC + + +# --------------------------------------------------------------------------- +# EasyOCR languages — probed from the easyocr module +# --------------------------------------------------------------------------- + +_EASYOCR_LANGS_STATIC: list[str] = [ + "abq", + "ady", + "af", + "ang", + "ar", + "as", + "ava", + "az", + "be", + "bg", + "bh", + "bn", + "bs", + "ch_sim", + "ch_tra", + "che", + "cs", + "cy", + "da", + "dar", + "de", + "en", + "es", + "et", + "fa", + "fi", + "fr", + "ga", + "gom", + "hi", + "hr", + "hu", + "id", + "inh", + "is", + "it", + "ja", + "ka", + "kk", + "km", + "kn", + "ko", + "ku", + "la", + "lbe", + "lez", + "lt", + "lv", + "mah", + "mai", + "mi", + "mn", + "mr", + "ms", + "mt", + "ne", + "new", + "nl", + "no", + "oc", + "pi", + "pl", + "pt", + "ro", + "ru", + "rs_cyrillic", + "rs_latin", + "sa", + "sck", + "sk", + "sl", + "sq", + "sv", + "sw", + "ta", + "tab", + "te", + "th", + "tjk", + "tl", + "tr", + "ug", + "uk", + "ur", + "uz", + "vi", +] + + +def get_easyocr_languages() -> list[str]: + """Return supported EasyOCR language codes, falling back to a static list.""" + try: + import easyocr # noqa: PLC0415 + + # easyocr stores the language list internally + if hasattr(easyocr, "config") and hasattr(easyocr.config, "all_lang_list"): + return sorted(easyocr.config.all_lang_list) + except Exception: + logger.debug("easyocr not available; using static language list") + return _EASYOCR_LANGS_STATIC + + +# --------------------------------------------------------------------------- +# Embedding models — static list (no standard discovery API) +# --------------------------------------------------------------------------- + +_EMBEDDING_MODELS: list[str] = [ + "text-embedding-3-small", + "text-embedding-3-large", + "text-embedding-ada-002", + "nomic-embed-text", + "nomic-embed-text-v1.5", + "mxbai-embed-large", + "mxbai-embed-large-v1", + "all-MiniLM-L6-v2", + "all-MiniLM-L12-v2", + "bge-small-en-v1.5", + "bge-base-en-v1.5", + "bge-large-en-v1.5", + "e5-small-v2", + "e5-base-v2", + "e5-large-v2", + "gte-small", + "gte-base", + "gte-large", + "voyage-3", + "voyage-3-lite", + "voyage-code-3", +] + + +def get_embedding_models() -> list[str]: + """Return known embedding model names.""" + return _EMBEDDING_MODELS + + +# --------------------------------------------------------------------------- +# Registry — maps setting keys to their provider functions +# --------------------------------------------------------------------------- + +SUGGESTION_PROVIDERS: dict[str, callable] = { + "aws_region": get_aws_regions, + "azure_region": get_azure_regions, + "tesseract_language": get_tesseract_languages, + "easyocr_languages": get_easyocr_languages, + "embedding_model": get_embedding_models, +} + + +def get_suggestions(key: str, query: str = "", limit: int = 10) -> list[str]: + """ + Return autocomplete suggestions for the given setting key. + + Fetches the full list from the registered provider, filters by + case-insensitive substring match on *query*, and returns at most + *limit* results. + + Args: + key: The setting key (must be registered in SUGGESTION_PROVIDERS). + query: Substring to filter by (case-insensitive). + limit: Maximum number of results to return. + + Returns: + Filtered list of suggestion strings. + + Raises: + KeyError: If no provider is registered for *key*. + """ + provider = SUGGESTION_PROVIDERS.get(key) + if provider is None: + raise KeyError(f"No suggestion provider registered for setting '{key}'") + + all_values = provider() + q = query.strip().lower() + + if q: + filtered = [v for v in all_values if q in v.lower()] + else: + filtered = list(all_values) + + return filtered[:limit] diff --git a/frontend/templates/settings.html b/frontend/templates/settings.html index 61702cf3..523e4983 100644 --- a/frontend/templates/settings.html +++ b/frontend/templates/settings.html @@ -365,6 +365,80 @@ Type to search existing users by name, or enter any identifier manually.
+ {% elif setting.metadata.type == 'autocomplete' %} + ++ + Type to search known values, or enter any custom value. +
+