feat(settings): add dynamic autocomplete for AWS/Azure regions, OCR langs, and embedding models
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -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")
|
@router.get("/{key}/history")
|
||||||
async def get_key_history(key: str, request: Request, db: DbSession, admin: AdminUser):
|
async def get_key_history(key: str, request: Request, db: DbSession, admin: AdminUser):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ SETTING_METADATA = {
|
|||||||
"azure_region": {
|
"azure_region": {
|
||||||
"category": "AI Services",
|
"category": "AI Services",
|
||||||
"description": "Azure region for Document Intelligence services (e.g., eastus)",
|
"description": "Azure region for Document Intelligence services (e.g., eastus)",
|
||||||
"type": "string",
|
"type": "autocomplete",
|
||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
@@ -412,8 +412,11 @@ SETTING_METADATA = {
|
|||||||
},
|
},
|
||||||
"tesseract_language": {
|
"tesseract_language": {
|
||||||
"category": "OCR Engines",
|
"category": "OCR Engines",
|
||||||
"description": "Tesseract language code(s), e.g. 'eng' or 'eng+deu'. Default: eng+deu (English + German).",
|
"description": (
|
||||||
"type": "string",
|
"Tesseract language code(s), e.g. 'eng' or 'eng+deu'. "
|
||||||
|
"Combine multiple with '+'. Default: eng+deu (English + German)."
|
||||||
|
),
|
||||||
|
"type": "autocomplete",
|
||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
@@ -421,8 +424,8 @@ SETTING_METADATA = {
|
|||||||
# OCR – EasyOCR
|
# OCR – EasyOCR
|
||||||
"easyocr_languages": {
|
"easyocr_languages": {
|
||||||
"category": "OCR Engines",
|
"category": "OCR Engines",
|
||||||
"description": "Comma-separated EasyOCR language codes, e.g. 'en,de,fr'. Default: en,de (English + German).",
|
"description": ("Comma-separated EasyOCR language codes, e.g. 'en,de,fr'. Default: en,de (English + German)."),
|
||||||
"type": "string",
|
"type": "autocomplete",
|
||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
@@ -853,7 +856,7 @@ SETTING_METADATA = {
|
|||||||
"aws_region": {
|
"aws_region": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
"description": "AWS region for S3 bucket (default: us-east-1)",
|
"description": "AWS region for S3 bucket (default: us-east-1)",
|
||||||
"type": "string",
|
"type": "autocomplete",
|
||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
@@ -1476,7 +1479,7 @@ SETTING_METADATA = {
|
|||||||
"Model name used for generating text embeddings via the OpenAI-compatible API. "
|
"Model name used for generating text embeddings via the OpenAI-compatible API. "
|
||||||
"Embeddings drive the document similarity feature. Default: text-embedding-3-small."
|
"Embeddings drive the document similarity feature. Default: text-embedding-3-small."
|
||||||
),
|
),
|
||||||
"type": "string",
|
"type": "autocomplete",
|
||||||
"sensitive": False,
|
"sensitive": False,
|
||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -365,6 +365,80 @@
|
|||||||
Type to search existing users by name, or enter any identifier manually.
|
Type to search existing users by name, or enter any identifier manually.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif setting.metadata.type == 'autocomplete' %}
|
||||||
|
<!-- Dynamic Autocomplete (WCAG 2.1 AA combobox pattern) -->
|
||||||
|
<div class="relative" x-data="dynamicAutocomplete('{{ setting.key }}')" @click.away="showSuggestions = false">
|
||||||
|
<div class="relative">
|
||||||
|
<label for="{{ setting.key }}" class="sr-only">{{ setting.key.replace('_', ' ').title() }}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="{{ setting.key }}"
|
||||||
|
name="{{ setting.key }}"
|
||||||
|
x-model="formData['{{ setting.key }}']"
|
||||||
|
@input.debounce.200ms="fetchSuggestions($event.target.value)"
|
||||||
|
@focus="fetchSuggestions(formData['{{ setting.key }}'] || '')"
|
||||||
|
@keydown.arrow-down.prevent="highlightNext()"
|
||||||
|
@keydown.arrow-up.prevent="highlightPrev()"
|
||||||
|
@keydown.enter.prevent="selectHighlighted()"
|
||||||
|
@keydown.escape="showSuggestions = false"
|
||||||
|
class="setting-input w-full px-3 py-2 pl-9 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Type to search or enter a value…"
|
||||||
|
autocomplete="off"
|
||||||
|
role="combobox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-label="{{ setting.key.replace('_', ' ').title() }} — type to search suggestions"
|
||||||
|
:aria-expanded="showSuggestions && filtered.length > 0"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
:aria-controls="'{{ setting.key }}_listbox'"
|
||||||
|
:aria-activedescendant="highlightedIdx >= 0 ? '{{ setting.key }}_opt_' + highlightedIdx : ''"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<i class="fas fa-search text-gray-400 text-sm" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<div x-show="loading" class="absolute inset-y-0 right-0 flex items-center pr-3" aria-hidden="true">
|
||||||
|
<i class="fas fa-spinner fa-spin text-gray-400 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Live region for screen reader announcements -->
|
||||||
|
<div class="sr-only" aria-live="polite" aria-atomic="true" x-text="statusText"></div>
|
||||||
|
<!-- Suggestion dropdown -->
|
||||||
|
<ul
|
||||||
|
x-show="showSuggestions && filtered.length > 0"
|
||||||
|
x-transition:enter="transition ease-out duration-100"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-1"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
|
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-y-auto"
|
||||||
|
role="listbox"
|
||||||
|
:id="'{{ setting.key }}_listbox'"
|
||||||
|
:aria-label="'{{ setting.key.replace('_', ' ').title() }} suggestions'"
|
||||||
|
>
|
||||||
|
<template x-for="(item, idx) in filtered" :key="item">
|
||||||
|
<li
|
||||||
|
:id="'{{ setting.key }}_opt_' + idx"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="highlightedIdx === idx"
|
||||||
|
@mouseenter="highlightedIdx = idx"
|
||||||
|
@click="selectItem(item)"
|
||||||
|
class="px-3 py-2.5 text-sm cursor-pointer min-h-[44px] flex items-center"
|
||||||
|
:class="highlightedIdx === idx ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'"
|
||||||
|
>
|
||||||
|
<span x-text="item"></span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div
|
||||||
|
x-show="showSuggestions && filtered.length === 0 && !loading && searchDone"
|
||||||
|
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg px-3 py-2 text-sm text-gray-500"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i> No matches — you can still type a custom value
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
|
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||||
|
Type to search known values, or enter any custom value.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{% elif setting.metadata.type == 'model_picker' %}
|
{% elif setting.metadata.type == 'model_picker' %}
|
||||||
<!-- Model Picker -->
|
<!-- Model Picker -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -859,5 +933,75 @@ function userAutocomplete(settingKey) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alpine.js component for the generic dynamic autocomplete setting widget.
|
||||||
|
* Fetches suggestions from GET /api/settings/{key}/suggestions?q=<term>&limit=10
|
||||||
|
* using a debounced API call. Implements the ARIA 1.1 combobox pattern.
|
||||||
|
*/
|
||||||
|
function dynamicAutocomplete(settingKey) {
|
||||||
|
return {
|
||||||
|
filtered: [],
|
||||||
|
showSuggestions: false,
|
||||||
|
loading: false,
|
||||||
|
searchDone: false,
|
||||||
|
highlightedIdx: -1,
|
||||||
|
statusText: '',
|
||||||
|
|
||||||
|
async fetchSuggestions(query) {
|
||||||
|
this.loading = true;
|
||||||
|
this.searchDone = false;
|
||||||
|
this.highlightedIdx = -1;
|
||||||
|
this.statusText = 'Searching…';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/settings/' + encodeURIComponent(settingKey) + '/suggestions?q=' + encodeURIComponent(query || '') + '&limit=10');
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
this.filtered = data.suggestions || [];
|
||||||
|
} else {
|
||||||
|
this.filtered = [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.filtered = [];
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
this.searchDone = true;
|
||||||
|
this.showSuggestions = true;
|
||||||
|
if (this.filtered.length === 0) {
|
||||||
|
this.statusText = 'No matches found';
|
||||||
|
} else if (this.filtered.length === 1) {
|
||||||
|
this.statusText = '1 suggestion found';
|
||||||
|
} else {
|
||||||
|
this.statusText = this.filtered.length + ' suggestions found';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectItem(item) {
|
||||||
|
this.formData[settingKey] = item;
|
||||||
|
this.showSuggestions = false;
|
||||||
|
this.statusText = item + ' selected';
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightNext() {
|
||||||
|
if (this.filtered.length === 0) return;
|
||||||
|
this.highlightedIdx = (this.highlightedIdx + 1) % this.filtered.length;
|
||||||
|
this.statusText = this.filtered[this.highlightedIdx];
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightPrev() {
|
||||||
|
if (this.filtered.length === 0) return;
|
||||||
|
this.highlightedIdx = this.highlightedIdx <= 0 ? this.filtered.length - 1 : this.highlightedIdx - 1;
|
||||||
|
this.statusText = this.filtered[this.highlightedIdx];
|
||||||
|
},
|
||||||
|
|
||||||
|
selectHighlighted() {
|
||||||
|
if (this.highlightedIdx >= 0 && this.highlightedIdx < this.filtered.length) {
|
||||||
|
this.selectItem(this.filtered[this.highlightedIdx]);
|
||||||
|
} else {
|
||||||
|
this.showSuggestions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""
|
||||||
|
Tests for the suggestion providers and the settings suggestions API.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Dynamic suggestion providers (AWS, Azure, Tesseract, EasyOCR, embedding models)
|
||||||
|
- GET /api/settings/{key}/suggestions endpoint
|
||||||
|
- Substring filtering and limit enforcement
|
||||||
|
- Fallback to static lists when SDKs are unavailable
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.utils.suggestion_providers import (
|
||||||
|
_AZURE_REGIONS_STATIC,
|
||||||
|
_EASYOCR_LANGS_STATIC,
|
||||||
|
_EMBEDDING_MODELS,
|
||||||
|
_TESSERACT_LANGS_STATIC,
|
||||||
|
SUGGESTION_PROVIDERS,
|
||||||
|
get_aws_regions,
|
||||||
|
get_azure_regions,
|
||||||
|
get_easyocr_languages,
|
||||||
|
get_embedding_models,
|
||||||
|
get_suggestions,
|
||||||
|
get_tesseract_languages,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAWSRegionProvider:
|
||||||
|
"""Tests for get_aws_regions."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_returns_list(self):
|
||||||
|
"""Should return a non-empty list of region strings."""
|
||||||
|
regions = get_aws_regions()
|
||||||
|
assert isinstance(regions, list)
|
||||||
|
assert len(regions) > 0
|
||||||
|
assert all(isinstance(r, str) for r in regions)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_us_east_1_present(self):
|
||||||
|
"""us-east-1 should always be in the list."""
|
||||||
|
regions = get_aws_regions()
|
||||||
|
assert "us-east-1" in regions
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_results_are_sorted(self):
|
||||||
|
"""Region list should be sorted alphabetically."""
|
||||||
|
regions = get_aws_regions()
|
||||||
|
assert regions == sorted(regions)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_fallback_on_boto3_failure(self):
|
||||||
|
"""Should fall back to static list when boto3 raises."""
|
||||||
|
with patch.dict("sys.modules", {"boto3": None}):
|
||||||
|
regions = get_aws_regions()
|
||||||
|
# Should still return a list (the static fallback)
|
||||||
|
assert isinstance(regions, list)
|
||||||
|
assert "us-east-1" in regions
|
||||||
|
|
||||||
|
|
||||||
|
class TestAzureRegionProvider:
|
||||||
|
"""Tests for get_azure_regions."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_returns_static_list(self):
|
||||||
|
"""Should return the curated static list."""
|
||||||
|
regions = get_azure_regions()
|
||||||
|
assert regions == _AZURE_REGIONS_STATIC
|
||||||
|
assert "eastus" in regions
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_contains_common_regions(self):
|
||||||
|
"""Common Azure regions should be present."""
|
||||||
|
regions = get_azure_regions()
|
||||||
|
for region in ["eastus", "westeurope", "uksouth", "japaneast"]:
|
||||||
|
assert region in regions
|
||||||
|
|
||||||
|
|
||||||
|
class TestTesseractLanguageProvider:
|
||||||
|
"""Tests for get_tesseract_languages."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_returns_list(self):
|
||||||
|
"""Should return a non-empty list."""
|
||||||
|
langs = get_tesseract_languages()
|
||||||
|
assert isinstance(langs, list)
|
||||||
|
assert len(langs) > 0
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_eng_present(self):
|
||||||
|
"""English should always be available."""
|
||||||
|
langs = get_tesseract_languages()
|
||||||
|
assert "eng" in langs
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_fallback_on_missing_tesseract(self):
|
||||||
|
"""Should fall back to static list when tesseract is not installed."""
|
||||||
|
with patch("subprocess.run", side_effect=FileNotFoundError):
|
||||||
|
langs = get_tesseract_languages()
|
||||||
|
assert langs == _TESSERACT_LANGS_STATIC
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_uses_subprocess_output_when_available(self):
|
||||||
|
"""Should parse subprocess output when tesseract is installed."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 0
|
||||||
|
mock_result.stdout = "List of available languages (4):\neng\ndeu\nfra\nita\n"
|
||||||
|
|
||||||
|
with patch("subprocess.run", return_value=mock_result):
|
||||||
|
langs = get_tesseract_languages()
|
||||||
|
|
||||||
|
assert langs == ["deu", "eng", "fra", "ita"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEasyOCRLanguageProvider:
|
||||||
|
"""Tests for get_easyocr_languages."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_returns_list(self):
|
||||||
|
"""Should return a non-empty list."""
|
||||||
|
langs = get_easyocr_languages()
|
||||||
|
assert isinstance(langs, list)
|
||||||
|
assert len(langs) > 0
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_en_present(self):
|
||||||
|
"""English should always be available."""
|
||||||
|
langs = get_easyocr_languages()
|
||||||
|
assert "en" in langs
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_fallback_when_easyocr_missing(self):
|
||||||
|
"""Should fall back to static list when easyocr is not installed."""
|
||||||
|
# easyocr is not installed in the test env, so this tests the real fallback
|
||||||
|
langs = get_easyocr_languages()
|
||||||
|
assert langs == _EASYOCR_LANGS_STATIC
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmbeddingModelProvider:
|
||||||
|
"""Tests for get_embedding_models."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_returns_list(self):
|
||||||
|
"""Should return a non-empty list."""
|
||||||
|
models = get_embedding_models()
|
||||||
|
assert isinstance(models, list)
|
||||||
|
assert len(models) > 0
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_default_model_present(self):
|
||||||
|
"""The default model should be in the list."""
|
||||||
|
models = get_embedding_models()
|
||||||
|
assert "text-embedding-3-small" in models
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_returns_static_list(self):
|
||||||
|
"""Should return the static embedding model list."""
|
||||||
|
assert get_embedding_models() == _EMBEDDING_MODELS
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_suggestions() tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSuggestions:
|
||||||
|
"""Tests for the get_suggestions aggregator function."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_all_providers_registered(self):
|
||||||
|
"""All expected keys should be registered."""
|
||||||
|
expected_keys = {"aws_region", "azure_region", "tesseract_language", "easyocr_languages", "embedding_model"}
|
||||||
|
assert expected_keys == set(SUGGESTION_PROVIDERS.keys())
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_unregistered_key_raises(self):
|
||||||
|
"""Requesting suggestions for an unknown key raises KeyError."""
|
||||||
|
with pytest.raises(KeyError, match="no_such_setting"):
|
||||||
|
get_suggestions("no_such_setting")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_empty_query_returns_all(self):
|
||||||
|
"""Empty query returns all suggestions up to the limit."""
|
||||||
|
results = get_suggestions("embedding_model", query="", limit=100)
|
||||||
|
assert len(results) == len(_EMBEDDING_MODELS)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_substring_filtering(self):
|
||||||
|
"""Query filters by case-insensitive substring."""
|
||||||
|
results = get_suggestions("aws_region", query="east", limit=50)
|
||||||
|
assert all("east" in r.lower() for r in results)
|
||||||
|
assert len(results) > 0
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
"""Filtering should be case-insensitive."""
|
||||||
|
results = get_suggestions("aws_region", query="EAST", limit=50)
|
||||||
|
assert len(results) > 0
|
||||||
|
assert "us-east-1" in results
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_limit_respected(self):
|
||||||
|
"""Results should not exceed the limit."""
|
||||||
|
results = get_suggestions("tesseract_language", query="", limit=3)
|
||||||
|
assert len(results) == 3
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_whitespace_trimmed(self):
|
||||||
|
"""Leading/trailing whitespace in the query should be trimmed."""
|
||||||
|
results = get_suggestions("aws_region", query=" us-east ", limit=10)
|
||||||
|
assert all("us-east" in r.lower() for r in results)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API endpoint integration tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuggestionsEndpoint:
|
||||||
|
"""Tests for GET /api/settings/{key}/suggestions."""
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_aws_region_suggestions(self, client):
|
||||||
|
"""AWS region endpoint returns suggestions."""
|
||||||
|
response = client.get("/api/settings/aws_region/suggestions?q=east")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "suggestions" in data
|
||||||
|
assert data["key"] == "aws_region"
|
||||||
|
assert len(data["suggestions"]) > 0
|
||||||
|
assert all("east" in s.lower() for s in data["suggestions"])
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_azure_region_suggestions(self, client):
|
||||||
|
"""Azure region endpoint returns suggestions."""
|
||||||
|
response = client.get("/api/settings/azure_region/suggestions?q=europe")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "westeurope" in data["suggestions"]
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_tesseract_language_suggestions(self, client):
|
||||||
|
"""Tesseract language endpoint returns suggestions."""
|
||||||
|
response = client.get("/api/settings/tesseract_language/suggestions?q=eng")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "eng" in data["suggestions"]
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_easyocr_language_suggestions(self, client):
|
||||||
|
"""EasyOCR language endpoint returns suggestions."""
|
||||||
|
response = client.get("/api/settings/easyocr_languages/suggestions?q=de")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "de" in data["suggestions"]
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_embedding_model_suggestions(self, client):
|
||||||
|
"""Embedding model endpoint returns suggestions."""
|
||||||
|
response = client.get("/api/settings/embedding_model/suggestions?q=embed")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["suggestions"]) > 0
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_unknown_key_returns_404(self, client):
|
||||||
|
"""Unknown setting key returns 404."""
|
||||||
|
response = client.get("/api/settings/nonexistent_setting/suggestions")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_empty_query_returns_results(self, client):
|
||||||
|
"""Empty query returns all suggestions up to limit."""
|
||||||
|
response = client.get("/api/settings/embedding_model/suggestions?q=")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["suggestions"]) > 0
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_limit_parameter(self, client):
|
||||||
|
"""Limit parameter restricts the number of results."""
|
||||||
|
response = client.get("/api/settings/aws_region/suggestions?q=&limit=3")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["suggestions"]) <= 3
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_limit_clamped_to_max(self, client):
|
||||||
|
"""Limit over 50 should be clamped to 50."""
|
||||||
|
response = client.get("/api/settings/tesseract_language/suggestions?q=&limit=999")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["suggestions"]) <= 50
|
||||||
Reference in New Issue
Block a user