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")
|
||||
async def get_key_history(key: str, request: Request, db: DbSession, admin: AdminUser):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
</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' %}
|
||||
<!-- Model Picker -->
|
||||
<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>
|
||||
{% 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