Commit 86d8358b25faf30015b8035300bad7fadb4d308f
1 parent
77bfa7e3
config optimize
Showing
37 changed files
with
2087 additions
and
1123 deletions
Show diff stats
api/app.py
| ... | ... | @@ -86,8 +86,7 @@ limiter = Limiter(key_func=get_remote_address) |
| 86 | 86 | # Add parent directory to path |
| 87 | 87 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| 88 | 88 | |
| 89 | -from config.env_config import ES_CONFIG, DB_CONFIG | |
| 90 | -from config import ConfigLoader | |
| 89 | +from config import get_app_config | |
| 91 | 90 | from utils import ESClient |
| 92 | 91 | from search import Searcher |
| 93 | 92 | from query import QueryParser |
| ... | ... | @@ -99,7 +98,7 @@ _es_client: Optional[ESClient] = None |
| 99 | 98 | _searcher: Optional[Searcher] = None |
| 100 | 99 | _query_parser: Optional[QueryParser] = None |
| 101 | 100 | _suggestion_service: Optional[SuggestionService] = None |
| 102 | -_config = None | |
| 101 | +_app_config = None | |
| 103 | 102 | |
| 104 | 103 | |
| 105 | 104 | def init_service(es_host: str = "http://localhost:9200"): |
| ... | ... | @@ -109,20 +108,20 @@ def init_service(es_host: str = "http://localhost:9200"): |
| 109 | 108 | Args: |
| 110 | 109 | es_host: Elasticsearch host URL |
| 111 | 110 | """ |
| 112 | - global _es_client, _searcher, _query_parser, _suggestion_service, _config | |
| 111 | + global _es_client, _searcher, _query_parser, _suggestion_service, _app_config | |
| 113 | 112 | |
| 114 | 113 | start_time = time.time() |
| 115 | 114 | logger.info("Initializing search service (multi-tenant)") |
| 116 | 115 | |
| 117 | 116 | # Load configuration |
| 118 | 117 | logger.info("Loading configuration...") |
| 119 | - config_loader = ConfigLoader("config/config.yaml") | |
| 120 | - _config = config_loader.load_config() | |
| 118 | + _app_config = get_app_config() | |
| 119 | + search_config = _app_config.search | |
| 121 | 120 | logger.info("Configuration loaded") |
| 122 | 121 | |
| 123 | 122 | # Get ES credentials |
| 124 | - es_username = os.getenv('ES_USERNAME') or ES_CONFIG.get('username') | |
| 125 | - es_password = os.getenv('ES_PASSWORD') or ES_CONFIG.get('password') | |
| 123 | + es_username = _app_config.infrastructure.elasticsearch.username | |
| 124 | + es_password = _app_config.infrastructure.elasticsearch.password | |
| 126 | 125 | |
| 127 | 126 | # Connect to Elasticsearch |
| 128 | 127 | logger.info(f"Connecting to Elasticsearch at {es_host}...") |
| ... | ... | @@ -139,15 +138,15 @@ def init_service(es_host: str = "http://localhost:9200"): |
| 139 | 138 | |
| 140 | 139 | # Initialize components |
| 141 | 140 | logger.info("Initializing query parser...") |
| 142 | - _query_parser = QueryParser(_config) | |
| 141 | + _query_parser = QueryParser(search_config) | |
| 143 | 142 | |
| 144 | 143 | logger.info("Initializing searcher...") |
| 145 | - _searcher = Searcher(_es_client, _config, _query_parser) | |
| 144 | + _searcher = Searcher(_es_client, search_config, _query_parser) | |
| 146 | 145 | logger.info("Initializing suggestion service...") |
| 147 | 146 | _suggestion_service = SuggestionService(_es_client) |
| 148 | 147 | |
| 149 | 148 | elapsed = time.time() - start_time |
| 150 | - logger.info(f"Search service ready! (took {elapsed:.2f}s) | Index: {_config.es_index_name}") | |
| 149 | + logger.info(f"Search service ready! (took {elapsed:.2f}s) | Index: {search_config.es_index_name}") | |
| 151 | 150 | |
| 152 | 151 | |
| 153 | 152 | |
| ... | ... | @@ -182,9 +181,9 @@ def get_suggestion_service() -> SuggestionService: |
| 182 | 181 | |
| 183 | 182 | def get_config(): |
| 184 | 183 | """Get global config instance.""" |
| 185 | - if _config is None: | |
| 184 | + if _app_config is None: | |
| 186 | 185 | raise RuntimeError("Service not initialized") |
| 187 | - return _config | |
| 186 | + return _app_config | |
| 188 | 187 | |
| 189 | 188 | |
| 190 | 189 | # Create FastAPI app with enhanced configuration |
| ... | ... | @@ -240,7 +239,7 @@ async def startup_event(): |
| 240 | 239 | except Exception as e: |
| 241 | 240 | logger.warning(f"Failed to set thread pool size: {e}, using default") |
| 242 | 241 | |
| 243 | - es_host = os.getenv("ES_HOST", "http://localhost:9200") | |
| 242 | + es_host = get_app_config().infrastructure.elasticsearch.host | |
| 244 | 243 | logger.info("Starting E-Commerce Search API (Multi-Tenant)") |
| 245 | 244 | logger.info(f"Elasticsearch Host: {es_host}") |
| 246 | 245 | ... | ... |
api/indexer_app.py
| ... | ... | @@ -38,8 +38,7 @@ logger = logging.getLogger(__name__) |
| 38 | 38 | # Add parent directory to path |
| 39 | 39 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| 40 | 40 | |
| 41 | -from config.env_config import ES_CONFIG # noqa: E402 | |
| 42 | -from config import ConfigLoader # noqa: E402 | |
| 41 | +from config import get_app_config # noqa: E402 | |
| 43 | 42 | from utils import ESClient # noqa: E402 |
| 44 | 43 | from utils.db_connector import create_db_connection # noqa: E402 |
| 45 | 44 | from indexer.incremental_service import IncrementalIndexerService # noqa: E402 |
| ... | ... | @@ -55,7 +54,7 @@ from .service_registry import ( |
| 55 | 54 | |
| 56 | 55 | |
| 57 | 56 | _es_client: Optional[ESClient] = None |
| 58 | -_config = None | |
| 57 | +_app_config = None | |
| 59 | 58 | _incremental_service: Optional[IncrementalIndexerService] = None |
| 60 | 59 | _bulk_indexing_service: Optional[BulkIndexingService] = None |
| 61 | 60 | _suggestion_builder: Optional[SuggestionIndexBuilder] = None |
| ... | ... | @@ -68,20 +67,19 @@ def init_indexer_service(es_host: str = "http://localhost:9200"): |
| 68 | 67 | This mirrors the indexing-related initialization logic in api.app.init_service |
| 69 | 68 | but without search-related components. |
| 70 | 69 | """ |
| 71 | - global _es_client, _config, _incremental_service, _bulk_indexing_service, _suggestion_builder | |
| 70 | + global _es_client, _app_config, _incremental_service, _bulk_indexing_service, _suggestion_builder | |
| 72 | 71 | |
| 73 | 72 | start_time = time.time() |
| 74 | 73 | logger.info("Initializing Indexer service") |
| 75 | 74 | |
| 76 | 75 | # Load configuration (kept for parity/logging; indexer routes don't depend on it) |
| 77 | 76 | logger.info("Loading configuration...") |
| 78 | - config_loader = ConfigLoader("config/config.yaml") | |
| 79 | - _config = config_loader.load_config() | |
| 77 | + _app_config = get_app_config() | |
| 80 | 78 | logger.info("Configuration loaded") |
| 81 | 79 | |
| 82 | 80 | # Get ES credentials |
| 83 | - es_username = os.getenv("ES_USERNAME") or ES_CONFIG.get("username") | |
| 84 | - es_password = os.getenv("ES_PASSWORD") or ES_CONFIG.get("password") | |
| 81 | + es_username = _app_config.infrastructure.elasticsearch.username | |
| 82 | + es_password = _app_config.infrastructure.elasticsearch.password | |
| 85 | 83 | |
| 86 | 84 | # Connect to Elasticsearch |
| 87 | 85 | logger.info(f"Connecting to Elasticsearch at {es_host} for indexer...") |
| ... | ... | @@ -97,11 +95,12 @@ def init_indexer_service(es_host: str = "http://localhost:9200"): |
| 97 | 95 | set_es_client(_es_client) |
| 98 | 96 | |
| 99 | 97 | # Initialize indexing services (DB is required here) |
| 100 | - db_host = os.getenv("DB_HOST") | |
| 101 | - db_port = int(os.getenv("DB_PORT", 3306)) | |
| 102 | - db_database = os.getenv("DB_DATABASE") | |
| 103 | - db_username = os.getenv("DB_USERNAME") | |
| 104 | - db_password = os.getenv("DB_PASSWORD") | |
| 98 | + db_config = _app_config.infrastructure.database | |
| 99 | + db_host = db_config.host | |
| 100 | + db_port = db_config.port | |
| 101 | + db_database = db_config.database | |
| 102 | + db_username = db_config.username | |
| 103 | + db_password = db_config.password | |
| 105 | 104 | |
| 106 | 105 | if all([db_host, db_database, db_username, db_password]): |
| 107 | 106 | logger.info("Initializing database connection for indexing services...") |
| ... | ... | @@ -166,7 +165,7 @@ async def startup_event(): |
| 166 | 165 | except Exception as e: |
| 167 | 166 | logger.warning(f"Failed to set thread pool size: {e}, using default") |
| 168 | 167 | |
| 169 | - es_host = os.getenv("ES_HOST", "http://localhost:9200") | |
| 168 | + es_host = get_app_config().infrastructure.elasticsearch.host | |
| 170 | 169 | logger.info("Starting Indexer API service") |
| 171 | 170 | logger.info(f"Elasticsearch Host: {es_host}") |
| 172 | 171 | try: |
| ... | ... | @@ -176,14 +175,11 @@ async def startup_event(): |
| 176 | 175 | # Eager warmup: build per-tenant transformer bundles at startup to avoid |
| 177 | 176 | # first-request latency (config/provider/encoder + transformer wiring). |
| 178 | 177 | try: |
| 179 | - if _incremental_service is not None and _config is not None: | |
| 178 | + if _incremental_service is not None and _app_config is not None: | |
| 180 | 179 | tenants = [] |
| 181 | - # config.tenant_config shape: {"default": {...}, "tenants": {"1": {...}, ...}} | |
| 182 | - tc = getattr(_config, "tenant_config", None) or {} | |
| 183 | - if isinstance(tc, dict): | |
| 184 | - tmap = tc.get("tenants") | |
| 185 | - if isinstance(tmap, dict): | |
| 186 | - tenants = [str(k) for k in tmap.keys()] | |
| 180 | + tmap = _app_config.tenants.tenants | |
| 181 | + if isinstance(tmap, dict): | |
| 182 | + tenants = [str(k) for k in tmap.keys()] | |
| 187 | 183 | # If no explicit tenants configured, skip warmup. |
| 188 | 184 | if tenants: |
| 189 | 185 | warm = _incremental_service.warmup_transformers(tenants) | ... | ... |
api/routes/admin.py
| ... | ... | @@ -42,23 +42,32 @@ async def health_check(): |
| 42 | 42 | @router.get("/config") |
| 43 | 43 | async def get_configuration(): |
| 44 | 44 | """ |
| 45 | - Get current search configuration (sanitized). | |
| 45 | + Get the effective application configuration (sanitized). | |
| 46 | 46 | """ |
| 47 | 47 | try: |
| 48 | 48 | from ..app import get_config |
| 49 | 49 | |
| 50 | - config = get_config() | |
| 50 | + return get_config().sanitized_dict() | |
| 51 | + | |
| 52 | + except HTTPException: | |
| 53 | + raise | |
| 54 | + except Exception as e: | |
| 55 | + raise HTTPException(status_code=500, detail=str(e)) | |
| 51 | 56 | |
| 57 | + | |
| 58 | +@router.get("/config/meta") | |
| 59 | +async def get_configuration_meta(): | |
| 60 | + """Get configuration metadata for observability.""" | |
| 61 | + try: | |
| 62 | + from ..app import get_config | |
| 63 | + | |
| 64 | + config = get_config() | |
| 52 | 65 | return { |
| 53 | - "es_index_name": config.es_index_name, | |
| 54 | - "num_field_boosts": len(config.field_boosts), | |
| 55 | - "multilingual_fields": config.query_config.multilingual_fields, | |
| 56 | - "shared_fields": config.query_config.shared_fields, | |
| 57 | - "core_multilingual_fields": config.query_config.core_multilingual_fields, | |
| 58 | - "supported_languages": config.query_config.supported_languages, | |
| 59 | - "spu_enabled": config.spu_config.enabled | |
| 66 | + "environment": config.runtime.environment, | |
| 67 | + "config_hash": config.metadata.config_hash, | |
| 68 | + "loaded_files": list(config.metadata.loaded_files), | |
| 69 | + "deprecated_keys": list(config.metadata.deprecated_keys), | |
| 60 | 70 | } |
| 61 | - | |
| 62 | 71 | except HTTPException: |
| 63 | 72 | raise |
| 64 | 73 | except Exception as e: | ... | ... |
config/__init__.py
| 1 | -""" | |
| 2 | -Configuration package for search engine. | |
| 1 | +"""Unified configuration package exports.""" | |
| 3 | 2 | |
| 4 | -Provides configuration loading, validation, and utility functions. | |
| 5 | -""" | |
| 6 | - | |
| 7 | -from .config_loader import ( | |
| 8 | - SearchConfig, | |
| 9 | - QueryConfig, | |
| 10 | - IndexConfig, | |
| 11 | - SPUConfig, | |
| 3 | +from config.config_loader import ConfigLoader, ConfigurationError | |
| 4 | +from config.loader import AppConfigLoader, get_app_config, reload_app_config | |
| 5 | +from config.schema import ( | |
| 6 | + AppConfig, | |
| 12 | 7 | FunctionScoreConfig, |
| 8 | + IndexConfig, | |
| 9 | + QueryConfig, | |
| 13 | 10 | RerankConfig, |
| 14 | - ConfigLoader, | |
| 15 | - ConfigurationError | |
| 16 | -) | |
| 17 | - | |
| 18 | -from .utils import ( | |
| 19 | - get_match_fields_for_index, | |
| 20 | - get_domain_fields | |
| 11 | + SPUConfig, | |
| 12 | + SearchConfig, | |
| 13 | + ServicesConfig, | |
| 21 | 14 | ) |
| 22 | -from .services_config import ( | |
| 23 | - get_translation_config, | |
| 24 | - get_embedding_config, | |
| 25 | - get_rerank_config, | |
| 15 | +from config.services_config import ( | |
| 26 | 16 | get_embedding_backend_config, |
| 27 | - get_rerank_backend_config, | |
| 28 | - get_translation_base_url, | |
| 29 | - get_embedding_text_base_url, | |
| 17 | + get_embedding_config, | |
| 18 | + get_embedding_image_backend_config, | |
| 30 | 19 | get_embedding_image_base_url, |
| 20 | + get_embedding_text_base_url, | |
| 21 | + get_rerank_backend_config, | |
| 22 | + get_rerank_config, | |
| 31 | 23 | get_rerank_service_url, |
| 24 | + get_translation_base_url, | |
| 32 | 25 | get_translation_cache_config, |
| 33 | - ServiceConfig, | |
| 26 | + get_translation_config, | |
| 34 | 27 | ) |
| 28 | +from config.utils import get_domain_fields, get_match_fields_for_index | |
| 35 | 29 | |
| 36 | 30 | __all__ = [ |
| 37 | - # Main config classes | |
| 38 | - 'SearchConfig', | |
| 39 | - 'QueryConfig', | |
| 40 | - 'IndexConfig', | |
| 41 | - 'SPUConfig', | |
| 42 | - 'FunctionScoreConfig', | |
| 43 | - 'RerankConfig', | |
| 44 | - | |
| 45 | - # Loader and utilities | |
| 46 | - 'ConfigLoader', | |
| 47 | - 'ConfigurationError', | |
| 48 | - 'get_match_fields_for_index', | |
| 49 | - 'get_domain_fields', | |
| 50 | - 'get_translation_config', | |
| 51 | - 'get_embedding_config', | |
| 52 | - 'get_rerank_config', | |
| 53 | - 'get_embedding_backend_config', | |
| 54 | - 'get_rerank_backend_config', | |
| 55 | - 'get_translation_base_url', | |
| 56 | - 'get_embedding_text_base_url', | |
| 57 | - 'get_embedding_image_base_url', | |
| 58 | - 'get_rerank_service_url', | |
| 59 | - 'get_translation_cache_config', | |
| 60 | - 'ServiceConfig', | |
| 31 | + "AppConfig", | |
| 32 | + "AppConfigLoader", | |
| 33 | + "ConfigLoader", | |
| 34 | + "ConfigurationError", | |
| 35 | + "FunctionScoreConfig", | |
| 36 | + "IndexConfig", | |
| 37 | + "QueryConfig", | |
| 38 | + "RerankConfig", | |
| 39 | + "SPUConfig", | |
| 40 | + "SearchConfig", | |
| 41 | + "ServicesConfig", | |
| 42 | + "get_app_config", | |
| 43 | + "reload_app_config", | |
| 44 | + "get_domain_fields", | |
| 45 | + "get_match_fields_for_index", | |
| 46 | + "get_translation_config", | |
| 47 | + "get_embedding_config", | |
| 48 | + "get_rerank_config", | |
| 49 | + "get_embedding_backend_config", | |
| 50 | + "get_embedding_image_backend_config", | |
| 51 | + "get_rerank_backend_config", | |
| 52 | + "get_translation_base_url", | |
| 53 | + "get_embedding_text_base_url", | |
| 54 | + "get_embedding_image_base_url", | |
| 55 | + "get_rerank_service_url", | |
| 56 | + "get_translation_cache_config", | |
| 61 | 57 | ] | ... | ... |
config/config.yaml
| ... | ... | @@ -5,6 +5,10 @@ |
| 5 | 5 | # Elasticsearch Index |
| 6 | 6 | es_index_name: "search_products" |
| 7 | 7 | |
| 8 | +# Config assets | |
| 9 | +assets: | |
| 10 | + query_rewrite_dictionary_path: "config/dictionaries/query_rewrite.dict" | |
| 11 | + | |
| 8 | 12 | # ES Index Settings (ๅบ็ก่ฎพ็ฝฎ) |
| 9 | 13 | es_settings: |
| 10 | 14 | number_of_shards: 1 |
| ... | ... | @@ -211,6 +215,19 @@ services: |
| 211 | 215 | device: "cuda" |
| 212 | 216 | batch_size: 32 |
| 213 | 217 | normalize_embeddings: true |
| 218 | + # ๆๅกๅ ๅพ็ๅ็ซฏ๏ผembedding ่ฟ็จๅฏๅจๆถ่ฏปๅ๏ผ | |
| 219 | + image_backend: "clip_as_service" # clip_as_service | local_cnclip | |
| 220 | + image_backends: | |
| 221 | + clip_as_service: | |
| 222 | + server: "grpc://127.0.0.1:51000" | |
| 223 | + model_name: "CN-CLIP/ViT-L-14" | |
| 224 | + batch_size: 8 | |
| 225 | + normalize_embeddings: true | |
| 226 | + local_cnclip: | |
| 227 | + model_name: "ViT-L-14" | |
| 228 | + device: null | |
| 229 | + batch_size: 8 | |
| 230 | + normalize_embeddings: true | |
| 214 | 231 | rerank: |
| 215 | 232 | provider: "http" |
| 216 | 233 | base_url: "http://127.0.0.1:6007" |
| ... | ... | @@ -218,6 +235,9 @@ services: |
| 218 | 235 | http: |
| 219 | 236 | base_url: "http://127.0.0.1:6007" |
| 220 | 237 | service_url: "http://127.0.0.1:6007/rerank" |
| 238 | + request: | |
| 239 | + max_docs: 1000 | |
| 240 | + normalize: true | |
| 221 | 241 | # ๆๅกๅ ๅ็ซฏ๏ผreranker ่ฟ็จๅฏๅจๆถ่ฏปๅ๏ผ |
| 222 | 242 | backend: "qwen3_vllm" # bge | qwen3_vllm | qwen3_transformers | dashscope_rerank |
| 223 | 243 | backends: | ... | ... |
config/config_loader.py
| 1 | 1 | """ |
| 2 | -Configuration loader and validator for search engine configurations. | |
| 2 | +Compatibility wrapper for search-behavior config access. | |
| 3 | 3 | |
| 4 | -This module handles loading, parsing, and validating YAML configuration files | |
| 5 | -that define how search should be executed (NOT how data should be indexed). | |
| 6 | - | |
| 7 | -็ดขๅผ็ปๆ็ฑ mappings/search_products.json ๅฎไนใ | |
| 8 | -ๆญค้ ็ฝฎๅชๅฎไนๆ็ดข่กไธบ๏ผๅญๆฎตๆ้ใๆ็ดขๅใๆฅ่ฏข็ญ็ฅ็ญใ | |
| 4 | +The unified loader lives in :mod:`config.loader`. This module now exposes the | |
| 5 | +search subtree only, so existing search/indexer code can consume a single | |
| 6 | +source-of-truth search config without reparsing YAML separately. | |
| 9 | 7 | """ |
| 10 | 8 | |
| 11 | -import yaml | |
| 12 | -from typing import Dict, Any, List, Optional | |
| 13 | -from dataclasses import dataclass, field | |
| 14 | -from pathlib import Path | |
| 15 | - | |
| 16 | - | |
| 17 | -@dataclass | |
| 18 | -class IndexConfig: | |
| 19 | - """Configuration for an index domain (e.g., default, title, brand).""" | |
| 20 | - name: str | |
| 21 | - label: str | |
| 22 | - fields: List[str] # List of field names to include in this search domain | |
| 23 | - boost: float = 1.0 | |
| 24 | - example: Optional[str] = None | |
| 25 | - | |
| 26 | - | |
| 27 | -@dataclass | |
| 28 | -class QueryConfig: | |
| 29 | - """Configuration for query processing.""" | |
| 30 | - supported_languages: List[str] = field(default_factory=lambda: ["zh", "en"]) | |
| 31 | - default_language: str = "en" | |
| 32 | - | |
| 33 | - # Feature flags | |
| 34 | - enable_text_embedding: bool = True | |
| 35 | - enable_query_rewrite: bool = True | |
| 36 | - | |
| 37 | - # Query rewrite dictionary (loaded from external file) | |
| 38 | - rewrite_dictionary: Dict[str, str] = field(default_factory=dict) | |
| 39 | - | |
| 40 | - # Embedding field names | |
| 41 | - text_embedding_field: Optional[str] = "title_embedding" | |
| 42 | - image_embedding_field: Optional[str] = None | |
| 43 | - | |
| 44 | - # Source fields configuration | |
| 45 | - source_fields: Optional[List[str]] = None | |
| 46 | - | |
| 47 | - # KNN boost configuration | |
| 48 | - knn_boost: float = 0.25 # Boost value for KNN (embedding recall) | |
| 49 | - | |
| 50 | - # Dynamic text fields for multi-language retrieval | |
| 51 | - multilingual_fields: List[str] = field( | |
| 52 | - default_factory=lambda: ["title", "brief", "description", "vendor", "category_path", "category_name_text"] | |
| 53 | - ) | |
| 54 | - shared_fields: List[str] = field( | |
| 55 | - default_factory=lambda: ["tags", "option1_values", "option2_values", "option3_values"] | |
| 56 | - ) | |
| 57 | - core_multilingual_fields: List[str] = field( | |
| 58 | - default_factory=lambda: ["title", "brief", "vendor", "category_name_text"] | |
| 59 | - ) | |
| 60 | - | |
| 61 | - # Unified text strategy tuning | |
| 62 | - base_minimum_should_match: str = "75%" | |
| 63 | - translation_minimum_should_match: str = "75%" | |
| 64 | - translation_boost: float = 0.4 | |
| 65 | - translation_boost_when_source_missing: float = 1.0 | |
| 66 | - source_boost_when_missing: float = 0.6 | |
| 67 | - original_query_fallback_boost_when_translation_missing: float = 0.2 | |
| 68 | - tie_breaker_base_query: float = 0.9 | |
| 69 | - | |
| 70 | - # Query-time translation model selection (configurable) | |
| 71 | - # - zh_to_en_model: model for zh -> en | |
| 72 | - # - en_to_zh_model: model for en -> zh | |
| 73 | - # - default_translation_model: fallback model for all other language pairs | |
| 74 | - zh_to_en_model: str = "opus-mt-zh-en" | |
| 75 | - en_to_zh_model: str = "opus-mt-en-zh" | |
| 76 | - default_translation_model: str = "nllb-200-distilled-600m" | |
| 77 | - | |
| 78 | - | |
| 79 | -@dataclass | |
| 80 | -class SPUConfig: | |
| 81 | - """Configuration for SPU aggregation.""" | |
| 82 | - enabled: bool = False | |
| 83 | - spu_field: Optional[str] = None | |
| 84 | - inner_hits_size: int = 3 | |
| 85 | - # ้ ็ฝฎๅชไบoption็ปดๅบฆๅไธๆฃ็ดข๏ผ่ฟ็ดขๅผใไปฅๅๅจ็บฟๆ็ดข๏ผ | |
| 86 | - searchable_option_dimensions: List[str] = field(default_factory=lambda: ['option1', 'option2', 'option3']) | |
| 87 | - | |
| 88 | - | |
| 89 | -@dataclass | |
| 90 | -class FunctionScoreConfig: | |
| 91 | - """Function Score้ ็ฝฎ๏ผESๅฑๆๅ่งๅ๏ผ""" | |
| 92 | - score_mode: str = "sum" | |
| 93 | - boost_mode: str = "multiply" | |
| 94 | - functions: List[Dict[str, Any]] = field(default_factory=list) | |
| 95 | - | |
| 96 | - | |
| 97 | -@dataclass | |
| 98 | -class RerankConfig: | |
| 99 | - """้ๆ้ ็ฝฎ๏ผprovider/URL ๅจ services.rerank๏ผ""" | |
| 100 | - enabled: bool = True | |
| 101 | - rerank_window: int = 384 | |
| 102 | - timeout_sec: float = 15.0 | |
| 103 | - weight_es: float = 0.4 | |
| 104 | - weight_ai: float = 0.6 | |
| 105 | - rerank_query_template: str = "{query}" | |
| 106 | - rerank_doc_template: str = "{title}" | |
| 107 | - | |
| 108 | - | |
| 109 | -@dataclass | |
| 110 | -class SearchConfig: | |
| 111 | - """Complete configuration for search engine (multi-tenant).""" | |
| 112 | - | |
| 113 | - # ๅญๆฎตๆ้้ ็ฝฎ๏ผ็จไบๆ็ดข๏ผ | |
| 114 | - field_boosts: Dict[str, float] | |
| 115 | - | |
| 116 | - # Legacy index domains (deprecated; kept for compatibility) | |
| 117 | - indexes: List[IndexConfig] | |
| 118 | - | |
| 119 | - # Query processing | |
| 120 | - query_config: QueryConfig | |
| 121 | - | |
| 122 | - # Function Score configuration (ESๅฑๆๅ) | |
| 123 | - function_score: FunctionScoreConfig | |
| 124 | - | |
| 125 | - # Rerank configuration (ๆฌๅฐ้ๆ) | |
| 126 | - rerank: RerankConfig | |
| 127 | - | |
| 128 | - # SPU configuration | |
| 129 | - spu_config: SPUConfig | |
| 130 | - | |
| 131 | - # ES index settings | |
| 132 | - es_index_name: str | |
| 133 | - | |
| 134 | - # Tenant configuration | |
| 135 | - tenant_config: Dict[str, Any] = field(default_factory=dict) | |
| 136 | - | |
| 137 | - # ES settings | |
| 138 | - es_settings: Dict[str, Any] = field(default_factory=dict) | |
| 139 | - # Extensible service/provider registry (translation/embedding/rerank/...) | |
| 140 | - services: Dict[str, Any] = field(default_factory=dict) | |
| 9 | +from __future__ import annotations | |
| 141 | 10 | |
| 11 | +from dataclasses import asdict | |
| 12 | +from pathlib import Path | |
| 13 | +from typing import Any, Dict, List, Optional | |
| 142 | 14 | |
| 143 | -class ConfigurationError(Exception): | |
| 144 | - """Raised when configuration validation fails.""" | |
| 145 | - pass | |
| 15 | +from config.loader import AppConfigLoader, ConfigurationError | |
| 16 | +from config.schema import ( | |
| 17 | + FunctionScoreConfig, | |
| 18 | + IndexConfig, | |
| 19 | + QueryConfig, | |
| 20 | + RerankConfig, | |
| 21 | + SPUConfig, | |
| 22 | + SearchConfig, | |
| 23 | +) | |
| 146 | 24 | |
| 147 | 25 | |
| 148 | 26 | class ConfigLoader: |
| 149 | - """Loads and validates unified search engine configuration from YAML file.""" | |
| 150 | - | |
| 151 | - def __init__(self, config_file: Optional[Path] = None): | |
| 152 | - """ | |
| 153 | - Initialize config loader. | |
| 154 | - | |
| 155 | - Args: | |
| 156 | - config_file: Path to config YAML file (defaults to config/config.yaml) | |
| 157 | - """ | |
| 158 | - if config_file is None: | |
| 159 | - config_file = Path(__file__).parent / "config.yaml" | |
| 160 | - self.config_file = Path(config_file) | |
| 161 | - | |
| 162 | - def _load_rewrite_dictionary(self) -> Dict[str, str]: | |
| 163 | - """Load query rewrite dictionary from external file.""" | |
| 164 | - rewrite_file = Path(__file__).parent / "rewrite_dictionary.txt" | |
| 165 | - rewrite_dict = {} | |
| 166 | - | |
| 167 | - if not rewrite_file.exists(): | |
| 168 | - return rewrite_dict | |
| 169 | - | |
| 170 | - try: | |
| 171 | - with open(rewrite_file, 'r', encoding='utf-8') as f: | |
| 172 | - for line in f: | |
| 173 | - line = line.strip() | |
| 174 | - if not line or line.startswith('#'): | |
| 175 | - continue | |
| 176 | - | |
| 177 | - parts = line.split('\t') | |
| 178 | - if len(parts) >= 2: | |
| 179 | - original = parts[0].strip() | |
| 180 | - replacement = parts[1].strip() | |
| 181 | - if original and replacement: | |
| 182 | - rewrite_dict[original] = replacement | |
| 183 | - except Exception as e: | |
| 184 | - print(f"Warning: Failed to load rewrite dictionary: {e}") | |
| 185 | - | |
| 186 | - return rewrite_dict | |
| 187 | - | |
| 188 | - def load_config(self, validate: bool = True) -> SearchConfig: | |
| 189 | - """ | |
| 190 | - Load unified configuration from YAML file. | |
| 191 | - | |
| 192 | - Args: | |
| 193 | - validate: Whether to validate configuration after loading | |
| 194 | - | |
| 195 | - Returns: | |
| 196 | - SearchConfig object | |
| 197 | - | |
| 198 | - Raises: | |
| 199 | - ConfigurationError: If config file not found, invalid, or validation fails | |
| 200 | - """ | |
| 201 | - if not self.config_file.exists(): | |
| 202 | - raise ConfigurationError(f"Configuration file not found: {self.config_file}") | |
| 203 | - | |
| 204 | - try: | |
| 205 | - with open(self.config_file, 'r', encoding='utf-8') as f: | |
| 206 | - config_data = yaml.safe_load(f) | |
| 207 | - except yaml.YAMLError as e: | |
| 208 | - raise ConfigurationError(f"Invalid YAML in {self.config_file}: {e}") | |
| 209 | - | |
| 210 | - config = self._parse_config(config_data) | |
| 211 | - | |
| 212 | - # Auto-validate configuration | |
| 213 | - if validate: | |
| 214 | - errors = self.validate_config(config) | |
| 215 | - if errors: | |
| 216 | - error_msg = "Configuration validation failed:\n" + "\n".join(f" - {err}" for err in errors) | |
| 217 | - raise ConfigurationError(error_msg) | |
| 218 | - | |
| 219 | - return config | |
| 220 | - | |
| 221 | - def _parse_config(self, config_data: Dict[str, Any]) -> SearchConfig: | |
| 222 | - """Parse configuration dictionary into SearchConfig object.""" | |
| 223 | - | |
| 224 | - # Parse field_boosts | |
| 225 | - field_boosts = config_data.get("field_boosts", {}) | |
| 226 | - if not isinstance(field_boosts, dict): | |
| 227 | - raise ConfigurationError("field_boosts must be a dictionary") | |
| 228 | - | |
| 229 | - # Parse indexes (deprecated; compatibility only) | |
| 230 | - indexes = [] | |
| 231 | - for index_data in config_data.get("indexes", []): | |
| 232 | - indexes.append(self._parse_index_config(index_data)) | |
| 233 | - | |
| 234 | - # Parse query config | |
| 235 | - query_config_data = config_data.get("query_config", {}) | |
| 236 | - rewrite_dictionary = self._load_rewrite_dictionary() | |
| 237 | - search_fields_cfg = query_config_data.get("search_fields", {}) | |
| 238 | - text_strategy_cfg = query_config_data.get("text_query_strategy", {}) | |
| 27 | + """Load the unified app config and return the search subtree.""" | |
| 239 | 28 | |
| 240 | - query_config = QueryConfig( | |
| 241 | - supported_languages=query_config_data.get("supported_languages") or ["zh", "en"], | |
| 242 | - default_language=query_config_data.get("default_language") or "en", | |
| 243 | - enable_text_embedding=query_config_data.get("enable_text_embedding", True), | |
| 244 | - enable_query_rewrite=query_config_data.get("enable_query_rewrite", True), | |
| 245 | - rewrite_dictionary=rewrite_dictionary, | |
| 246 | - text_embedding_field=query_config_data.get("text_embedding_field"), | |
| 247 | - image_embedding_field=query_config_data.get("image_embedding_field"), | |
| 248 | - source_fields=query_config_data.get("source_fields"), | |
| 249 | - knn_boost=query_config_data.get("knn_boost", 0.25), | |
| 250 | - multilingual_fields=search_fields_cfg.get( | |
| 251 | - "multilingual_fields", | |
| 252 | - ["title", "brief", "description", "vendor", "category_path", "category_name_text"], | |
| 253 | - ), | |
| 254 | - shared_fields=search_fields_cfg.get( | |
| 255 | - "shared_fields", | |
| 256 | - ["tags", "option1_values", "option2_values", "option3_values"], | |
| 257 | - ), | |
| 258 | - core_multilingual_fields=search_fields_cfg.get( | |
| 259 | - "core_multilingual_fields", | |
| 260 | - ["title", "brief", "vendor", "category_name_text"], | |
| 261 | - ), | |
| 262 | - base_minimum_should_match=str(text_strategy_cfg.get("base_minimum_should_match", "75%")), | |
| 263 | - translation_minimum_should_match=str(text_strategy_cfg.get("translation_minimum_should_match", "75%")), | |
| 264 | - translation_boost=float(text_strategy_cfg.get("translation_boost", 0.4)), | |
| 265 | - translation_boost_when_source_missing=float( | |
| 266 | - text_strategy_cfg.get("translation_boost_when_source_missing", 1.0) | |
| 267 | - ), | |
| 268 | - source_boost_when_missing=float(text_strategy_cfg.get("source_boost_when_missing", 0.6)), | |
| 269 | - original_query_fallback_boost_when_translation_missing=float( | |
| 270 | - text_strategy_cfg.get("original_query_fallback_boost_when_translation_missing", 0.2) | |
| 271 | - ), | |
| 272 | - tie_breaker_base_query=float(text_strategy_cfg.get("tie_breaker_base_query", 0.9)), | |
| 273 | - zh_to_en_model=str(query_config_data.get("zh_to_en_model") or "opus-mt-zh-en"), | |
| 274 | - en_to_zh_model=str(query_config_data.get("en_to_zh_model") or "opus-mt-en-zh"), | |
| 275 | - default_translation_model=str( | |
| 276 | - query_config_data.get("default_translation_model") or "nllb-200-distilled-600m" | |
| 277 | - ), | |
| 278 | - ) | |
| 279 | - | |
| 280 | - # Parse Function Score configuration | |
| 281 | - fs_data = config_data.get("function_score", {}) | |
| 282 | - function_score = FunctionScoreConfig( | |
| 283 | - score_mode=fs_data.get("score_mode") or "sum", | |
| 284 | - boost_mode=fs_data.get("boost_mode") or "multiply", | |
| 285 | - functions=fs_data.get("functions") or [] | |
| 286 | - ) | |
| 287 | - | |
| 288 | - # Parse Rerank (provider/URL in services.rerank) | |
| 289 | - rerank_data = config_data.get("rerank", {}) | |
| 290 | - rerank = RerankConfig( | |
| 291 | - enabled=bool(rerank_data.get("enabled", True)), | |
| 292 | - rerank_window=int(rerank_data.get("rerank_window", 384)), | |
| 293 | - timeout_sec=float(rerank_data.get("timeout_sec", 15.0)), | |
| 294 | - weight_es=float(rerank_data.get("weight_es", 0.4)), | |
| 295 | - weight_ai=float(rerank_data.get("weight_ai", 0.6)), | |
| 296 | - rerank_query_template=str(rerank_data.get("rerank_query_template") or "{query}"), | |
| 297 | - rerank_doc_template=str(rerank_data.get("rerank_doc_template") or "{title}"), | |
| 298 | - ) | |
| 299 | - | |
| 300 | - # Parse SPU config | |
| 301 | - spu_data = config_data.get("spu_config", {}) | |
| 302 | - spu_config = SPUConfig( | |
| 303 | - enabled=spu_data.get("enabled", False), | |
| 304 | - spu_field=spu_data.get("spu_field"), | |
| 305 | - inner_hits_size=spu_data.get("inner_hits_size", 3), | |
| 306 | - searchable_option_dimensions=spu_data.get("searchable_option_dimensions", ['option1', 'option2', 'option3']) | |
| 307 | - ) | |
| 308 | - | |
| 309 | - # Parse tenant config | |
| 310 | - tenant_config_data = config_data.get("tenant_config", {}) | |
| 29 | + def __init__(self, config_file: Optional[Path] = None) -> None: | |
| 30 | + self._loader = AppConfigLoader(config_file=Path(config_file) if config_file is not None else None) | |
| 311 | 31 | |
| 312 | - # Parse extensible services/provider registry | |
| 313 | - services_data = config_data.get("services", {}) or {} | |
| 314 | - if not isinstance(services_data, dict): | |
| 315 | - raise ConfigurationError("services must be a dictionary if provided") | |
| 32 | + def load_config(self, validate: bool = True) -> SearchConfig: | |
| 33 | + return self._loader.load(validate=validate).search | |
| 316 | 34 | |
| 317 | - return SearchConfig( | |
| 318 | - field_boosts=field_boosts, | |
| 319 | - indexes=indexes, | |
| 320 | - query_config=query_config, | |
| 321 | - function_score=function_score, | |
| 322 | - rerank=rerank, | |
| 323 | - spu_config=spu_config, | |
| 324 | - tenant_config=tenant_config_data, | |
| 325 | - es_index_name=config_data.get("es_index_name", "search_products"), | |
| 326 | - es_settings=config_data.get("es_settings", {}), | |
| 327 | - services=services_data | |
| 328 | - ) | |
| 329 | - | |
| 330 | - def _parse_index_config(self, index_data: Dict[str, Any]) -> IndexConfig: | |
| 331 | - """Parse index configuration from dictionary.""" | |
| 332 | - return IndexConfig( | |
| 333 | - name=index_data["name"], | |
| 334 | - label=index_data.get("label", index_data["name"]), | |
| 335 | - fields=index_data.get("fields", []), | |
| 336 | - boost=index_data.get("boost", 1.0), | |
| 337 | - example=index_data.get("example") | |
| 338 | - ) | |
| 339 | - | |
| 340 | 35 | def validate_config(self, config: SearchConfig) -> List[str]: |
| 341 | - """ | |
| 342 | - Validate configuration for common errors. | |
| 343 | - | |
| 344 | - Args: | |
| 345 | - config: SearchConfig to validate | |
| 346 | - | |
| 347 | - Returns: | |
| 348 | - List of error messages (empty if valid) | |
| 349 | - """ | |
| 350 | - errors = [] | |
| 351 | - | |
| 352 | - # Validate es_index_name | |
| 36 | + errors: List[str] = [] | |
| 353 | 37 | if not config.es_index_name: |
| 354 | 38 | errors.append("es_index_name is required") |
| 355 | - | |
| 356 | - # Validate field_boosts | |
| 357 | 39 | if not config.field_boosts: |
| 358 | 40 | errors.append("field_boosts is empty") |
| 359 | - | |
| 360 | - for field_name, boost in config.field_boosts.items(): | |
| 361 | - if not isinstance(boost, (int, float)): | |
| 362 | - errors.append(f"field_boosts['{field_name}']: boost must be a number, got {type(boost).__name__}") | |
| 363 | - elif boost < 0: | |
| 364 | - errors.append(f"field_boosts['{field_name}']: boost must be non-negative") | |
| 365 | - | |
| 366 | - # Validate indexes (deprecated, optional) | |
| 367 | - index_names = set() | |
| 368 | - for index in config.indexes: | |
| 369 | - # Check for duplicate index names | |
| 370 | - if index.name in index_names: | |
| 371 | - errors.append(f"Duplicate index name: {index.name}") | |
| 372 | - index_names.add(index.name) | |
| 373 | - | |
| 374 | - # Validate fields in index | |
| 375 | - if not index.fields: | |
| 376 | - errors.append(f"Index '{index.name}': fields list is empty") | |
| 377 | - | |
| 378 | - # Validate SPU config | |
| 379 | - if config.spu_config.enabled: | |
| 380 | - if not config.spu_config.spu_field: | |
| 381 | - errors.append("SPU aggregation enabled but no spu_field specified") | |
| 382 | - | |
| 383 | - # Validate query config | |
| 384 | - if not config.query_config.supported_languages: | |
| 385 | - errors.append("At least one supported language must be specified") | |
| 386 | - | |
| 387 | 41 | if config.query_config.default_language not in config.query_config.supported_languages: |
| 388 | - errors.append( | |
| 389 | - f"Default language '{config.query_config.default_language}' " | |
| 390 | - f"not in supported languages: {config.query_config.supported_languages}" | |
| 391 | - ) | |
| 392 | - | |
| 393 | - # Validate dynamic search fields | |
| 394 | - def _validate_str_list(name: str, values: List[str]) -> None: | |
| 395 | - if not isinstance(values, list) or not values: | |
| 396 | - errors.append(f"query_config.{name} must be a non-empty list[str]") | |
| 397 | - return | |
| 398 | - for i, val in enumerate(values): | |
| 399 | - if not isinstance(val, str) or not val.strip(): | |
| 400 | - errors.append(f"query_config.{name}[{i}] must be a non-empty string") | |
| 401 | - | |
| 402 | - _validate_str_list("multilingual_fields", config.query_config.multilingual_fields) | |
| 403 | - _validate_str_list("shared_fields", config.query_config.shared_fields) | |
| 404 | - _validate_str_list("core_multilingual_fields", config.query_config.core_multilingual_fields) | |
| 405 | - | |
| 406 | - core_set = set(config.query_config.core_multilingual_fields) | |
| 407 | - multi_set = set(config.query_config.multilingual_fields) | |
| 408 | - if not core_set.issubset(multi_set): | |
| 409 | - errors.append("query_config.core_multilingual_fields must be subset of multilingual_fields") | |
| 410 | - | |
| 411 | - # Validate text query strategy numbers | |
| 412 | - for name in ( | |
| 413 | - "translation_boost", | |
| 414 | - "translation_boost_when_source_missing", | |
| 415 | - "source_boost_when_missing", | |
| 416 | - "original_query_fallback_boost_when_translation_missing", | |
| 417 | - "tie_breaker_base_query", | |
| 418 | - ): | |
| 419 | - value = getattr(config.query_config, name, None) | |
| 420 | - if not isinstance(value, (int, float)): | |
| 421 | - errors.append(f"query_config.{name} must be a number") | |
| 422 | - elif value < 0: | |
| 423 | - errors.append(f"query_config.{name} must be non-negative") | |
| 424 | - | |
| 425 | - # Validate source_fields tri-state semantics | |
| 426 | - source_fields = config.query_config.source_fields | |
| 427 | - if source_fields is not None: | |
| 428 | - if not isinstance(source_fields, list): | |
| 429 | - errors.append("query_config.source_fields must be null or list[str]") | |
| 430 | - else: | |
| 431 | - for idx, field_name in enumerate(source_fields): | |
| 432 | - if not isinstance(field_name, str) or not field_name.strip(): | |
| 433 | - errors.append( | |
| 434 | - f"query_config.source_fields[{idx}] must be a non-empty string" | |
| 435 | - ) | |
| 436 | - | |
| 437 | - # Validate tenant config shape (default must exist in config) | |
| 438 | - tenant_cfg = config.tenant_config | |
| 439 | - if not isinstance(tenant_cfg, dict): | |
| 440 | - errors.append("tenant_config must be an object") | |
| 441 | - else: | |
| 442 | - default_cfg = tenant_cfg.get("default") | |
| 443 | - if not isinstance(default_cfg, dict): | |
| 444 | - errors.append("tenant_config.default must be configured") | |
| 445 | - else: | |
| 446 | - index_languages = default_cfg.get("index_languages") | |
| 447 | - if not isinstance(index_languages, list) or len(index_languages) == 0: | |
| 448 | - errors.append("tenant_config.default.index_languages must be a non-empty list") | |
| 449 | - | |
| 42 | + errors.append("default_language must be included in supported_languages") | |
| 43 | + if config.spu_config.enabled and not config.spu_config.spu_field: | |
| 44 | + errors.append("spu_field is required when SPU is enabled") | |
| 450 | 45 | return errors |
| 451 | - | |
| 46 | + | |
| 452 | 47 | def to_dict(self, config: SearchConfig) -> Dict[str, Any]: |
| 453 | - """Convert SearchConfig to dictionary representation.""" | |
| 454 | - | |
| 455 | - # Build query_config dict | |
| 456 | - query_config_dict = { | |
| 457 | - "supported_languages": config.query_config.supported_languages, | |
| 458 | - "default_language": config.query_config.default_language, | |
| 459 | - "enable_text_embedding": config.query_config.enable_text_embedding, | |
| 460 | - "enable_query_rewrite": config.query_config.enable_query_rewrite, | |
| 461 | - "text_embedding_field": config.query_config.text_embedding_field, | |
| 462 | - "image_embedding_field": config.query_config.image_embedding_field, | |
| 463 | - "source_fields": config.query_config.source_fields, | |
| 464 | - "search_fields": { | |
| 465 | - "multilingual_fields": config.query_config.multilingual_fields, | |
| 466 | - "shared_fields": config.query_config.shared_fields, | |
| 467 | - "core_multilingual_fields": config.query_config.core_multilingual_fields, | |
| 468 | - }, | |
| 469 | - "text_query_strategy": { | |
| 470 | - "base_minimum_should_match": config.query_config.base_minimum_should_match, | |
| 471 | - "translation_minimum_should_match": config.query_config.translation_minimum_should_match, | |
| 472 | - "translation_boost": config.query_config.translation_boost, | |
| 473 | - "translation_boost_when_source_missing": config.query_config.translation_boost_when_source_missing, | |
| 474 | - "source_boost_when_missing": config.query_config.source_boost_when_missing, | |
| 475 | - "original_query_fallback_boost_when_translation_missing": ( | |
| 476 | - config.query_config.original_query_fallback_boost_when_translation_missing | |
| 477 | - ), | |
| 478 | - "tie_breaker_base_query": config.query_config.tie_breaker_base_query, | |
| 479 | - } | |
| 480 | - } | |
| 481 | - | |
| 482 | - return { | |
| 483 | - "es_index_name": config.es_index_name, | |
| 484 | - "es_settings": config.es_settings, | |
| 485 | - "field_boosts": config.field_boosts, | |
| 486 | - "indexes": [self._index_to_dict(index) for index in config.indexes], | |
| 487 | - "query_config": query_config_dict, | |
| 488 | - "function_score": { | |
| 489 | - "score_mode": config.function_score.score_mode, | |
| 490 | - "boost_mode": config.function_score.boost_mode, | |
| 491 | - "functions": config.function_score.functions | |
| 492 | - }, | |
| 493 | - "rerank": { | |
| 494 | - "enabled": config.rerank.enabled, | |
| 495 | - "rerank_window": config.rerank.rerank_window, | |
| 496 | - "timeout_sec": config.rerank.timeout_sec, | |
| 497 | - "weight_es": config.rerank.weight_es, | |
| 498 | - "weight_ai": config.rerank.weight_ai, | |
| 499 | - "rerank_query_template": config.rerank.rerank_query_template, | |
| 500 | - "rerank_doc_template": config.rerank.rerank_doc_template, | |
| 501 | - }, | |
| 502 | - "spu_config": { | |
| 503 | - "enabled": config.spu_config.enabled, | |
| 504 | - "spu_field": config.spu_config.spu_field, | |
| 505 | - "inner_hits_size": config.spu_config.inner_hits_size, | |
| 506 | - "searchable_option_dimensions": config.spu_config.searchable_option_dimensions | |
| 507 | - }, | |
| 508 | - "services": config.services, | |
| 509 | - } | |
| 510 | - | |
| 511 | - def _index_to_dict(self, index: IndexConfig) -> Dict[str, Any]: | |
| 512 | - """Convert IndexConfig to dictionary.""" | |
| 513 | - result = { | |
| 514 | - "name": index.name, | |
| 515 | - "label": index.label, | |
| 516 | - "fields": index.fields, | |
| 517 | - "boost": index.boost | |
| 518 | - } | |
| 519 | - | |
| 520 | - if index.example: | |
| 521 | - result["example"] = index.example | |
| 522 | - | |
| 523 | - return result | |
| 48 | + return asdict(config) | ... | ... |
config/env_config.py
| 1 | 1 | """ |
| 2 | -Centralized configuration management for saas-search. | |
| 2 | +Compatibility accessors for infrastructure/runtime environment settings. | |
| 3 | 3 | |
| 4 | -Loads configuration from environment variables and .env file. | |
| 5 | -This module provides a single point for loading .env and setting defaults. | |
| 6 | -All configuration variables are exported directly - no need for getter functions. | |
| 4 | +All values are derived from the unified application config. This module no | |
| 5 | +longer owns any independent loading or precedence rules. | |
| 7 | 6 | """ |
| 8 | 7 | |
| 9 | -import os | |
| 10 | -from pathlib import Path | |
| 11 | -from dotenv import load_dotenv | |
| 12 | - | |
| 13 | -# Load .env file from project root | |
| 14 | -PROJECT_ROOT = Path(__file__).parent.parent | |
| 15 | -load_dotenv(PROJECT_ROOT / '.env') | |
| 16 | - | |
| 17 | - | |
| 18 | -# Elasticsearch Configuration | |
| 19 | -ES_CONFIG = { | |
| 20 | - 'host': os.getenv('ES_HOST', 'http://localhost:9200'), | |
| 21 | - 'username': os.getenv('ES_USERNAME'), | |
| 22 | - 'password': os.getenv('ES_PASSWORD'), | |
| 23 | -} | |
| 24 | - | |
| 25 | -# Runtime environment & index namespace | |
| 26 | -# RUNTIME_ENV: ๅฝๅ่ฟ่ก็ฏๅข๏ผๅปบ่ฎฎไฝฟ็จ prod / uat / test / dev ็ญๆไธพๅผ | |
| 27 | -RUNTIME_ENV = os.getenv('RUNTIME_ENV', 'prod') | |
| 28 | -# ES_INDEX_NAMESPACE: ็จไบๆ็ฏๅข้็ฆป็ดขๅผ็ๅฝๅ็ฉบ้ดๅ็ผ๏ผไพๅฆ "uat_" / "test_" | |
| 29 | -# ไธบ็ฉบๅญ็ฌฆไธฒๆถ่กจ็คบไธๅ ๅ็ผ๏ผ้ๅธธๆฏ prod ็ฏๅข๏ผ | |
| 30 | -ES_INDEX_NAMESPACE = os.getenv('ES_INDEX_NAMESPACE') | |
| 31 | -if ES_INDEX_NAMESPACE is None: | |
| 32 | - # ๆชๆพๅผ้ ็ฝฎๆถ๏ผ้ prod ็ฏๅข้ป่ฎคๅ "<env>_" ๅ็ผ๏ผprod ็ฏๅข้ป่ฎคไธๅ ๅ็ผ | |
| 33 | - ES_INDEX_NAMESPACE = '' if RUNTIME_ENV == 'prod' else f'{RUNTIME_ENV}_' | |
| 34 | - | |
| 35 | -# Redis Configuration | |
| 36 | -REDIS_CONFIG = { | |
| 37 | - 'host': os.getenv('REDIS_HOST', 'localhost'), | |
| 38 | - 'port': int(os.getenv('REDIS_PORT', 6479)), | |
| 39 | - 'snapshot_db': int(os.getenv('REDIS_SNAPSHOT_DB', 0)), | |
| 40 | - 'password': os.getenv('REDIS_PASSWORD'), | |
| 41 | - 'socket_timeout': int(os.getenv('REDIS_SOCKET_TIMEOUT', 1)), | |
| 42 | - 'socket_connect_timeout': int(os.getenv('REDIS_SOCKET_CONNECT_TIMEOUT', 1)), | |
| 43 | - 'retry_on_timeout': os.getenv('REDIS_RETRY_ON_TIMEOUT', 'False').lower() == 'true', | |
| 44 | - 'cache_expire_days': int(os.getenv('REDIS_CACHE_EXPIRE_DAYS', 360*2)), # 6 months | |
| 45 | - # Embedding ็ผๅญ key ๅ็ผ๏ผไพๅฆ "embedding" | |
| 46 | - 'embedding_cache_prefix': os.getenv('REDIS_EMBEDDING_CACHE_PREFIX', 'embedding'), | |
| 47 | -} | |
| 48 | - | |
| 49 | -# DeepL API Key | |
| 50 | -DEEPL_AUTH_KEY = os.getenv('DEEPL_AUTH_KEY') | |
| 51 | - | |
| 52 | -# DashScope API Key (for Qwen models) | |
| 53 | -DASHSCOPE_API_KEY = os.getenv('DASHSCOPE_API_KEY') | |
| 54 | - | |
| 55 | -# API Service Configuration | |
| 56 | -API_HOST = os.getenv('API_HOST', '0.0.0.0') | |
| 57 | -API_PORT = int(os.getenv('API_PORT', 6002)) | |
| 58 | -# Indexer service | |
| 59 | -INDEXER_HOST = os.getenv('INDEXER_HOST', '0.0.0.0') | |
| 60 | -INDEXER_PORT = int(os.getenv('INDEXER_PORT', 6004)) | |
| 61 | -# Optional dependent services | |
| 62 | -# EMBEDDING_HOST / EMBEDDING_PORT are only used by the optional combined embedding mode. | |
| 63 | -EMBEDDING_HOST = os.getenv('EMBEDDING_HOST', '127.0.0.1') | |
| 64 | -EMBEDDING_PORT = int(os.getenv('EMBEDDING_PORT', 6005)) | |
| 65 | -EMBEDDING_TEXT_HOST = os.getenv('EMBEDDING_TEXT_HOST', '127.0.0.1') | |
| 66 | -EMBEDDING_TEXT_PORT = int(os.getenv('EMBEDDING_TEXT_PORT', 6005)) | |
| 67 | -EMBEDDING_IMAGE_HOST = os.getenv('EMBEDDING_IMAGE_HOST', '127.0.0.1') | |
| 68 | -EMBEDDING_IMAGE_PORT = int(os.getenv('EMBEDDING_IMAGE_PORT', 6008)) | |
| 69 | -TRANSLATION_HOST = os.getenv('TRANSLATION_HOST', '127.0.0.1') | |
| 70 | -TRANSLATION_PORT = int(os.getenv('TRANSLATION_PORT', 6006)) | |
| 71 | -RERANKER_HOST = os.getenv('RERANKER_HOST', '127.0.0.1') | |
| 72 | -RERANKER_PORT = int(os.getenv('RERANKER_PORT', 6007)) | |
| 73 | -RERANK_PROVIDER = os.getenv('RERANK_PROVIDER', 'http') | |
| 74 | -# API_BASE_URL: ๅฆๆๆช่ฎพ็ฝฎ๏ผๆ นๆฎAPI_HOSTๆๅปบ๏ผ0.0.0.0ไฝฟ็จlocalhost๏ผ | |
| 75 | -API_BASE_URL = os.getenv('API_BASE_URL') | |
| 76 | -if not API_BASE_URL: | |
| 77 | - API_BASE_URL = f'http://localhost:{API_PORT}' if API_HOST == '0.0.0.0' else f'http://{API_HOST}:{API_PORT}' | |
| 78 | -INDEXER_BASE_URL = os.getenv('INDEXER_BASE_URL') or ( | |
| 79 | - f'http://localhost:{INDEXER_PORT}' if INDEXER_HOST == '0.0.0.0' else f'http://{INDEXER_HOST}:{INDEXER_PORT}' | |
| 80 | -) | |
| 81 | -EMBEDDING_TEXT_SERVICE_URL = os.getenv('EMBEDDING_TEXT_SERVICE_URL') or ( | |
| 82 | - f'http://{EMBEDDING_TEXT_HOST}:{EMBEDDING_TEXT_PORT}' | |
| 83 | -) | |
| 84 | -EMBEDDING_IMAGE_SERVICE_URL = os.getenv('EMBEDDING_IMAGE_SERVICE_URL') or ( | |
| 85 | - f'http://{EMBEDDING_IMAGE_HOST}:{EMBEDDING_IMAGE_PORT}' | |
| 86 | -) | |
| 87 | -RERANKER_SERVICE_URL = os.getenv('RERANKER_SERVICE_URL') or f'http://{RERANKER_HOST}:{RERANKER_PORT}/rerank' | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +from typing import Any, Dict | |
| 11 | + | |
| 12 | +from config.loader import get_app_config | |
| 13 | + | |
| 14 | + | |
| 15 | +def _app(): | |
| 16 | + return get_app_config() | |
| 17 | + | |
| 18 | + | |
| 19 | +def _runtime(): | |
| 20 | + return _app().runtime | |
| 88 | 21 | |
| 89 | -# Model IDs / paths | |
| 90 | -TEXT_MODEL_DIR = os.getenv('TEXT_MODEL_DIR', os.getenv('TEXT_MODEL_ID', 'Qwen/Qwen3-Embedding-0.6B')) | |
| 91 | -IMAGE_MODEL_DIR = os.getenv('IMAGE_MODEL_DIR', '/data/tw/models/cn-clip') | |
| 92 | 22 | |
| 93 | -# Cache Directory | |
| 94 | -CACHE_DIR = os.getenv('CACHE_DIR', '.cache') | |
| 23 | +def _infra(): | |
| 24 | + return _app().infrastructure | |
| 95 | 25 | |
| 96 | -# MySQL Database Configuration (Shoplazza) | |
| 97 | -DB_CONFIG = { | |
| 98 | - 'host': os.getenv('DB_HOST'), | |
| 99 | - 'port': int(os.getenv('DB_PORT', 3306)) if os.getenv('DB_PORT') else 3306, | |
| 100 | - 'database': os.getenv('DB_DATABASE'), | |
| 101 | - 'username': os.getenv('DB_USERNAME'), | |
| 102 | - 'password': os.getenv('DB_PASSWORD'), | |
| 103 | -} | |
| 104 | 26 | |
| 27 | +def _elasticsearch_dict() -> Dict[str, Any]: | |
| 28 | + cfg = _infra().elasticsearch | |
| 29 | + return { | |
| 30 | + "host": cfg.host, | |
| 31 | + "username": cfg.username, | |
| 32 | + "password": cfg.password, | |
| 33 | + } | |
| 105 | 34 | |
| 106 | -def print_config(): | |
| 107 | - """Print current configuration (with sensitive data masked).""" | |
| 108 | - print("=" * 60) | |
| 109 | - print("saas-search Configuration") | |
| 110 | - print("=" * 60) | |
| 111 | 35 | |
| 112 | - print("\nElasticsearch:") | |
| 113 | - print(f" Host: {ES_CONFIG['host']}") | |
| 114 | - print(f" Username: {ES_CONFIG['username']}") | |
| 115 | - print(f" Password: {'*' * 10 if ES_CONFIG['password'] else 'None'}") | |
| 36 | +def _redis_dict() -> Dict[str, Any]: | |
| 37 | + cfg = _infra().redis | |
| 38 | + return { | |
| 39 | + "host": cfg.host, | |
| 40 | + "port": cfg.port, | |
| 41 | + "snapshot_db": cfg.snapshot_db, | |
| 42 | + "password": cfg.password, | |
| 43 | + "socket_timeout": cfg.socket_timeout, | |
| 44 | + "socket_connect_timeout": cfg.socket_connect_timeout, | |
| 45 | + "retry_on_timeout": cfg.retry_on_timeout, | |
| 46 | + "cache_expire_days": cfg.cache_expire_days, | |
| 47 | + "embedding_cache_prefix": cfg.embedding_cache_prefix, | |
| 48 | + "anchor_cache_prefix": cfg.anchor_cache_prefix, | |
| 49 | + "anchor_cache_expire_days": cfg.anchor_cache_expire_days, | |
| 50 | + } | |
| 116 | 51 | |
| 117 | - print("\nRedis:") | |
| 118 | - print(f" Host: {REDIS_CONFIG['host']}") | |
| 119 | - print(f" Port: {REDIS_CONFIG['port']}") | |
| 120 | - print(f" Password: {'*' * 10 if REDIS_CONFIG['password'] else 'None'}") | |
| 121 | 52 | |
| 122 | - print("\nDeepL:") | |
| 123 | - print(f" API Key: {'*' * 10 if DEEPL_AUTH_KEY else 'None (translation disabled)'}") | |
| 53 | +def _db_dict() -> Dict[str, Any]: | |
| 54 | + cfg = _infra().database | |
| 55 | + return { | |
| 56 | + "host": cfg.host, | |
| 57 | + "port": cfg.port, | |
| 58 | + "database": cfg.database, | |
| 59 | + "username": cfg.username, | |
| 60 | + "password": cfg.password, | |
| 61 | + } | |
| 62 | + | |
| 63 | + | |
| 64 | +ES_CONFIG = _elasticsearch_dict() | |
| 65 | +REDIS_CONFIG = _redis_dict() | |
| 66 | +DB_CONFIG = _db_dict() | |
| 67 | + | |
| 68 | +RUNTIME_ENV = _runtime().environment | |
| 69 | +ES_INDEX_NAMESPACE = _runtime().index_namespace | |
| 70 | + | |
| 71 | +DEEPL_AUTH_KEY = _infra().secrets.deepl_auth_key | |
| 72 | +DASHSCOPE_API_KEY = _infra().secrets.dashscope_api_key | |
| 73 | + | |
| 74 | +API_HOST = _runtime().api_host | |
| 75 | +API_PORT = _runtime().api_port | |
| 76 | +INDEXER_HOST = _runtime().indexer_host | |
| 77 | +INDEXER_PORT = _runtime().indexer_port | |
| 78 | +EMBEDDING_HOST = _runtime().embedding_host | |
| 79 | +EMBEDDING_PORT = _runtime().embedding_port | |
| 80 | +EMBEDDING_TEXT_HOST = _runtime().embedding_host | |
| 81 | +EMBEDDING_TEXT_PORT = _runtime().embedding_text_port | |
| 82 | +EMBEDDING_IMAGE_HOST = _runtime().embedding_host | |
| 83 | +EMBEDDING_IMAGE_PORT = _runtime().embedding_image_port | |
| 84 | +TRANSLATION_HOST = _runtime().translator_host | |
| 85 | +TRANSLATION_PORT = _runtime().translator_port | |
| 86 | +RERANKER_HOST = _runtime().reranker_host | |
| 87 | +RERANKER_PORT = _runtime().reranker_port | |
| 88 | + | |
| 89 | +API_BASE_URL = f"http://localhost:{API_PORT}" if API_HOST == "0.0.0.0" else f"http://{API_HOST}:{API_PORT}" | |
| 90 | +INDEXER_BASE_URL = ( | |
| 91 | + f"http://localhost:{INDEXER_PORT}" if INDEXER_HOST == "0.0.0.0" else f"http://{INDEXER_HOST}:{INDEXER_PORT}" | |
| 92 | +) | |
| 93 | +EMBEDDING_TEXT_SERVICE_URL = _app().services.embedding.get_provider_config().get("text_base_url") | |
| 94 | +EMBEDDING_IMAGE_SERVICE_URL = _app().services.embedding.get_provider_config().get("image_base_url") | |
| 95 | +RERANKER_SERVICE_URL = ( | |
| 96 | + _app().services.rerank.get_provider_config().get("service_url") | |
| 97 | + or _app().services.rerank.get_provider_config().get("base_url") | |
| 98 | +) | |
| 99 | + | |
| 124 | 100 | |
| 125 | - print("\nAPI Service:") | |
| 126 | - print(f" Host: {API_HOST}") | |
| 127 | - print(f" Port: {API_PORT}") | |
| 101 | +def get_es_config() -> Dict[str, Any]: | |
| 102 | + return dict(ES_CONFIG) | |
| 128 | 103 | |
| 129 | - print("\nModels:") | |
| 130 | - print(f" Text Model: {TEXT_MODEL_DIR}") | |
| 131 | - print(f" Image Model: {IMAGE_MODEL_DIR}") | |
| 132 | 104 | |
| 133 | - print("\nCache:") | |
| 134 | - print(f" Cache Directory: {CACHE_DIR}") | |
| 105 | +def get_db_config() -> Dict[str, Any]: | |
| 106 | + return dict(DB_CONFIG) | |
| 135 | 107 | |
| 136 | - print("\nMySQL Database:") | |
| 137 | - print(f" Host: {DB_CONFIG['host']}") | |
| 138 | - print(f" Port: {DB_CONFIG['port']}") | |
| 139 | - print(f" Database: {DB_CONFIG['database']}") | |
| 140 | - print(f" Username: {DB_CONFIG['username']}") | |
| 141 | - print(f" Password: {'*' * 10 if DB_CONFIG['password'] else 'None'}") | |
| 142 | 108 | |
| 143 | - print("=" * 60) | |
| 109 | +def get_redis_config() -> Dict[str, Any]: | |
| 110 | + return dict(REDIS_CONFIG) | |
| 144 | 111 | |
| 145 | 112 | |
| 146 | -if __name__ == "__main__": | |
| 147 | - print_config() | |
| 113 | +def print_config() -> None: | |
| 114 | + config = _app().sanitized_dict() | |
| 115 | + print(config) | ... | ... |
| ... | ... | @@ -0,0 +1,592 @@ |
| 1 | +""" | |
| 2 | +Unified application configuration loader. | |
| 3 | + | |
| 4 | +This module is the single source of truth for loading, merging, normalizing, | |
| 5 | +and validating application configuration. | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import hashlib | |
| 11 | +import json | |
| 12 | +import os | |
| 13 | +from copy import deepcopy | |
| 14 | +from dataclasses import asdict | |
| 15 | +from functools import lru_cache | |
| 16 | +from pathlib import Path | |
| 17 | +from typing import Any, Dict, Iterable, List, Optional, Tuple | |
| 18 | + | |
| 19 | +import yaml | |
| 20 | + | |
| 21 | +try: | |
| 22 | + from dotenv import load_dotenv as _load_dotenv # type: ignore | |
| 23 | +except Exception: # pragma: no cover | |
| 24 | + _load_dotenv = None | |
| 25 | + | |
| 26 | +from config.schema import ( | |
| 27 | + AppConfig, | |
| 28 | + AssetsConfig, | |
| 29 | + ConfigMetadata, | |
| 30 | + DatabaseSettings, | |
| 31 | + ElasticsearchSettings, | |
| 32 | + EmbeddingServiceConfig, | |
| 33 | + FunctionScoreConfig, | |
| 34 | + IndexConfig, | |
| 35 | + InfrastructureConfig, | |
| 36 | + QueryConfig, | |
| 37 | + RedisSettings, | |
| 38 | + RerankConfig, | |
| 39 | + RerankServiceConfig, | |
| 40 | + RuntimeConfig, | |
| 41 | + SearchConfig, | |
| 42 | + SecretsConfig, | |
| 43 | + ServicesConfig, | |
| 44 | + SPUConfig, | |
| 45 | + TenantCatalogConfig, | |
| 46 | + TranslationServiceConfig, | |
| 47 | +) | |
| 48 | +from translation.settings import build_translation_config | |
| 49 | + | |
| 50 | + | |
| 51 | +class ConfigurationError(Exception): | |
| 52 | + """Raised when configuration validation fails.""" | |
| 53 | + | |
| 54 | + | |
| 55 | +def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: | |
| 56 | + result = deepcopy(base) | |
| 57 | + for key, value in (override or {}).items(): | |
| 58 | + if ( | |
| 59 | + key in result | |
| 60 | + and isinstance(result[key], dict) | |
| 61 | + and isinstance(value, dict) | |
| 62 | + ): | |
| 63 | + result[key] = _deep_merge(result[key], value) | |
| 64 | + else: | |
| 65 | + result[key] = deepcopy(value) | |
| 66 | + return result | |
| 67 | + | |
| 68 | + | |
| 69 | +def _load_yaml(path: Path) -> Dict[str, Any]: | |
| 70 | + with open(path, "r", encoding="utf-8") as handle: | |
| 71 | + data = yaml.safe_load(handle) or {} | |
| 72 | + if not isinstance(data, dict): | |
| 73 | + raise ConfigurationError(f"Configuration file root must be a mapping: {path}") | |
| 74 | + return data | |
| 75 | + | |
| 76 | + | |
| 77 | +def _read_rewrite_dictionary(path: Path) -> Dict[str, str]: | |
| 78 | + rewrite_dict: Dict[str, str] = {} | |
| 79 | + if not path.exists(): | |
| 80 | + return rewrite_dict | |
| 81 | + | |
| 82 | + with open(path, "r", encoding="utf-8") as handle: | |
| 83 | + for raw_line in handle: | |
| 84 | + line = raw_line.strip() | |
| 85 | + if not line or line.startswith("#"): | |
| 86 | + continue | |
| 87 | + parts = line.split("\t") | |
| 88 | + if len(parts) < 2: | |
| 89 | + continue | |
| 90 | + original = parts[0].strip() | |
| 91 | + replacement = parts[1].strip() | |
| 92 | + if original and replacement: | |
| 93 | + rewrite_dict[original] = replacement | |
| 94 | + return rewrite_dict | |
| 95 | + | |
| 96 | + | |
| 97 | +class AppConfigLoader: | |
| 98 | + """Load the unified application configuration.""" | |
| 99 | + | |
| 100 | + def __init__( | |
| 101 | + self, | |
| 102 | + *, | |
| 103 | + config_dir: Optional[Path] = None, | |
| 104 | + config_file: Optional[Path] = None, | |
| 105 | + env_file: Optional[Path] = None, | |
| 106 | + ) -> None: | |
| 107 | + self.config_dir = Path(config_dir or Path(__file__).parent) | |
| 108 | + self.config_file = Path(config_file) if config_file is not None else None | |
| 109 | + self.project_root = self.config_dir.parent | |
| 110 | + self.env_file = Path(env_file) if env_file is not None else self.project_root / ".env" | |
| 111 | + | |
| 112 | + def load(self, validate: bool = True) -> AppConfig: | |
| 113 | + self._load_env() | |
| 114 | + raw_config, loaded_files = self._load_raw_config() | |
| 115 | + app_config = self._build_app_config(raw_config, loaded_files) | |
| 116 | + if validate: | |
| 117 | + self._validate(app_config) | |
| 118 | + return app_config | |
| 119 | + | |
| 120 | + def _load_env(self) -> None: | |
| 121 | + if _load_dotenv is not None: | |
| 122 | + _load_dotenv(self.env_file, override=False) | |
| 123 | + return | |
| 124 | + _load_env_file_fallback(self.env_file) | |
| 125 | + | |
| 126 | + def _load_raw_config(self) -> Tuple[Dict[str, Any], List[str]]: | |
| 127 | + env_name = (os.getenv("APP_ENV") or os.getenv("RUNTIME_ENV") or "prod").strip().lower() or "prod" | |
| 128 | + loaded_files: List[str] = [] | |
| 129 | + raw: Dict[str, Any] = {} | |
| 130 | + | |
| 131 | + if self.config_file is not None: | |
| 132 | + config_path = self.config_file | |
| 133 | + if not config_path.exists(): | |
| 134 | + raise ConfigurationError(f"Configuration file not found: {config_path}") | |
| 135 | + raw = _deep_merge(raw, _load_yaml(config_path)) | |
| 136 | + loaded_files.append(str(config_path)) | |
| 137 | + else: | |
| 138 | + base_path = self.config_dir / "base.yaml" | |
| 139 | + legacy_path = self.config_dir / "config.yaml" | |
| 140 | + primary_path = base_path if base_path.exists() else legacy_path | |
| 141 | + if not primary_path.exists(): | |
| 142 | + raise ConfigurationError(f"Configuration file not found: {primary_path}") | |
| 143 | + raw = _deep_merge(raw, _load_yaml(primary_path)) | |
| 144 | + loaded_files.append(str(primary_path)) | |
| 145 | + | |
| 146 | + env_path = self.config_dir / "environments" / f"{env_name}.yaml" | |
| 147 | + if env_path.exists(): | |
| 148 | + raw = _deep_merge(raw, _load_yaml(env_path)) | |
| 149 | + loaded_files.append(str(env_path)) | |
| 150 | + | |
| 151 | + tenant_dir = self.config_dir / "tenants" | |
| 152 | + if tenant_dir.is_dir(): | |
| 153 | + tenant_files = sorted(tenant_dir.glob("*.yaml")) | |
| 154 | + if tenant_files: | |
| 155 | + tenant_config = {"default": {}, "tenants": {}} | |
| 156 | + default_path = tenant_dir / "_default.yaml" | |
| 157 | + if default_path.exists(): | |
| 158 | + tenant_config["default"] = _load_yaml(default_path) | |
| 159 | + loaded_files.append(str(default_path)) | |
| 160 | + for tenant_path in tenant_files: | |
| 161 | + if tenant_path.name == "_default.yaml": | |
| 162 | + continue | |
| 163 | + tenant_config["tenants"][tenant_path.stem] = _load_yaml(tenant_path) | |
| 164 | + loaded_files.append(str(tenant_path)) | |
| 165 | + raw["tenant_config"] = tenant_config | |
| 166 | + | |
| 167 | + return raw, loaded_files | |
| 168 | + | |
| 169 | + def _build_app_config(self, raw: Dict[str, Any], loaded_files: List[str]) -> AppConfig: | |
| 170 | + assets_cfg = raw.get("assets") if isinstance(raw.get("assets"), dict) else {} | |
| 171 | + rewrite_path = ( | |
| 172 | + assets_cfg.get("query_rewrite_dictionary_path") | |
| 173 | + or assets_cfg.get("rewrite_dictionary_path") | |
| 174 | + or self.config_dir / "dictionaries" / "query_rewrite.dict" | |
| 175 | + ) | |
| 176 | + rewrite_path = Path(rewrite_path) | |
| 177 | + if not rewrite_path.is_absolute(): | |
| 178 | + rewrite_path = (self.project_root / rewrite_path).resolve() | |
| 179 | + if not rewrite_path.exists(): | |
| 180 | + legacy_rewrite_path = (self.config_dir / "query_rewrite.dict").resolve() | |
| 181 | + if legacy_rewrite_path.exists(): | |
| 182 | + rewrite_path = legacy_rewrite_path | |
| 183 | + | |
| 184 | + rewrite_dictionary = _read_rewrite_dictionary(rewrite_path) | |
| 185 | + search_config = self._build_search_config(raw, rewrite_dictionary) | |
| 186 | + services_config = self._build_services_config(raw.get("services") or {}) | |
| 187 | + tenants_config = self._build_tenants_config(raw.get("tenant_config") or {}) | |
| 188 | + runtime_config = self._build_runtime_config() | |
| 189 | + infrastructure_config = self._build_infrastructure_config(runtime_config.environment) | |
| 190 | + | |
| 191 | + metadata = ConfigMetadata( | |
| 192 | + loaded_files=tuple(loaded_files), | |
| 193 | + config_hash="", | |
| 194 | + deprecated_keys=tuple(self._detect_deprecated_keys(raw)), | |
| 195 | + ) | |
| 196 | + | |
| 197 | + app_config = AppConfig( | |
| 198 | + runtime=runtime_config, | |
| 199 | + infrastructure=infrastructure_config, | |
| 200 | + search=search_config, | |
| 201 | + services=services_config, | |
| 202 | + tenants=tenants_config, | |
| 203 | + assets=AssetsConfig(query_rewrite_dictionary_path=rewrite_path), | |
| 204 | + metadata=metadata, | |
| 205 | + ) | |
| 206 | + | |
| 207 | + config_hash = self._compute_hash(app_config) | |
| 208 | + return AppConfig( | |
| 209 | + runtime=app_config.runtime, | |
| 210 | + infrastructure=app_config.infrastructure, | |
| 211 | + search=app_config.search, | |
| 212 | + services=app_config.services, | |
| 213 | + tenants=app_config.tenants, | |
| 214 | + assets=app_config.assets, | |
| 215 | + metadata=ConfigMetadata( | |
| 216 | + loaded_files=app_config.metadata.loaded_files, | |
| 217 | + config_hash=config_hash, | |
| 218 | + deprecated_keys=app_config.metadata.deprecated_keys, | |
| 219 | + ), | |
| 220 | + ) | |
| 221 | + | |
| 222 | + def _build_search_config(self, raw: Dict[str, Any], rewrite_dictionary: Dict[str, str]) -> SearchConfig: | |
| 223 | + field_boosts = raw.get("field_boosts") or {} | |
| 224 | + if not isinstance(field_boosts, dict): | |
| 225 | + raise ConfigurationError("field_boosts must be a mapping") | |
| 226 | + | |
| 227 | + indexes: List[IndexConfig] = [] | |
| 228 | + for item in raw.get("indexes") or []: | |
| 229 | + if not isinstance(item, dict): | |
| 230 | + raise ConfigurationError("indexes items must be mappings") | |
| 231 | + indexes.append( | |
| 232 | + IndexConfig( | |
| 233 | + name=str(item["name"]), | |
| 234 | + label=str(item.get("label") or item["name"]), | |
| 235 | + fields=list(item.get("fields") or []), | |
| 236 | + boost=float(item.get("boost", 1.0)), | |
| 237 | + example=item.get("example"), | |
| 238 | + ) | |
| 239 | + ) | |
| 240 | + | |
| 241 | + query_cfg = raw.get("query_config") if isinstance(raw.get("query_config"), dict) else {} | |
| 242 | + search_fields = query_cfg.get("search_fields") if isinstance(query_cfg.get("search_fields"), dict) else {} | |
| 243 | + text_strategy = ( | |
| 244 | + query_cfg.get("text_query_strategy") | |
| 245 | + if isinstance(query_cfg.get("text_query_strategy"), dict) | |
| 246 | + else {} | |
| 247 | + ) | |
| 248 | + query_config = QueryConfig( | |
| 249 | + supported_languages=list(query_cfg.get("supported_languages") or ["zh", "en"]), | |
| 250 | + default_language=str(query_cfg.get("default_language") or "en"), | |
| 251 | + enable_text_embedding=bool(query_cfg.get("enable_text_embedding", True)), | |
| 252 | + enable_query_rewrite=bool(query_cfg.get("enable_query_rewrite", True)), | |
| 253 | + rewrite_dictionary=rewrite_dictionary, | |
| 254 | + text_embedding_field=query_cfg.get("text_embedding_field"), | |
| 255 | + image_embedding_field=query_cfg.get("image_embedding_field"), | |
| 256 | + source_fields=query_cfg.get("source_fields"), | |
| 257 | + knn_boost=float(query_cfg.get("knn_boost", 0.25)), | |
| 258 | + multilingual_fields=list( | |
| 259 | + search_fields.get( | |
| 260 | + "multilingual_fields", | |
| 261 | + ["title", "brief", "description", "vendor", "category_path", "category_name_text"], | |
| 262 | + ) | |
| 263 | + ), | |
| 264 | + shared_fields=list( | |
| 265 | + search_fields.get( | |
| 266 | + "shared_fields", | |
| 267 | + ["tags", "option1_values", "option2_values", "option3_values"], | |
| 268 | + ) | |
| 269 | + ), | |
| 270 | + core_multilingual_fields=list( | |
| 271 | + search_fields.get( | |
| 272 | + "core_multilingual_fields", | |
| 273 | + ["title", "brief", "vendor", "category_name_text"], | |
| 274 | + ) | |
| 275 | + ), | |
| 276 | + base_minimum_should_match=str(text_strategy.get("base_minimum_should_match", "75%")), | |
| 277 | + translation_minimum_should_match=str(text_strategy.get("translation_minimum_should_match", "75%")), | |
| 278 | + translation_boost=float(text_strategy.get("translation_boost", 0.4)), | |
| 279 | + translation_boost_when_source_missing=float( | |
| 280 | + text_strategy.get("translation_boost_when_source_missing", 1.0) | |
| 281 | + ), | |
| 282 | + source_boost_when_missing=float(text_strategy.get("source_boost_when_missing", 0.6)), | |
| 283 | + original_query_fallback_boost_when_translation_missing=float( | |
| 284 | + text_strategy.get("original_query_fallback_boost_when_translation_missing", 0.2) | |
| 285 | + ), | |
| 286 | + tie_breaker_base_query=float(text_strategy.get("tie_breaker_base_query", 0.9)), | |
| 287 | + zh_to_en_model=str(query_cfg.get("zh_to_en_model") or "opus-mt-zh-en"), | |
| 288 | + en_to_zh_model=str(query_cfg.get("en_to_zh_model") or "opus-mt-en-zh"), | |
| 289 | + default_translation_model=str( | |
| 290 | + query_cfg.get("default_translation_model") or "nllb-200-distilled-600m" | |
| 291 | + ), | |
| 292 | + ) | |
| 293 | + | |
| 294 | + function_score_cfg = raw.get("function_score") if isinstance(raw.get("function_score"), dict) else {} | |
| 295 | + rerank_cfg = raw.get("rerank") if isinstance(raw.get("rerank"), dict) else {} | |
| 296 | + spu_cfg = raw.get("spu_config") if isinstance(raw.get("spu_config"), dict) else {} | |
| 297 | + | |
| 298 | + return SearchConfig( | |
| 299 | + field_boosts={str(key): float(value) for key, value in field_boosts.items()}, | |
| 300 | + indexes=indexes, | |
| 301 | + query_config=query_config, | |
| 302 | + function_score=FunctionScoreConfig( | |
| 303 | + score_mode=str(function_score_cfg.get("score_mode") or "sum"), | |
| 304 | + boost_mode=str(function_score_cfg.get("boost_mode") or "multiply"), | |
| 305 | + functions=list(function_score_cfg.get("functions") or []), | |
| 306 | + ), | |
| 307 | + rerank=RerankConfig( | |
| 308 | + enabled=bool(rerank_cfg.get("enabled", True)), | |
| 309 | + rerank_window=int(rerank_cfg.get("rerank_window", 384)), | |
| 310 | + timeout_sec=float(rerank_cfg.get("timeout_sec", 15.0)), | |
| 311 | + weight_es=float(rerank_cfg.get("weight_es", 0.4)), | |
| 312 | + weight_ai=float(rerank_cfg.get("weight_ai", 0.6)), | |
| 313 | + rerank_query_template=str(rerank_cfg.get("rerank_query_template") or "{query}"), | |
| 314 | + rerank_doc_template=str(rerank_cfg.get("rerank_doc_template") or "{title}"), | |
| 315 | + ), | |
| 316 | + spu_config=SPUConfig( | |
| 317 | + enabled=bool(spu_cfg.get("enabled", False)), | |
| 318 | + spu_field=spu_cfg.get("spu_field"), | |
| 319 | + inner_hits_size=int(spu_cfg.get("inner_hits_size", 3)), | |
| 320 | + searchable_option_dimensions=list( | |
| 321 | + spu_cfg.get("searchable_option_dimensions") or ["option1", "option2", "option3"] | |
| 322 | + ), | |
| 323 | + ), | |
| 324 | + es_index_name=str(raw.get("es_index_name") or "search_products"), | |
| 325 | + es_settings=dict(raw.get("es_settings") or {}), | |
| 326 | + ) | |
| 327 | + | |
| 328 | + def _build_services_config(self, raw: Dict[str, Any]) -> ServicesConfig: | |
| 329 | + if not isinstance(raw, dict): | |
| 330 | + raise ConfigurationError("services must be a mapping") | |
| 331 | + | |
| 332 | + translation_raw = raw.get("translation") if isinstance(raw.get("translation"), dict) else {} | |
| 333 | + normalized_translation = build_translation_config(translation_raw) | |
| 334 | + translation_config = TranslationServiceConfig( | |
| 335 | + endpoint=str(normalized_translation["service_url"]).rstrip("/"), | |
| 336 | + timeout_sec=float(normalized_translation["timeout_sec"]), | |
| 337 | + default_model=str(normalized_translation["default_model"]), | |
| 338 | + default_scene=str(normalized_translation["default_scene"]), | |
| 339 | + cache=dict(normalized_translation["cache"]), | |
| 340 | + capabilities={str(key): dict(value) for key, value in normalized_translation["capabilities"].items()}, | |
| 341 | + ) | |
| 342 | + | |
| 343 | + embedding_raw = raw.get("embedding") if isinstance(raw.get("embedding"), dict) else {} | |
| 344 | + embedding_provider = str(embedding_raw.get("provider") or "http").strip().lower() | |
| 345 | + embedding_providers = dict(embedding_raw.get("providers") or {}) | |
| 346 | + if embedding_provider not in embedding_providers: | |
| 347 | + raise ConfigurationError(f"services.embedding.providers.{embedding_provider} must be configured") | |
| 348 | + embedding_backend = str(embedding_raw.get("backend") or "").strip().lower() | |
| 349 | + embedding_backends = { | |
| 350 | + str(key).strip().lower(): dict(value) | |
| 351 | + for key, value in dict(embedding_raw.get("backends") or {}).items() | |
| 352 | + } | |
| 353 | + if embedding_backend not in embedding_backends: | |
| 354 | + raise ConfigurationError(f"services.embedding.backends.{embedding_backend} must be configured") | |
| 355 | + image_backend = str(embedding_raw.get("image_backend") or "clip_as_service").strip().lower() | |
| 356 | + image_backends = { | |
| 357 | + str(key).strip().lower(): dict(value) | |
| 358 | + for key, value in dict(embedding_raw.get("image_backends") or {}).items() | |
| 359 | + } | |
| 360 | + if not image_backends: | |
| 361 | + image_backends = { | |
| 362 | + "clip_as_service": { | |
| 363 | + "server": "grpc://127.0.0.1:51000", | |
| 364 | + "model_name": "CN-CLIP/ViT-L-14", | |
| 365 | + "batch_size": 8, | |
| 366 | + "normalize_embeddings": True, | |
| 367 | + }, | |
| 368 | + "local_cnclip": { | |
| 369 | + "model_name": "ViT-L-14", | |
| 370 | + "device": None, | |
| 371 | + "batch_size": 8, | |
| 372 | + "normalize_embeddings": True, | |
| 373 | + }, | |
| 374 | + } | |
| 375 | + if image_backend not in image_backends: | |
| 376 | + raise ConfigurationError(f"services.embedding.image_backends.{image_backend} must be configured") | |
| 377 | + | |
| 378 | + embedding_config = EmbeddingServiceConfig( | |
| 379 | + provider=embedding_provider, | |
| 380 | + providers=embedding_providers, | |
| 381 | + backend=embedding_backend, | |
| 382 | + backends=embedding_backends, | |
| 383 | + image_backend=image_backend, | |
| 384 | + image_backends=image_backends, | |
| 385 | + ) | |
| 386 | + | |
| 387 | + rerank_raw = raw.get("rerank") if isinstance(raw.get("rerank"), dict) else {} | |
| 388 | + rerank_provider = str(rerank_raw.get("provider") or "http").strip().lower() | |
| 389 | + rerank_providers = dict(rerank_raw.get("providers") or {}) | |
| 390 | + if rerank_provider not in rerank_providers: | |
| 391 | + raise ConfigurationError(f"services.rerank.providers.{rerank_provider} must be configured") | |
| 392 | + rerank_backend = str(rerank_raw.get("backend") or "").strip().lower() | |
| 393 | + rerank_backends = { | |
| 394 | + str(key).strip().lower(): dict(value) | |
| 395 | + for key, value in dict(rerank_raw.get("backends") or {}).items() | |
| 396 | + } | |
| 397 | + if rerank_backend not in rerank_backends: | |
| 398 | + raise ConfigurationError(f"services.rerank.backends.{rerank_backend} must be configured") | |
| 399 | + rerank_request = dict(rerank_raw.get("request") or {}) | |
| 400 | + rerank_request.setdefault("max_docs", 1000) | |
| 401 | + rerank_request.setdefault("normalize", True) | |
| 402 | + | |
| 403 | + rerank_config = RerankServiceConfig( | |
| 404 | + provider=rerank_provider, | |
| 405 | + providers=rerank_providers, | |
| 406 | + backend=rerank_backend, | |
| 407 | + backends=rerank_backends, | |
| 408 | + request=rerank_request, | |
| 409 | + ) | |
| 410 | + | |
| 411 | + return ServicesConfig( | |
| 412 | + translation=translation_config, | |
| 413 | + embedding=embedding_config, | |
| 414 | + rerank=rerank_config, | |
| 415 | + ) | |
| 416 | + | |
| 417 | + def _build_tenants_config(self, raw: Dict[str, Any]) -> TenantCatalogConfig: | |
| 418 | + if not isinstance(raw, dict): | |
| 419 | + raise ConfigurationError("tenant_config must be a mapping") | |
| 420 | + default_cfg = raw.get("default") if isinstance(raw.get("default"), dict) else {} | |
| 421 | + tenants_cfg = raw.get("tenants") if isinstance(raw.get("tenants"), dict) else {} | |
| 422 | + return TenantCatalogConfig( | |
| 423 | + default=dict(default_cfg), | |
| 424 | + tenants={str(key): dict(value) for key, value in tenants_cfg.items()}, | |
| 425 | + ) | |
| 426 | + | |
| 427 | + def _build_runtime_config(self) -> RuntimeConfig: | |
| 428 | + environment = (os.getenv("APP_ENV") or os.getenv("RUNTIME_ENV") or "prod").strip().lower() or "prod" | |
| 429 | + namespace = os.getenv("ES_INDEX_NAMESPACE") | |
| 430 | + if namespace is None: | |
| 431 | + namespace = "" if environment == "prod" else f"{environment}_" | |
| 432 | + | |
| 433 | + return RuntimeConfig( | |
| 434 | + environment=environment, | |
| 435 | + index_namespace=namespace, | |
| 436 | + api_host=os.getenv("API_HOST", "0.0.0.0"), | |
| 437 | + api_port=int(os.getenv("API_PORT", 6002)), | |
| 438 | + indexer_host=os.getenv("INDEXER_HOST", "0.0.0.0"), | |
| 439 | + indexer_port=int(os.getenv("INDEXER_PORT", 6004)), | |
| 440 | + embedding_host=os.getenv("EMBEDDING_HOST", "127.0.0.1"), | |
| 441 | + embedding_port=int(os.getenv("EMBEDDING_PORT", 6005)), | |
| 442 | + embedding_text_port=int(os.getenv("EMBEDDING_TEXT_PORT", 6005)), | |
| 443 | + embedding_image_port=int(os.getenv("EMBEDDING_IMAGE_PORT", 6008)), | |
| 444 | + translator_host=os.getenv("TRANSLATION_HOST", "127.0.0.1"), | |
| 445 | + translator_port=int(os.getenv("TRANSLATION_PORT", 6006)), | |
| 446 | + reranker_host=os.getenv("RERANKER_HOST", "127.0.0.1"), | |
| 447 | + reranker_port=int(os.getenv("RERANKER_PORT", 6007)), | |
| 448 | + ) | |
| 449 | + | |
| 450 | + def _build_infrastructure_config(self, environment: str) -> InfrastructureConfig: | |
| 451 | + del environment | |
| 452 | + return InfrastructureConfig( | |
| 453 | + elasticsearch=ElasticsearchSettings( | |
| 454 | + host=os.getenv("ES_HOST", "http://localhost:9200"), | |
| 455 | + username=os.getenv("ES_USERNAME"), | |
| 456 | + password=os.getenv("ES_PASSWORD"), | |
| 457 | + ), | |
| 458 | + redis=RedisSettings( | |
| 459 | + host=os.getenv("REDIS_HOST", "localhost"), | |
| 460 | + port=int(os.getenv("REDIS_PORT", 6479)), | |
| 461 | + snapshot_db=int(os.getenv("REDIS_SNAPSHOT_DB", 0)), | |
| 462 | + password=os.getenv("REDIS_PASSWORD"), | |
| 463 | + socket_timeout=int(os.getenv("REDIS_SOCKET_TIMEOUT", 1)), | |
| 464 | + socket_connect_timeout=int(os.getenv("REDIS_SOCKET_CONNECT_TIMEOUT", 1)), | |
| 465 | + retry_on_timeout=os.getenv("REDIS_RETRY_ON_TIMEOUT", "false").strip().lower() == "true", | |
| 466 | + cache_expire_days=int(os.getenv("REDIS_CACHE_EXPIRE_DAYS", 360 * 2)), | |
| 467 | + embedding_cache_prefix=os.getenv("REDIS_EMBEDDING_CACHE_PREFIX", "embedding"), | |
| 468 | + anchor_cache_prefix=os.getenv("REDIS_ANCHOR_CACHE_PREFIX", "product_anchors"), | |
| 469 | + anchor_cache_expire_days=int(os.getenv("REDIS_ANCHOR_CACHE_EXPIRE_DAYS", 30)), | |
| 470 | + ), | |
| 471 | + database=DatabaseSettings( | |
| 472 | + host=os.getenv("DB_HOST"), | |
| 473 | + port=int(os.getenv("DB_PORT", 3306)) if os.getenv("DB_PORT") else 3306, | |
| 474 | + database=os.getenv("DB_DATABASE"), | |
| 475 | + username=os.getenv("DB_USERNAME"), | |
| 476 | + password=os.getenv("DB_PASSWORD"), | |
| 477 | + ), | |
| 478 | + secrets=SecretsConfig( | |
| 479 | + dashscope_api_key=os.getenv("DASHSCOPE_API_KEY"), | |
| 480 | + deepl_auth_key=os.getenv("DEEPL_AUTH_KEY"), | |
| 481 | + ), | |
| 482 | + ) | |
| 483 | + | |
| 484 | + def _validate(self, app_config: AppConfig) -> None: | |
| 485 | + errors: List[str] = [] | |
| 486 | + | |
| 487 | + if not app_config.search.es_index_name: | |
| 488 | + errors.append("search.es_index_name is required") | |
| 489 | + | |
| 490 | + if not app_config.search.field_boosts: | |
| 491 | + errors.append("search.field_boosts cannot be empty") | |
| 492 | + else: | |
| 493 | + for field_name, boost in app_config.search.field_boosts.items(): | |
| 494 | + if boost < 0: | |
| 495 | + errors.append(f"field_boosts.{field_name} must be non-negative") | |
| 496 | + | |
| 497 | + query_config = app_config.search.query_config | |
| 498 | + if not query_config.supported_languages: | |
| 499 | + errors.append("query_config.supported_languages must not be empty") | |
| 500 | + if query_config.default_language not in query_config.supported_languages: | |
| 501 | + errors.append("query_config.default_language must be included in supported_languages") | |
| 502 | + for name, values in ( | |
| 503 | + ("multilingual_fields", query_config.multilingual_fields), | |
| 504 | + ("shared_fields", query_config.shared_fields), | |
| 505 | + ("core_multilingual_fields", query_config.core_multilingual_fields), | |
| 506 | + ): | |
| 507 | + if not values: | |
| 508 | + errors.append(f"query_config.{name} must not be empty") | |
| 509 | + | |
| 510 | + if not set(query_config.core_multilingual_fields).issubset(set(query_config.multilingual_fields)): | |
| 511 | + errors.append("query_config.core_multilingual_fields must be a subset of multilingual_fields") | |
| 512 | + | |
| 513 | + if app_config.search.spu_config.enabled and not app_config.search.spu_config.spu_field: | |
| 514 | + errors.append("spu_config.spu_field is required when spu_config.enabled is true") | |
| 515 | + | |
| 516 | + if not app_config.tenants.default or not app_config.tenants.default.get("index_languages"): | |
| 517 | + errors.append("tenant_config.default.index_languages must be configured") | |
| 518 | + | |
| 519 | + if app_config.metadata.deprecated_keys: | |
| 520 | + errors.append( | |
| 521 | + "Deprecated tenant config keys are not supported: " | |
| 522 | + + ", ".join(app_config.metadata.deprecated_keys) | |
| 523 | + ) | |
| 524 | + | |
| 525 | + embedding_provider_cfg = app_config.services.embedding.get_provider_config() | |
| 526 | + if not embedding_provider_cfg.get("text_base_url"): | |
| 527 | + errors.append("services.embedding.providers.<provider>.text_base_url is required") | |
| 528 | + if not embedding_provider_cfg.get("image_base_url"): | |
| 529 | + errors.append("services.embedding.providers.<provider>.image_base_url is required") | |
| 530 | + | |
| 531 | + rerank_provider_cfg = app_config.services.rerank.get_provider_config() | |
| 532 | + if not rerank_provider_cfg.get("service_url") and not rerank_provider_cfg.get("base_url"): | |
| 533 | + errors.append("services.rerank.providers.<provider>.service_url or base_url is required") | |
| 534 | + | |
| 535 | + if errors: | |
| 536 | + raise ConfigurationError("Configuration validation failed:\n" + "\n".join(f" - {err}" for err in errors)) | |
| 537 | + | |
| 538 | + def _compute_hash(self, app_config: AppConfig) -> str: | |
| 539 | + payload = asdict(app_config) | |
| 540 | + payload["metadata"]["config_hash"] = "" | |
| 541 | + payload["infrastructure"]["elasticsearch"]["password"] = "***" if payload["infrastructure"]["elasticsearch"].get("password") else None | |
| 542 | + payload["infrastructure"]["database"]["password"] = "***" if payload["infrastructure"]["database"].get("password") else None | |
| 543 | + payload["infrastructure"]["redis"]["password"] = "***" if payload["infrastructure"]["redis"].get("password") else None | |
| 544 | + payload["infrastructure"]["secrets"]["dashscope_api_key"] = "***" if payload["infrastructure"]["secrets"].get("dashscope_api_key") else None | |
| 545 | + payload["infrastructure"]["secrets"]["deepl_auth_key"] = "***" if payload["infrastructure"]["secrets"].get("deepl_auth_key") else None | |
| 546 | + blob = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) | |
| 547 | + return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16] | |
| 548 | + | |
| 549 | + def _detect_deprecated_keys(self, raw: Dict[str, Any]) -> Iterable[str]: | |
| 550 | + tenant_raw = raw.get("tenant_config") if isinstance(raw.get("tenant_config"), dict) else {} | |
| 551 | + for key in ("default",): | |
| 552 | + item = tenant_raw.get(key) | |
| 553 | + if isinstance(item, dict): | |
| 554 | + for deprecated in ("translate_to_en", "translate_to_zh"): | |
| 555 | + if deprecated in item: | |
| 556 | + yield f"tenant_config.{key}.{deprecated}" | |
| 557 | + tenants = tenant_raw.get("tenants") if isinstance(tenant_raw.get("tenants"), dict) else {} | |
| 558 | + for tenant_id, cfg in tenants.items(): | |
| 559 | + if not isinstance(cfg, dict): | |
| 560 | + continue | |
| 561 | + for deprecated in ("translate_to_en", "translate_to_zh"): | |
| 562 | + if deprecated in cfg: | |
| 563 | + yield f"tenant_config.tenants.{tenant_id}.{deprecated}" | |
| 564 | + | |
| 565 | + | |
| 566 | +@lru_cache(maxsize=1) | |
| 567 | +def get_app_config() -> AppConfig: | |
| 568 | + """Return the process-global application configuration.""" | |
| 569 | + | |
| 570 | + return AppConfigLoader().load() | |
| 571 | + | |
| 572 | + | |
| 573 | +def reload_app_config() -> AppConfig: | |
| 574 | + """Clear the cached configuration and reload it.""" | |
| 575 | + | |
| 576 | + get_app_config.cache_clear() | |
| 577 | + return get_app_config() | |
| 578 | + | |
| 579 | + | |
| 580 | +def _load_env_file_fallback(path: Path) -> None: | |
| 581 | + if not path.exists(): | |
| 582 | + return | |
| 583 | + with open(path, "r", encoding="utf-8") as handle: | |
| 584 | + for raw_line in handle: | |
| 585 | + line = raw_line.strip() | |
| 586 | + if not line or line.startswith("#") or "=" not in line: | |
| 587 | + continue | |
| 588 | + key, value = line.split("=", 1) | |
| 589 | + key = key.strip() | |
| 590 | + value = value.strip().strip('"').strip("'") | |
| 591 | + if key and key not in os.environ: | |
| 592 | + os.environ[key] = value | ... | ... |
config/query_rewrite.dict deleted
| ... | ... | @@ -0,0 +1,307 @@ |
| 1 | +""" | |
| 2 | +Typed configuration schema for the unified application configuration. | |
| 3 | + | |
| 4 | +This module defines the normalized in-memory structure used by all services. | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from __future__ import annotations | |
| 8 | + | |
| 9 | +from dataclasses import asdict, dataclass, field | |
| 10 | +from pathlib import Path | |
| 11 | +from typing import Any, Dict, List, Optional, Tuple | |
| 12 | + | |
| 13 | + | |
| 14 | +@dataclass(frozen=True) | |
| 15 | +class IndexConfig: | |
| 16 | + """Deprecated compatibility shape for legacy diagnostics/tests.""" | |
| 17 | + | |
| 18 | + name: str | |
| 19 | + label: str | |
| 20 | + fields: List[str] | |
| 21 | + boost: float = 1.0 | |
| 22 | + example: Optional[str] = None | |
| 23 | + | |
| 24 | + | |
| 25 | +@dataclass(frozen=True) | |
| 26 | +class QueryConfig: | |
| 27 | + """Configuration for query processing.""" | |
| 28 | + | |
| 29 | + supported_languages: List[str] = field(default_factory=lambda: ["zh", "en"]) | |
| 30 | + default_language: str = "en" | |
| 31 | + enable_text_embedding: bool = True | |
| 32 | + enable_query_rewrite: bool = True | |
| 33 | + rewrite_dictionary: Dict[str, str] = field(default_factory=dict) | |
| 34 | + text_embedding_field: Optional[str] = "title_embedding" | |
| 35 | + image_embedding_field: Optional[str] = None | |
| 36 | + source_fields: Optional[List[str]] = None | |
| 37 | + knn_boost: float = 0.25 | |
| 38 | + multilingual_fields: List[str] = field( | |
| 39 | + default_factory=lambda: [ | |
| 40 | + "title", | |
| 41 | + "brief", | |
| 42 | + "description", | |
| 43 | + "vendor", | |
| 44 | + "category_path", | |
| 45 | + "category_name_text", | |
| 46 | + ] | |
| 47 | + ) | |
| 48 | + shared_fields: List[str] = field( | |
| 49 | + default_factory=lambda: ["tags", "option1_values", "option2_values", "option3_values"] | |
| 50 | + ) | |
| 51 | + core_multilingual_fields: List[str] = field( | |
| 52 | + default_factory=lambda: ["title", "brief", "vendor", "category_name_text"] | |
| 53 | + ) | |
| 54 | + base_minimum_should_match: str = "75%" | |
| 55 | + translation_minimum_should_match: str = "75%" | |
| 56 | + translation_boost: float = 0.4 | |
| 57 | + translation_boost_when_source_missing: float = 1.0 | |
| 58 | + source_boost_when_missing: float = 0.6 | |
| 59 | + original_query_fallback_boost_when_translation_missing: float = 0.2 | |
| 60 | + tie_breaker_base_query: float = 0.9 | |
| 61 | + zh_to_en_model: str = "opus-mt-zh-en" | |
| 62 | + en_to_zh_model: str = "opus-mt-en-zh" | |
| 63 | + default_translation_model: str = "nllb-200-distilled-600m" | |
| 64 | + | |
| 65 | + | |
| 66 | +@dataclass(frozen=True) | |
| 67 | +class SPUConfig: | |
| 68 | + """SPU aggregation/search configuration.""" | |
| 69 | + | |
| 70 | + enabled: bool = False | |
| 71 | + spu_field: Optional[str] = None | |
| 72 | + inner_hits_size: int = 3 | |
| 73 | + searchable_option_dimensions: List[str] = field( | |
| 74 | + default_factory=lambda: ["option1", "option2", "option3"] | |
| 75 | + ) | |
| 76 | + | |
| 77 | + | |
| 78 | +@dataclass(frozen=True) | |
| 79 | +class FunctionScoreConfig: | |
| 80 | + """Function score configuration.""" | |
| 81 | + | |
| 82 | + score_mode: str = "sum" | |
| 83 | + boost_mode: str = "multiply" | |
| 84 | + functions: List[Dict[str, Any]] = field(default_factory=list) | |
| 85 | + | |
| 86 | + | |
| 87 | +@dataclass(frozen=True) | |
| 88 | +class RerankConfig: | |
| 89 | + """Search-time rerank configuration.""" | |
| 90 | + | |
| 91 | + enabled: bool = True | |
| 92 | + rerank_window: int = 384 | |
| 93 | + timeout_sec: float = 15.0 | |
| 94 | + weight_es: float = 0.4 | |
| 95 | + weight_ai: float = 0.6 | |
| 96 | + rerank_query_template: str = "{query}" | |
| 97 | + rerank_doc_template: str = "{title}" | |
| 98 | + | |
| 99 | + | |
| 100 | +@dataclass(frozen=True) | |
| 101 | +class SearchConfig: | |
| 102 | + """Search behavior configuration shared by backend and indexer.""" | |
| 103 | + | |
| 104 | + field_boosts: Dict[str, float] | |
| 105 | + indexes: List[IndexConfig] = field(default_factory=list) | |
| 106 | + query_config: QueryConfig = field(default_factory=QueryConfig) | |
| 107 | + function_score: FunctionScoreConfig = field(default_factory=FunctionScoreConfig) | |
| 108 | + rerank: RerankConfig = field(default_factory=RerankConfig) | |
| 109 | + spu_config: SPUConfig = field(default_factory=SPUConfig) | |
| 110 | + es_index_name: str = "search_products" | |
| 111 | + es_settings: Dict[str, Any] = field(default_factory=dict) | |
| 112 | + | |
| 113 | + | |
| 114 | +@dataclass(frozen=True) | |
| 115 | +class TranslationServiceConfig: | |
| 116 | + """Translator service configuration.""" | |
| 117 | + | |
| 118 | + endpoint: str | |
| 119 | + timeout_sec: float | |
| 120 | + default_model: str | |
| 121 | + default_scene: str | |
| 122 | + cache: Dict[str, Any] | |
| 123 | + capabilities: Dict[str, Dict[str, Any]] | |
| 124 | + | |
| 125 | + def as_dict(self) -> Dict[str, Any]: | |
| 126 | + return { | |
| 127 | + "service_url": self.endpoint, | |
| 128 | + "timeout_sec": self.timeout_sec, | |
| 129 | + "default_model": self.default_model, | |
| 130 | + "default_scene": self.default_scene, | |
| 131 | + "cache": self.cache, | |
| 132 | + "capabilities": self.capabilities, | |
| 133 | + } | |
| 134 | + | |
| 135 | + | |
| 136 | +@dataclass(frozen=True) | |
| 137 | +class EmbeddingServiceConfig: | |
| 138 | + """Embedding service configuration.""" | |
| 139 | + | |
| 140 | + provider: str | |
| 141 | + providers: Dict[str, Any] | |
| 142 | + backend: str | |
| 143 | + backends: Dict[str, Dict[str, Any]] | |
| 144 | + image_backend: str | |
| 145 | + image_backends: Dict[str, Dict[str, Any]] | |
| 146 | + | |
| 147 | + def get_provider_config(self) -> Dict[str, Any]: | |
| 148 | + return dict(self.providers.get(self.provider, {}) or {}) | |
| 149 | + | |
| 150 | + def get_backend_config(self) -> Dict[str, Any]: | |
| 151 | + return dict(self.backends.get(self.backend, {}) or {}) | |
| 152 | + | |
| 153 | + def get_image_backend_config(self) -> Dict[str, Any]: | |
| 154 | + return dict(self.image_backends.get(self.image_backend, {}) or {}) | |
| 155 | + | |
| 156 | + | |
| 157 | +@dataclass(frozen=True) | |
| 158 | +class RerankServiceConfig: | |
| 159 | + """Reranker service configuration.""" | |
| 160 | + | |
| 161 | + provider: str | |
| 162 | + providers: Dict[str, Any] | |
| 163 | + backend: str | |
| 164 | + backends: Dict[str, Dict[str, Any]] | |
| 165 | + request: Dict[str, Any] | |
| 166 | + | |
| 167 | + def get_provider_config(self) -> Dict[str, Any]: | |
| 168 | + return dict(self.providers.get(self.provider, {}) or {}) | |
| 169 | + | |
| 170 | + def get_backend_config(self) -> Dict[str, Any]: | |
| 171 | + return dict(self.backends.get(self.backend, {}) or {}) | |
| 172 | + | |
| 173 | + | |
| 174 | +@dataclass(frozen=True) | |
| 175 | +class ServicesConfig: | |
| 176 | + """All service-level configuration.""" | |
| 177 | + | |
| 178 | + translation: TranslationServiceConfig | |
| 179 | + embedding: EmbeddingServiceConfig | |
| 180 | + rerank: RerankServiceConfig | |
| 181 | + | |
| 182 | + | |
| 183 | +@dataclass(frozen=True) | |
| 184 | +class TenantCatalogConfig: | |
| 185 | + """Tenant catalog configuration.""" | |
| 186 | + | |
| 187 | + default: Dict[str, Any] | |
| 188 | + tenants: Dict[str, Dict[str, Any]] | |
| 189 | + | |
| 190 | + def get_raw(self) -> Dict[str, Any]: | |
| 191 | + return { | |
| 192 | + "default": dict(self.default), | |
| 193 | + "tenants": {str(key): dict(value) for key, value in self.tenants.items()}, | |
| 194 | + } | |
| 195 | + | |
| 196 | + | |
| 197 | +@dataclass(frozen=True) | |
| 198 | +class ElasticsearchSettings: | |
| 199 | + host: str = "http://localhost:9200" | |
| 200 | + username: Optional[str] = None | |
| 201 | + password: Optional[str] = None | |
| 202 | + | |
| 203 | + | |
| 204 | +@dataclass(frozen=True) | |
| 205 | +class RedisSettings: | |
| 206 | + host: str = "localhost" | |
| 207 | + port: int = 6479 | |
| 208 | + snapshot_db: int = 0 | |
| 209 | + password: Optional[str] = None | |
| 210 | + socket_timeout: int = 1 | |
| 211 | + socket_connect_timeout: int = 1 | |
| 212 | + retry_on_timeout: bool = False | |
| 213 | + cache_expire_days: int = 720 | |
| 214 | + embedding_cache_prefix: str = "embedding" | |
| 215 | + anchor_cache_prefix: str = "product_anchors" | |
| 216 | + anchor_cache_expire_days: int = 30 | |
| 217 | + | |
| 218 | + | |
| 219 | +@dataclass(frozen=True) | |
| 220 | +class DatabaseSettings: | |
| 221 | + host: Optional[str] = None | |
| 222 | + port: int = 3306 | |
| 223 | + database: Optional[str] = None | |
| 224 | + username: Optional[str] = None | |
| 225 | + password: Optional[str] = None | |
| 226 | + | |
| 227 | + | |
| 228 | +@dataclass(frozen=True) | |
| 229 | +class SecretsConfig: | |
| 230 | + dashscope_api_key: Optional[str] = None | |
| 231 | + deepl_auth_key: Optional[str] = None | |
| 232 | + | |
| 233 | + | |
| 234 | +@dataclass(frozen=True) | |
| 235 | +class InfrastructureConfig: | |
| 236 | + elasticsearch: ElasticsearchSettings | |
| 237 | + redis: RedisSettings | |
| 238 | + database: DatabaseSettings | |
| 239 | + secrets: SecretsConfig | |
| 240 | + | |
| 241 | + | |
| 242 | +@dataclass(frozen=True) | |
| 243 | +class RuntimeConfig: | |
| 244 | + environment: str = "prod" | |
| 245 | + index_namespace: str = "" | |
| 246 | + api_host: str = "0.0.0.0" | |
| 247 | + api_port: int = 6002 | |
| 248 | + indexer_host: str = "0.0.0.0" | |
| 249 | + indexer_port: int = 6004 | |
| 250 | + embedding_host: str = "127.0.0.1" | |
| 251 | + embedding_port: int = 6005 | |
| 252 | + embedding_text_port: int = 6005 | |
| 253 | + embedding_image_port: int = 6008 | |
| 254 | + translator_host: str = "127.0.0.1" | |
| 255 | + translator_port: int = 6006 | |
| 256 | + reranker_host: str = "127.0.0.1" | |
| 257 | + reranker_port: int = 6007 | |
| 258 | + | |
| 259 | + | |
| 260 | +@dataclass(frozen=True) | |
| 261 | +class AssetsConfig: | |
| 262 | + query_rewrite_dictionary_path: Path | |
| 263 | + | |
| 264 | + | |
| 265 | +@dataclass(frozen=True) | |
| 266 | +class ConfigMetadata: | |
| 267 | + loaded_files: Tuple[str, ...] | |
| 268 | + config_hash: str | |
| 269 | + deprecated_keys: Tuple[str, ...] = field(default_factory=tuple) | |
| 270 | + | |
| 271 | + | |
| 272 | +@dataclass(frozen=True) | |
| 273 | +class AppConfig: | |
| 274 | + """Root application configuration.""" | |
| 275 | + | |
| 276 | + runtime: RuntimeConfig | |
| 277 | + infrastructure: InfrastructureConfig | |
| 278 | + search: SearchConfig | |
| 279 | + services: ServicesConfig | |
| 280 | + tenants: TenantCatalogConfig | |
| 281 | + assets: AssetsConfig | |
| 282 | + metadata: ConfigMetadata | |
| 283 | + | |
| 284 | + def sanitized_dict(self) -> Dict[str, Any]: | |
| 285 | + data = asdict(self) | |
| 286 | + data["infrastructure"]["elasticsearch"]["password"] = _mask_secret( | |
| 287 | + data["infrastructure"]["elasticsearch"].get("password") | |
| 288 | + ) | |
| 289 | + data["infrastructure"]["database"]["password"] = _mask_secret( | |
| 290 | + data["infrastructure"]["database"].get("password") | |
| 291 | + ) | |
| 292 | + data["infrastructure"]["redis"]["password"] = _mask_secret( | |
| 293 | + data["infrastructure"]["redis"].get("password") | |
| 294 | + ) | |
| 295 | + data["infrastructure"]["secrets"]["dashscope_api_key"] = _mask_secret( | |
| 296 | + data["infrastructure"]["secrets"].get("dashscope_api_key") | |
| 297 | + ) | |
| 298 | + data["infrastructure"]["secrets"]["deepl_auth_key"] = _mask_secret( | |
| 299 | + data["infrastructure"]["secrets"].get("deepl_auth_key") | |
| 300 | + ) | |
| 301 | + return data | |
| 302 | + | |
| 303 | + | |
| 304 | +def _mask_secret(value: Optional[str]) -> Optional[str]: | |
| 305 | + if not value: | |
| 306 | + return value | |
| 307 | + return "***" | ... | ... |
config/services_config.py
| 1 | 1 | """ |
| 2 | -Services configuration - single source for translation, embedding, rerank. | |
| 2 | +Unified service configuration accessors. | |
| 3 | 3 | |
| 4 | -Translation is modeled as: | |
| 5 | -- one translator service endpoint used by business callers | |
| 6 | -- multiple translation capabilities loaded inside the translator service | |
| 4 | +This module is now a thin adapter over ``config.loader.get_app_config`` and | |
| 5 | +contains no independent parsing or precedence logic. | |
| 7 | 6 | """ |
| 8 | 7 | |
| 9 | 8 | from __future__ import annotations |
| 10 | 9 | |
| 11 | -import os | |
| 12 | -from dataclasses import dataclass, field | |
| 13 | -from functools import lru_cache | |
| 14 | -from pathlib import Path | |
| 15 | -from typing import Any, Dict, List, Optional | |
| 16 | - | |
| 17 | -import yaml | |
| 18 | -from translation.settings import TranslationConfig, build_translation_config, get_translation_cache | |
| 19 | - | |
| 20 | - | |
| 21 | -@dataclass | |
| 22 | -class ServiceConfig: | |
| 23 | - """Config for one capability (embedding/rerank).""" | |
| 24 | - | |
| 25 | - provider: str | |
| 26 | - providers: Dict[str, Any] = field(default_factory=dict) | |
| 27 | - | |
| 28 | - def get_provider_cfg(self) -> Dict[str, Any]: | |
| 29 | - p = (self.provider or "").strip().lower() | |
| 30 | - return self.providers.get(p, {}) if isinstance(self.providers, dict) else {} | |
| 31 | - | |
| 32 | - | |
| 33 | -def _load_services_raw(config_path: Optional[Path] = None) -> Dict[str, Any]: | |
| 34 | - if config_path is None: | |
| 35 | - config_path = Path(__file__).parent / "config.yaml" | |
| 36 | - path = Path(config_path) | |
| 37 | - if not path.exists(): | |
| 38 | - raise FileNotFoundError(f"services config file not found: {path}") | |
| 39 | - try: | |
| 40 | - with open(path, "r", encoding="utf-8") as f: | |
| 41 | - data = yaml.safe_load(f) | |
| 42 | - except Exception as exc: | |
| 43 | - raise RuntimeError(f"failed to parse services config from {path}: {exc}") from exc | |
| 44 | - if not isinstance(data, dict): | |
| 45 | - raise RuntimeError(f"invalid config format in {path}: expected mapping root") | |
| 46 | - services = data.get("services") | |
| 47 | - if not isinstance(services, dict): | |
| 48 | - raise RuntimeError("config.yaml must contain a valid 'services' mapping") | |
| 49 | - return services | |
| 50 | - | |
| 51 | - | |
| 52 | -def _resolve_provider_name(env_name: str, config_provider: Any, capability: str) -> str: | |
| 53 | - provider = os.getenv(env_name) or config_provider | |
| 54 | - if not provider: | |
| 55 | - raise ValueError( | |
| 56 | - f"services.{capability}.provider is required " | |
| 57 | - f"(or set env override {env_name})" | |
| 58 | - ) | |
| 59 | - return str(provider).strip().lower() | |
| 60 | - | |
| 61 | - | |
| 62 | -def _resolve_translation() -> TranslationConfig: | |
| 63 | - raw = _load_services_raw() | |
| 64 | - cfg = raw.get("translation", {}) if isinstance(raw.get("translation"), dict) else {} | |
| 65 | - return build_translation_config(cfg) | |
| 66 | - | |
| 67 | - | |
| 68 | -def _resolve_embedding() -> ServiceConfig: | |
| 69 | - raw = _load_services_raw() | |
| 70 | - cfg = raw.get("embedding", {}) if isinstance(raw.get("embedding"), dict) else {} | |
| 71 | - providers = cfg.get("providers", {}) if isinstance(cfg.get("providers"), dict) else {} | |
| 72 | - | |
| 73 | - provider = _resolve_provider_name( | |
| 74 | - env_name="EMBEDDING_PROVIDER", | |
| 75 | - config_provider=cfg.get("provider"), | |
| 76 | - capability="embedding", | |
| 77 | - ) | |
| 78 | - if provider != "http": | |
| 79 | - raise ValueError(f"Unsupported embedding provider: {provider}") | |
| 80 | - | |
| 81 | - env_text_url = os.getenv("EMBEDDING_TEXT_SERVICE_URL") | |
| 82 | - env_image_url = os.getenv("EMBEDDING_IMAGE_SERVICE_URL") | |
| 83 | - if provider == "http": | |
| 84 | - providers = dict(providers) | |
| 85 | - http_cfg = dict(providers.get("http", {})) | |
| 86 | - if env_text_url: | |
| 87 | - http_cfg["text_base_url"] = env_text_url.rstrip("/") | |
| 88 | - if env_image_url: | |
| 89 | - http_cfg["image_base_url"] = env_image_url.rstrip("/") | |
| 90 | - if not http_cfg.get("text_base_url"): | |
| 91 | - raise ValueError("services.embedding.providers.http.text_base_url is required") | |
| 92 | - if not http_cfg.get("image_base_url"): | |
| 93 | - raise ValueError("services.embedding.providers.http.image_base_url is required") | |
| 94 | - providers["http"] = http_cfg | |
| 95 | - | |
| 96 | - return ServiceConfig(provider=provider, providers=providers) | |
| 97 | - | |
| 98 | - | |
| 99 | -def _resolve_rerank() -> ServiceConfig: | |
| 100 | - raw = _load_services_raw() | |
| 101 | - cfg = raw.get("rerank", {}) if isinstance(raw.get("rerank"), dict) else {} | |
| 102 | - providers = cfg.get("providers", {}) if isinstance(cfg.get("providers"), dict) else {} | |
| 103 | - | |
| 104 | - provider = _resolve_provider_name( | |
| 105 | - env_name="RERANK_PROVIDER", | |
| 106 | - config_provider=cfg.get("provider"), | |
| 107 | - capability="rerank", | |
| 108 | - ) | |
| 109 | - if provider != "http": | |
| 110 | - raise ValueError(f"Unsupported rerank provider: {provider}") | |
| 111 | - | |
| 112 | - env_url = os.getenv("RERANKER_SERVICE_URL") | |
| 113 | - if env_url: | |
| 114 | - url = env_url.rstrip("/") | |
| 115 | - if not url.endswith("/rerank"): | |
| 116 | - url = f"{url}/rerank" if "/rerank" not in url else url | |
| 117 | - providers = dict(providers) | |
| 118 | - providers["http"] = dict(providers.get("http", {})) | |
| 119 | - providers["http"]["base_url"] = url.replace("/rerank", "") | |
| 120 | - providers["http"]["service_url"] = url | |
| 121 | - | |
| 122 | - return ServiceConfig(provider=provider, providers=providers) | |
| 123 | - | |
| 124 | - | |
| 125 | -def get_rerank_backend_config() -> tuple[str, dict]: | |
| 126 | - raw = _load_services_raw() | |
| 127 | - cfg = raw.get("rerank", {}) if isinstance(raw.get("rerank"), dict) else {} | |
| 128 | - backends = cfg.get("backends", {}) if isinstance(cfg.get("backends"), dict) else {} | |
| 129 | - name = os.getenv("RERANK_BACKEND") or cfg.get("backend") | |
| 130 | - if not name: | |
| 131 | - raise ValueError("services.rerank.backend is required (or env RERANK_BACKEND)") | |
| 132 | - name = str(name).strip().lower() | |
| 133 | - backend_cfg = backends.get(name, {}) if isinstance(backends.get(name), dict) else {} | |
| 134 | - if not backend_cfg: | |
| 135 | - raise ValueError(f"services.rerank.backends.{name} is required") | |
| 136 | - return name, backend_cfg | |
| 137 | - | |
| 138 | - | |
| 139 | -def get_embedding_backend_config() -> tuple[str, dict]: | |
| 140 | - raw = _load_services_raw() | |
| 141 | - cfg = raw.get("embedding", {}) if isinstance(raw.get("embedding"), dict) else {} | |
| 142 | - backends = cfg.get("backends", {}) if isinstance(cfg.get("backends"), dict) else {} | |
| 143 | - name = os.getenv("EMBEDDING_BACKEND") or cfg.get("backend") | |
| 144 | - if not name: | |
| 145 | - raise ValueError("services.embedding.backend is required (or env EMBEDDING_BACKEND)") | |
| 146 | - name = str(name).strip().lower() | |
| 147 | - backend_cfg = backends.get(name, {}) if isinstance(backends.get(name), dict) else {} | |
| 148 | - if not backend_cfg: | |
| 149 | - raise ValueError(f"services.embedding.backends.{name} is required") | |
| 150 | - return name, backend_cfg | |
| 151 | - | |
| 152 | - | |
| 153 | -@lru_cache(maxsize=1) | |
| 154 | -def get_translation_config() -> TranslationConfig: | |
| 155 | - return _resolve_translation() | |
| 156 | - | |
| 157 | - | |
| 158 | -@lru_cache(maxsize=1) | |
| 159 | -def get_embedding_config() -> ServiceConfig: | |
| 160 | - return _resolve_embedding() | |
| 161 | - | |
| 162 | - | |
| 163 | -@lru_cache(maxsize=1) | |
| 164 | -def get_rerank_config() -> ServiceConfig: | |
| 165 | - return _resolve_rerank() | |
| 10 | +from typing import Any, Dict, Tuple | |
| 11 | + | |
| 12 | +from config.loader import get_app_config | |
| 13 | +from config.schema import EmbeddingServiceConfig, RerankServiceConfig, TranslationServiceConfig | |
| 14 | + | |
| 15 | + | |
| 16 | +def get_translation_config() -> Dict[str, Any]: | |
| 17 | + return get_app_config().services.translation.as_dict() | |
| 18 | + | |
| 19 | + | |
| 20 | +def get_embedding_config() -> EmbeddingServiceConfig: | |
| 21 | + return get_app_config().services.embedding | |
| 22 | + | |
| 23 | + | |
| 24 | +def get_rerank_config() -> RerankServiceConfig: | |
| 25 | + return get_app_config().services.rerank | |
| 166 | 26 | |
| 167 | 27 | |
| 168 | 28 | def get_translation_base_url() -> str: |
| 169 | - return str(get_translation_config()["service_url"]) | |
| 29 | + return get_app_config().services.translation.endpoint | |
| 170 | 30 | |
| 171 | 31 | |
| 172 | 32 | def get_translation_cache_config() -> Dict[str, Any]: |
| 173 | - return get_translation_cache(get_translation_config()) | |
| 33 | + return dict(get_app_config().services.translation.cache) | |
| 174 | 34 | |
| 175 | 35 | |
| 176 | 36 | def get_embedding_text_base_url() -> str: |
| 177 | - provider_cfg = get_embedding_config().providers.get("http", {}) | |
| 178 | - base = os.getenv("EMBEDDING_TEXT_SERVICE_URL") or provider_cfg.get("text_base_url") | |
| 37 | + provider_cfg = get_app_config().services.embedding.get_provider_config() | |
| 38 | + base = provider_cfg.get("text_base_url") | |
| 179 | 39 | if not base: |
| 180 | - raise ValueError("Embedding text HTTP base_url is not configured") | |
| 40 | + raise ValueError("Embedding text base_url is not configured") | |
| 181 | 41 | return str(base).rstrip("/") |
| 182 | 42 | |
| 183 | 43 | |
| 184 | 44 | def get_embedding_image_base_url() -> str: |
| 185 | - provider_cfg = get_embedding_config().providers.get("http", {}) | |
| 186 | - base = os.getenv("EMBEDDING_IMAGE_SERVICE_URL") or provider_cfg.get("image_base_url") | |
| 45 | + provider_cfg = get_app_config().services.embedding.get_provider_config() | |
| 46 | + base = provider_cfg.get("image_base_url") | |
| 187 | 47 | if not base: |
| 188 | - raise ValueError("Embedding image HTTP base_url is not configured") | |
| 48 | + raise ValueError("Embedding image base_url is not configured") | |
| 189 | 49 | return str(base).rstrip("/") |
| 190 | 50 | |
| 191 | 51 | |
| 52 | +def get_embedding_backend_config() -> Tuple[str, Dict[str, Any]]: | |
| 53 | + cfg = get_app_config().services.embedding | |
| 54 | + return cfg.backend, cfg.get_backend_config() | |
| 55 | + | |
| 56 | + | |
| 57 | +def get_embedding_image_backend_config() -> Tuple[str, Dict[str, Any]]: | |
| 58 | + cfg = get_app_config().services.embedding | |
| 59 | + return cfg.image_backend, cfg.get_image_backend_config() | |
| 60 | + | |
| 61 | + | |
| 62 | +def get_rerank_backend_config() -> Tuple[str, Dict[str, Any]]: | |
| 63 | + cfg = get_app_config().services.rerank | |
| 64 | + return cfg.backend, cfg.get_backend_config() | |
| 65 | + | |
| 66 | + | |
| 192 | 67 | def get_rerank_base_url() -> str: |
| 193 | - base = ( | |
| 194 | - os.getenv("RERANKER_SERVICE_URL") | |
| 195 | - or get_rerank_config().providers.get("http", {}).get("service_url") | |
| 196 | - or get_rerank_config().providers.get("http", {}).get("base_url") | |
| 197 | - ) | |
| 68 | + provider_cfg = get_app_config().services.rerank.get_provider_config() | |
| 69 | + base = provider_cfg.get("service_url") or provider_cfg.get("base_url") | |
| 198 | 70 | if not base: |
| 199 | - raise ValueError("Rerank HTTP base_url is not configured") | |
| 71 | + raise ValueError("Rerank service URL is not configured") | |
| 200 | 72 | return str(base).rstrip("/") |
| 201 | 73 | |
| 202 | 74 | |
| 203 | 75 | def get_rerank_service_url() -> str: |
| 204 | - """Backward-compatible alias.""" | |
| 205 | 76 | return get_rerank_base_url() | ... | ... |
config/tenant_config_loader.py
| ... | ... | @@ -2,12 +2,13 @@ |
| 2 | 2 | ็งๆท้ ็ฝฎๅ ่ฝฝๅจใ |
| 3 | 3 | |
| 4 | 4 | ไป็ปไธ้ ็ฝฎๆไปถ๏ผconfig.yaml๏ผๅ ่ฝฝ็งๆท้ ็ฝฎ๏ผๅ ๆฌไธป่ฏญ่จๅ็ดขๅผ่ฏญ่จ๏ผindex_languages๏ผใ |
| 5 | -ๆฏๆๆง้ ็ฝฎ translate_to_en / translate_to_zh ็ๅ ผๅฎน่งฃๆใ | |
| 6 | 5 | """ |
| 7 | 6 | |
| 8 | 7 | import logging |
| 9 | 8 | from typing import Dict, Any, Optional, List |
| 10 | 9 | |
| 10 | +from config.loader import get_app_config | |
| 11 | + | |
| 11 | 12 | logger = logging.getLogger(__name__) |
| 12 | 13 | |
| 13 | 14 | # ๆฏๆ็็ดขๅผ่ฏญ่จ๏ผcode -> display name๏ผไพๅๅฎถๅพ้ไธปๅธๅบ่ฏญ่จ็ญๅบๆฏไฝฟ็จ๏ผ |
| ... | ... | @@ -83,25 +84,13 @@ def resolve_index_languages( |
| 83 | 84 | ) -> List[str]: |
| 84 | 85 | """ |
| 85 | 86 | ไป็งๆท้ ็ฝฎ่งฃๆ index_languagesใ |
| 86 | - ่ฅๅญๅจ index_languages ๅ็จไน๏ผๅฆๅๆๆง้ ็ฝฎ translate_to_en / translate_to_zh ๆจๅฏผใ | |
| 87 | + ่ฅ้ ็ฝฎ็ผบๅคฑๆ้ๆณ๏ผๅๅ้ๅฐ้ป่ฎค้ ็ฝฎใ | |
| 87 | 88 | """ |
| 88 | - if "index_languages" in tenant_config: | |
| 89 | - normalized = normalize_index_languages( | |
| 90 | - tenant_config["index_languages"], | |
| 91 | - tenant_config.get("primary_language") or "en", | |
| 92 | - ) | |
| 93 | - return normalized if normalized else list(default_index_languages) | |
| 94 | - primary = (tenant_config.get("primary_language") or "en").strip().lower() | |
| 95 | - to_en = bool(tenant_config.get("translate_to_en")) | |
| 96 | - to_zh = bool(tenant_config.get("translate_to_zh")) | |
| 97 | - langs: List[str] = [] | |
| 98 | - if primary and primary in SOURCE_LANG_CODE_MAP: | |
| 99 | - langs.append(primary) | |
| 100 | - for code in ("en", "zh"): | |
| 101 | - if code not in langs and ((code == "en" and to_en) or (code == "zh" and to_zh)): | |
| 102 | - if code in SOURCE_LANG_CODE_MAP: | |
| 103 | - langs.append(code) | |
| 104 | - return langs if langs else list(default_index_languages) | |
| 89 | + normalized = normalize_index_languages( | |
| 90 | + tenant_config.get("index_languages"), | |
| 91 | + tenant_config.get("primary_language") or "en", | |
| 92 | + ) | |
| 93 | + return normalized if normalized else list(default_index_languages) | |
| 105 | 94 | |
| 106 | 95 | |
| 107 | 96 | class TenantConfigLoader: |
| ... | ... | @@ -122,15 +111,8 @@ class TenantConfigLoader: |
| 122 | 111 | return self._config |
| 123 | 112 | |
| 124 | 113 | try: |
| 125 | - from config import ConfigLoader | |
| 126 | - | |
| 127 | - config_loader = ConfigLoader() | |
| 128 | - search_config = config_loader.load_config() | |
| 129 | - tenant_cfg = search_config.tenant_config | |
| 130 | - if not isinstance(tenant_cfg, dict): | |
| 131 | - raise RuntimeError("tenant_config must be an object") | |
| 132 | - | |
| 133 | - default_cfg = tenant_cfg.get("default") | |
| 114 | + tenant_cfg = get_app_config().tenants | |
| 115 | + default_cfg = tenant_cfg.default | |
| 134 | 116 | if not isinstance(default_cfg, dict): |
| 135 | 117 | raise RuntimeError("tenant_config.default must be configured in config.yaml") |
| 136 | 118 | default_primary = (default_cfg.get("primary_language") or "en").strip().lower() |
| ... | ... | @@ -143,7 +125,7 @@ class TenantConfigLoader: |
| 143 | 125 | "tenant_config.default.index_languages must include at least one supported language" |
| 144 | 126 | ) |
| 145 | 127 | |
| 146 | - tenants_cfg = tenant_cfg.get("tenants", {}) | |
| 128 | + tenants_cfg = tenant_cfg.tenants | |
| 147 | 129 | if not isinstance(tenants_cfg, dict): |
| 148 | 130 | raise RuntimeError("tenant_config.tenants must be an object") |
| 149 | 131 | ... | ... |
config/utils.py
| ... | ... | @@ -0,0 +1,738 @@ |
| 1 | +# Configuration System Review And Redesign | |
| 2 | + | |
| 3 | +## 1. Goal | |
| 4 | + | |
| 5 | +This document reviews the current configuration system and proposes a practical redesign for long-term maintainability. | |
| 6 | + | |
| 7 | +The target is a configuration system that is: | |
| 8 | + | |
| 9 | +- unified in loading and ownership | |
| 10 | +- clear in boundaries and precedence | |
| 11 | +- visible in effective behavior | |
| 12 | +- easy to evolve across development, deployment, and operations | |
| 13 | + | |
| 14 | +This review is based on the current implementation, not only on the intended architecture in docs. | |
| 15 | + | |
| 16 | +## 2. Project Context | |
| 17 | + | |
| 18 | +The repo already defines the right architectural direction: | |
| 19 | + | |
| 20 | +- `config/config.yaml` should be the main configuration source for search behavior and service wiring | |
| 21 | +- `.env` should mainly carry deployment-specific values and secrets | |
| 22 | +- provider/backend expansion should stay centralized instead of spreading through business code | |
| 23 | + | |
| 24 | +That direction is described in: | |
| 25 | + | |
| 26 | +- [`README.md`](/data/saas-search/README.md) | |
| 27 | +- [`docs/DEVELOPER_GUIDE.md`](/data/saas-search/docs/DEVELOPER_GUIDE.md) | |
| 28 | +- [`docs/QUICKSTART.md`](/data/saas-search/docs/QUICKSTART.md) | |
| 29 | +- [`translation/README.md`](/data/saas-search/translation/README.md) | |
| 30 | + | |
| 31 | +The problem is not the architectural intent. The problem is that the current implementation only partially follows it. | |
| 32 | + | |
| 33 | +## 3. Current-State Review | |
| 34 | + | |
| 35 | +### 3.1 What exists today | |
| 36 | + | |
| 37 | +The current system effectively has several configuration channels: | |
| 38 | + | |
| 39 | +- `config/config.yaml` | |
| 40 | + - search behavior | |
| 41 | + - rerank behavior | |
| 42 | + - services registry | |
| 43 | + - tenant config | |
| 44 | +- `config/config_loader.py` | |
| 45 | + - parses search behavior and tenant config into `SearchConfig` | |
| 46 | + - also injects some defaults from code | |
| 47 | +- `config/services_config.py` | |
| 48 | + - reparses `config/config.yaml` again, independently | |
| 49 | + - resolves translation, embedding, rerank service config | |
| 50 | + - also applies env overrides | |
| 51 | +- `config/env_config.py` | |
| 52 | + - loads `.env` | |
| 53 | + - defines ES, Redis, DB, host/port, service URLs, namespace, model path defaults | |
| 54 | +- service-local config modules | |
| 55 | + - [`embeddings/config.py`](/data/saas-search/embeddings/config.py) | |
| 56 | + - [`reranker/config.py`](/data/saas-search/reranker/config.py) | |
| 57 | +- startup scripts | |
| 58 | + - derive defaults from shell env, Python config, and YAML in different combinations | |
| 59 | +- inline fallbacks in business logic | |
| 60 | + - query parsing | |
| 61 | + - indexing | |
| 62 | + - service startup | |
| 63 | + | |
| 64 | +### 3.2 Main findings | |
| 65 | + | |
| 66 | +#### Finding A: there is no single loader for the full effective configuration | |
| 67 | + | |
| 68 | +`ConfigLoader` and `services_config` both parse `config/config.yaml`, but they do so separately and with different responsibilities. | |
| 69 | + | |
| 70 | +- [`config/config_loader.py`](/data/saas-search/config/config_loader.py#L148) | |
| 71 | +- [`config/services_config.py`](/data/saas-search/config/services_config.py#L33) | |
| 72 | + | |
| 73 | +Impact: | |
| 74 | + | |
| 75 | +- the same file is loaded twice through different code paths | |
| 76 | +- search config and services config can drift in interpretation | |
| 77 | +- alternative config paths are hard to support cleanly | |
| 78 | +- tests and tools cannot ask one place for the full effective config tree | |
| 79 | + | |
| 80 | +#### Finding B: precedence is not explicit, stable, or globally enforced | |
| 81 | + | |
| 82 | +Current precedence differs by subsystem: | |
| 83 | + | |
| 84 | +- search behavior mostly comes from YAML plus code defaults | |
| 85 | +- embedding and rerank allow env overrides for provider/backend/url | |
| 86 | +- translation intentionally blocks some env overrides | |
| 87 | +- startup scripts still choose host/port and mode via env | |
| 88 | +- some values are reconstructed from other env vars | |
| 89 | + | |
| 90 | +Examples: | |
| 91 | + | |
| 92 | +- env override for embedding provider/url/backend: | |
| 93 | + - [`config/services_config.py`](/data/saas-search/config/services_config.py#L52) | |
| 94 | + - [`config/services_config.py`](/data/saas-search/config/services_config.py#L68) | |
| 95 | + - [`config/services_config.py`](/data/saas-search/config/services_config.py#L139) | |
| 96 | +- host/port and service URL reconstruction: | |
| 97 | + - [`config/env_config.py`](/data/saas-search/config/env_config.py#L55) | |
| 98 | + - [`config/env_config.py`](/data/saas-search/config/env_config.py#L75) | |
| 99 | +- translator host/port still driven by startup env: | |
| 100 | + - [`scripts/start_translator.sh`](/data/saas-search/scripts/start_translator.sh#L28) | |
| 101 | + | |
| 102 | +Impact: | |
| 103 | + | |
| 104 | +- operators cannot reliably predict the effective configuration by reading one file | |
| 105 | +- the same setting category behaves differently across services | |
| 106 | +- incidents become harder to debug because source-of-truth depends on the code path | |
| 107 | + | |
| 108 | +#### Finding C: defaults are duplicated across YAML and code | |
| 109 | + | |
| 110 | +There are several layers of default values: | |
| 111 | + | |
| 112 | +- dataclass defaults in `QueryConfig` | |
| 113 | +- fallback defaults in `ConfigLoader._parse_config` | |
| 114 | +- defaults in `config.yaml` | |
| 115 | +- defaults in `env_config.py` | |
| 116 | +- defaults in `embeddings/config.py` | |
| 117 | +- defaults in `reranker/config.py` | |
| 118 | +- defaults in startup scripts | |
| 119 | + | |
| 120 | +Examples: | |
| 121 | + | |
| 122 | +- query defaults duplicated in dataclass and parser: | |
| 123 | + - [`config/config_loader.py`](/data/saas-search/config/config_loader.py#L24) | |
| 124 | + - [`config/config_loader.py`](/data/saas-search/config/config_loader.py#L240) | |
| 125 | +- embedding defaults duplicated in YAML, `services_config`, `embeddings/config.py`, and startup script: | |
| 126 | + - [`config/config.yaml`](/data/saas-search/config/config.yaml#L196) | |
| 127 | + - [`embeddings/config.py`](/data/saas-search/embeddings/config.py#L14) | |
| 128 | + - [`scripts/start_embedding_service.sh`](/data/saas-search/scripts/start_embedding_service.sh#L29) | |
| 129 | +- reranker defaults duplicated in YAML and `reranker/config.py`: | |
| 130 | + - [`config/config.yaml`](/data/saas-search/config/config.yaml#L214) | |
| 131 | + - [`reranker/config.py`](/data/saas-search/reranker/config.py#L6) | |
| 132 | + | |
| 133 | +Impact: | |
| 134 | + | |
| 135 | +- changing a default is risky because there may be multiple hidden copies | |
| 136 | +- code review cannot easily tell whether a value is authoritative or dead legacy | |
| 137 | +- โsame configโ may behave differently across processes | |
| 138 | + | |
| 139 | +#### Finding D: config is still embedded in runtime logic | |
| 140 | + | |
| 141 | +Some important behavior remains encoded as inline fallback logic rather than declared config. | |
| 142 | + | |
| 143 | +Examples: | |
| 144 | + | |
| 145 | +- query-time translation target languages fallback to `["en", "zh"]`: | |
| 146 | + - [`query/query_parser.py`](/data/saas-search/query/query_parser.py#L339) | |
| 147 | +- indexer text handling and LLM enrichment also fallback to `["en", "zh"]`: | |
| 148 | + - [`indexer/document_transformer.py`](/data/saas-search/indexer/document_transformer.py#L216) | |
| 149 | + - [`indexer/document_transformer.py`](/data/saas-search/indexer/document_transformer.py#L310) | |
| 150 | + - [`indexer/document_transformer.py`](/data/saas-search/indexer/document_transformer.py#L649) | |
| 151 | + | |
| 152 | +Impact: | |
| 153 | + | |
| 154 | +- configuration is not fully visible in config files | |
| 155 | +- behavior can silently change when tenant config is missing or malformed | |
| 156 | +- โdefault behaviorโ is spread across business modules | |
| 157 | + | |
| 158 | +#### Finding E: some configuration assets are not managed as first-class config | |
| 159 | + | |
| 160 | +Query rewrite is configured through an external file, but the file path is hardcoded and currently inconsistent with the repository content. | |
| 161 | + | |
| 162 | +- loader expects: | |
| 163 | + - [`config/config_loader.py`](/data/saas-search/config/config_loader.py#L162) | |
| 164 | +- repo currently contains: | |
| 165 | + - [`config/query_rewrite.dict`](/data/saas-search/config/query_rewrite.dict) | |
| 166 | + | |
| 167 | +There is also an admin API that mutates rewrite rules in memory only: | |
| 168 | + | |
| 169 | +- [`api/routes/admin.py`](/data/saas-search/api/routes/admin.py#L68) | |
| 170 | +- [`query/query_parser.py`](/data/saas-search/query/query_parser.py#L622) | |
| 171 | + | |
| 172 | +Impact: | |
| 173 | + | |
| 174 | +- rewrite rules are neither cleanly file-backed nor fully runtime-managed | |
| 175 | +- restart behavior is unclear | |
| 176 | +- configuration visibility and persistence are weak | |
| 177 | + | |
| 178 | +#### Finding F: visibility is limited | |
| 179 | + | |
| 180 | +The system exposes only a small sanitized subset at `/admin/config`. | |
| 181 | + | |
| 182 | +- [`api/routes/admin.py`](/data/saas-search/api/routes/admin.py#L42) | |
| 183 | + | |
| 184 | +At the same time, the true effective config includes: | |
| 185 | + | |
| 186 | +- tenant overlays | |
| 187 | +- env overrides | |
| 188 | +- service backend selections | |
| 189 | +- script-selected modes | |
| 190 | +- hidden defaults in code | |
| 191 | + | |
| 192 | +Impact: | |
| 193 | + | |
| 194 | +- there is no authoritative โeffective configโ view | |
| 195 | +- debugging configuration mismatches requires source reading | |
| 196 | +- operators cannot easily verify what each process actually started with | |
| 197 | + | |
| 198 | +#### Finding G: the indexer does not really consume the unified config as a first-class dependency | |
| 199 | + | |
| 200 | +Indexer startup explicitly says config is loaded only for parity/logging and routes do not depend on it. | |
| 201 | + | |
| 202 | +- [`api/indexer_app.py`](/data/saas-search/api/indexer_app.py#L76) | |
| 203 | + | |
| 204 | +Impact: | |
| 205 | + | |
| 206 | +- configuration is not truly system-wide | |
| 207 | +- search-side and indexer-side behavior can drift | |
| 208 | +- the current โunified configโ is only partially unified | |
| 209 | + | |
| 210 | +#### Finding H: docs still carry legacy and mixed mental models | |
| 211 | + | |
| 212 | +Most high-level docs describe the desired centralized model, but some implementation/docs still expose legacy concepts such as `translate_to_en` and `translate_to_zh`. | |
| 213 | + | |
| 214 | +- desired model: | |
| 215 | + - [`README.md`](/data/saas-search/README.md#L78) | |
| 216 | + - [`docs/DEVELOPER_GUIDE.md`](/data/saas-search/docs/DEVELOPER_GUIDE.md#L207) | |
| 217 | + - [`translation/README.md`](/data/saas-search/translation/README.md#L161) | |
| 218 | +- legacy tenant translation flags still documented: | |
| 219 | + - [`indexer/README.md`](/data/saas-search/indexer/README.md#L39) | |
| 220 | + | |
| 221 | +Impact: | |
| 222 | + | |
| 223 | +- new developers may follow old mental models | |
| 224 | +- cleanup work keeps getting deferred because old and new systems appear both โsupportedโ | |
| 225 | + | |
| 226 | +## 4. Design Principles For The Redesign | |
| 227 | + | |
| 228 | +The redesign should follow these rules. | |
| 229 | + | |
| 230 | +### 4.1 One logical configuration system | |
| 231 | + | |
| 232 | +It is acceptable to have multiple files, but not multiple loaders with overlapping ownership. | |
| 233 | + | |
| 234 | +There must be one loader pipeline that produces one typed `AppConfig`. | |
| 235 | + | |
| 236 | +### 4.2 Configuration files declare, parser code interprets, env provides runtime injection | |
| 237 | + | |
| 238 | +Responsibilities should be: | |
| 239 | + | |
| 240 | +- configuration files | |
| 241 | + - declare non-secret desired behavior and non-secret deployable settings | |
| 242 | +- parsing logic | |
| 243 | + - load, merge, validate, normalize, and expose typed config | |
| 244 | + - never invent hidden business behavior | |
| 245 | +- environment variables | |
| 246 | + - carry secrets and a small set of runtime/process values | |
| 247 | + - do not redefine business behavior casually | |
| 248 | + | |
| 249 | +### 4.3 One precedence rule for the whole system | |
| 250 | + | |
| 251 | +Every config category should follow the same merge model unless explicitly exempted. | |
| 252 | + | |
| 253 | +### 4.4 No silent implicit fallback for business behavior | |
| 254 | + | |
| 255 | +Fail fast at startup when required config is missing or invalid. | |
| 256 | + | |
| 257 | +Do not silently fall back to legacy behavior such as hardcoded language lists. | |
| 258 | + | |
| 259 | +### 4.5 Effective configuration must be observable | |
| 260 | + | |
| 261 | +Every service should be able to show: | |
| 262 | + | |
| 263 | +- config version or hash | |
| 264 | +- source files loaded | |
| 265 | +- environment name | |
| 266 | +- sanitized effective configuration | |
| 267 | + | |
| 268 | +## 5. Recommended Target Design | |
| 269 | + | |
| 270 | +## 5.1 Boundary model | |
| 271 | + | |
| 272 | +Use three clear layers. | |
| 273 | + | |
| 274 | +### Layer 1: repository-managed static config | |
| 275 | + | |
| 276 | +Purpose: | |
| 277 | + | |
| 278 | +- search behavior | |
| 279 | +- tenant behavior | |
| 280 | +- provider/backend registry | |
| 281 | +- non-secret service topology defaults | |
| 282 | +- feature switches | |
| 283 | + | |
| 284 | +Examples: | |
| 285 | + | |
| 286 | +- field boosts | |
| 287 | +- query strategy | |
| 288 | +- rerank fusion parameters | |
| 289 | +- tenant language plans | |
| 290 | +- translation capability registry | |
| 291 | +- embedding backend selection default | |
| 292 | + | |
| 293 | +### Layer 2: environment-specific overlays | |
| 294 | + | |
| 295 | +Purpose: | |
| 296 | + | |
| 297 | +- per-environment non-secret differences | |
| 298 | +- service endpoints by environment | |
| 299 | +- resource sizing defaults by environment | |
| 300 | +- dev/test/prod operational differences | |
| 301 | + | |
| 302 | +Examples: | |
| 303 | + | |
| 304 | +- local embedding URL vs production URL | |
| 305 | +- dev rerank backend vs prod rerank backend | |
| 306 | +- lower concurrency in local development | |
| 307 | + | |
| 308 | +### Layer 3: environment variables | |
| 309 | + | |
| 310 | +Purpose: | |
| 311 | + | |
| 312 | +- secrets | |
| 313 | +- bind host/port | |
| 314 | +- external infrastructure credentials | |
| 315 | +- container-orchestrator last-mile injection | |
| 316 | + | |
| 317 | +Examples: | |
| 318 | + | |
| 319 | +- `ES_HOST`, `ES_USERNAME`, `ES_PASSWORD` | |
| 320 | +- `DB_HOST`, `DB_USERNAME`, `DB_PASSWORD` | |
| 321 | +- `REDIS_HOST`, `REDIS_PASSWORD` | |
| 322 | +- `DASHSCOPE_API_KEY`, `DEEPL_AUTH_KEY` | |
| 323 | +- `API_HOST`, `API_PORT`, `INDEXER_PORT`, `TRANSLATION_PORT` | |
| 324 | + | |
| 325 | +Rule: | |
| 326 | + | |
| 327 | +- environment variables should not be the normal path for choosing business behavior such as translation model, embedding backend, or tenant language policy | |
| 328 | +- if an env override is allowed for a non-secret field, it must be explicitly listed and documented as an operational override, not a hidden convention | |
| 329 | + | |
| 330 | +## 5.2 Unified precedence | |
| 331 | + | |
| 332 | +Recommended precedence: | |
| 333 | + | |
| 334 | +1. schema defaults in code | |
| 335 | +2. `config/base.yaml` | |
| 336 | +3. `config/environments/<env>.yaml` | |
| 337 | +4. tenant overlay from `config/tenants/` | |
| 338 | +5. environment variables for the explicitly allowed runtime keys | |
| 339 | +6. CLI flags for the current process only | |
| 340 | + | |
| 341 | +Important rule: | |
| 342 | + | |
| 343 | +- only one module may implement this merge logic | |
| 344 | +- no business module may call `os.getenv()` directly for configuration | |
| 345 | + | |
| 346 | +## 5.3 Recommended directory structure | |
| 347 | + | |
| 348 | +```text | |
| 349 | +config/ | |
| 350 | + schema.py | |
| 351 | + loader.py | |
| 352 | + sources.py | |
| 353 | + base.yaml | |
| 354 | + environments/ | |
| 355 | + dev.yaml | |
| 356 | + test.yaml | |
| 357 | + prod.yaml | |
| 358 | + tenants/ | |
| 359 | + _default.yaml | |
| 360 | + 1.yaml | |
| 361 | + 162.yaml | |
| 362 | + 170.yaml | |
| 363 | + dictionaries/ | |
| 364 | + query_rewrite.dict | |
| 365 | + README.md | |
| 366 | +.env.example | |
| 367 | +``` | |
| 368 | + | |
| 369 | +Notes: | |
| 370 | + | |
| 371 | +- `base.yaml` contains shared defaults and feature behavior | |
| 372 | +- `environments/*.yaml` contains environment-specific non-secret overrides | |
| 373 | +- `tenants/*.yaml` contains tenant-specific overrides only | |
| 374 | +- `dictionaries/` stores first-class config assets such as rewrite dictionaries | |
| 375 | +- `schema.py` defines the typed config model | |
| 376 | +- `loader.py` is the only entry point that loads and merges config | |
| 377 | + | |
| 378 | +If the team prefers fewer files, `tenants.yaml` is also acceptable. The key requirement is not โone fileโ, but โone loading model with clear ownershipโ. | |
| 379 | + | |
| 380 | +## 5.4 Typed configuration model | |
| 381 | + | |
| 382 | +Introduce one root object, for example: | |
| 383 | + | |
| 384 | +```python | |
| 385 | +class AppConfig(BaseModel): | |
| 386 | + runtime: RuntimeConfig | |
| 387 | + infrastructure: InfrastructureConfig | |
| 388 | + search: SearchConfig | |
| 389 | + services: ServicesConfig | |
| 390 | + tenants: TenantCatalogConfig | |
| 391 | + assets: ConfigAssets | |
| 392 | +``` | |
| 393 | + | |
| 394 | +Suggested subtrees: | |
| 395 | + | |
| 396 | +- `runtime` | |
| 397 | + - environment name | |
| 398 | + - config revision/hash | |
| 399 | + - bind addresses/ports | |
| 400 | +- `infrastructure` | |
| 401 | + - ES | |
| 402 | + - DB | |
| 403 | + - Redis | |
| 404 | + - index namespace | |
| 405 | +- `search` | |
| 406 | + - field boosts | |
| 407 | + - query config | |
| 408 | + - function score | |
| 409 | + - rerank behavior | |
| 410 | + - spu config | |
| 411 | +- `services` | |
| 412 | + - translation | |
| 413 | + - embedding | |
| 414 | + - rerank | |
| 415 | +- `tenants` | |
| 416 | + - default tenant config | |
| 417 | + - tenant overrides | |
| 418 | +- `assets` | |
| 419 | + - rewrite dictionary path | |
| 420 | + | |
| 421 | +Benefits: | |
| 422 | + | |
| 423 | +- one validated object shared by backend, indexer, translator, embedding, reranker | |
| 424 | +- one place for defaults | |
| 425 | +- one place for schema evolution | |
| 426 | + | |
| 427 | +## 5.5 Loading flow | |
| 428 | + | |
| 429 | +Recommended loading flow: | |
| 430 | + | |
| 431 | +1. determine `APP_ENV` or `RUNTIME_ENV` | |
| 432 | +2. load schema defaults | |
| 433 | +3. load `config/base.yaml` | |
| 434 | +4. load `config/environments/<env>.yaml` if present | |
| 435 | +5. load tenant files | |
| 436 | +6. inject first-class assets such as rewrite dictionary | |
| 437 | +7. apply allowed env overrides | |
| 438 | +8. validate the final `AppConfig` | |
| 439 | +9. freeze and cache the config object | |
| 440 | +10. expose a sanitized effective-config view | |
| 441 | + | |
| 442 | +Important: | |
| 443 | + | |
| 444 | +- every process should call the same loader | |
| 445 | +- services should receive a resolved `AppConfig`, not re-open YAML independently | |
| 446 | + | |
| 447 | +## 5.6 Clear responsibility split | |
| 448 | + | |
| 449 | +### Configuration files are responsible for | |
| 450 | + | |
| 451 | +- what the system should do | |
| 452 | +- what providers/backends are available | |
| 453 | +- which features are enabled | |
| 454 | +- tenant language/index policies | |
| 455 | +- non-secret service topology | |
| 456 | + | |
| 457 | +### Parser/loader code is responsible for | |
| 458 | + | |
| 459 | +- locating sources | |
| 460 | +- merge precedence | |
| 461 | +- type validation | |
| 462 | +- normalization | |
| 463 | +- deprecation warnings | |
| 464 | +- producing the final immutable config object | |
| 465 | + | |
| 466 | +### Environment variables are responsible for | |
| 467 | + | |
| 468 | +- secrets | |
| 469 | +- bind addresses/ports | |
| 470 | +- infrastructure endpoints when the deployment platform injects them | |
| 471 | +- a very small set of documented operational overrides | |
| 472 | + | |
| 473 | +### Business code is not responsible for | |
| 474 | + | |
| 475 | +- inventing defaults for missing config | |
| 476 | +- loading YAML directly | |
| 477 | +- calling `os.getenv()` for normal application behavior | |
| 478 | + | |
| 479 | +## 5.7 How to handle service config | |
| 480 | + | |
| 481 | +Unify all service-facing config under one structure: | |
| 482 | + | |
| 483 | +```yaml | |
| 484 | +services: | |
| 485 | + translation: | |
| 486 | + endpoint: "http://translator:6006" | |
| 487 | + timeout_sec: 10 | |
| 488 | + default_model: "llm" | |
| 489 | + default_scene: "general" | |
| 490 | + capabilities: ... | |
| 491 | + embedding: | |
| 492 | + endpoint: | |
| 493 | + text: "http://embedding:6005" | |
| 494 | + image: "http://embedding-image:6008" | |
| 495 | + backend: "tei" | |
| 496 | + backends: ... | |
| 497 | + rerank: | |
| 498 | + endpoint: "http://reranker:6007/rerank" | |
| 499 | + backend: "qwen3_vllm" | |
| 500 | + backends: ... | |
| 501 | +``` | |
| 502 | + | |
| 503 | +Rules: | |
| 504 | + | |
| 505 | +- `endpoint` is how callers reach the service | |
| 506 | +- `backend` is how the service itself is implemented | |
| 507 | +- only the service process cares about `backend` | |
| 508 | +- only callers care about `endpoint` | |
| 509 | +- both still belong to the same config tree, because they are part of one system | |
| 510 | + | |
| 511 | +## 5.8 How to handle tenant config | |
| 512 | + | |
| 513 | +Tenant config should become explicit policy, not translation-era leftovers. | |
| 514 | + | |
| 515 | +Recommended tenant fields: | |
| 516 | + | |
| 517 | +- `primary_language` | |
| 518 | +- `index_languages` | |
| 519 | +- `search_languages` | |
| 520 | +- `translation_policy` | |
| 521 | +- `facet_policy` | |
| 522 | +- optional tenant-specific ranking overrides | |
| 523 | + | |
| 524 | +Avoid keeping `translate_to_en` and `translate_to_zh` as active concepts in the long-term model. | |
| 525 | + | |
| 526 | +If compatibility is needed, support them only in the loader as deprecated aliases and emit warnings. | |
| 527 | + | |
| 528 | +## 5.9 How to handle rewrite rules and similar assets | |
| 529 | + | |
| 530 | +Treat them as declared config assets. | |
| 531 | + | |
| 532 | +Recommended rules: | |
| 533 | + | |
| 534 | +- file path declared in config | |
| 535 | +- one canonical location under `config/dictionaries/` | |
| 536 | +- loader validates presence and format | |
| 537 | +- admin runtime updates either: | |
| 538 | + - are removed, or | |
| 539 | + - write back through a controlled persistence path | |
| 540 | + | |
| 541 | +Do not keep a hybrid model where startup loads one file and admin mutates only in memory. | |
| 542 | + | |
| 543 | +## 5.10 Observability improvements | |
| 544 | + | |
| 545 | +Add the following: | |
| 546 | + | |
| 547 | +- `config dump` CLI that prints sanitized effective config | |
| 548 | +- startup log with config hash, environment, and config file list | |
| 549 | +- `/admin/config/effective` endpoint returning sanitized effective config | |
| 550 | +- `/admin/config/meta` endpoint returning: | |
| 551 | + - environment | |
| 552 | + - config hash | |
| 553 | + - loaded source files | |
| 554 | + - deprecated keys in use | |
| 555 | + | |
| 556 | +This is important for operations and for multi-service debugging. | |
| 557 | + | |
| 558 | +## 6. Practical Refactor Plan | |
| 559 | + | |
| 560 | +The refactor should be incremental. | |
| 561 | + | |
| 562 | +### Phase 1: establish the new config core without changing behavior | |
| 563 | + | |
| 564 | +- create `config/schema.py` | |
| 565 | +- create `config/loader.py` | |
| 566 | +- move all current defaults into schema models | |
| 567 | +- make loader read current `config/config.yaml` | |
| 568 | +- make loader read `.env` only for approved keys | |
| 569 | +- expose one `get_app_config()` | |
| 570 | + | |
| 571 | +Result: | |
| 572 | + | |
| 573 | +- same behavior, but one typed root config becomes available | |
| 574 | + | |
| 575 | +### Phase 2: remove duplicate readers | |
| 576 | + | |
| 577 | +- make `services_config.py` a thin adapter over `get_app_config()` | |
| 578 | +- make `tenant_config_loader.py` read from `get_app_config()` | |
| 579 | +- stop reparsing YAML in `services_config.py` | |
| 580 | +- stop service modules from depending on legacy local config modules for behavior | |
| 581 | + | |
| 582 | +Result: | |
| 583 | + | |
| 584 | +- one parsing path | |
| 585 | +- fewer divergence risks | |
| 586 | + | |
| 587 | +### Phase 3: move hidden defaults out of business logic | |
| 588 | + | |
| 589 | +- remove hardcoded fallback language lists from query/indexer modules | |
| 590 | +- require tenant defaults to come from config schema only | |
| 591 | +- remove duplicate behavior defaults from service code | |
| 592 | + | |
| 593 | +Result: | |
| 594 | + | |
| 595 | +- behavior becomes visible and reviewable | |
| 596 | + | |
| 597 | +### Phase 4: clean service startup configuration | |
| 598 | + | |
| 599 | +- make startup scripts ask the unified loader for resolved values | |
| 600 | +- keep only bind host/port and secret injection in shell env | |
| 601 | +- retire or reduce `embeddings/config.py` and `reranker/config.py` | |
| 602 | + | |
| 603 | +Result: | |
| 604 | + | |
| 605 | +- startup behavior matches runtime config model | |
| 606 | + | |
| 607 | +### Phase 5: split config files by responsibility | |
| 608 | + | |
| 609 | +- keep a single root loader | |
| 610 | +- split current giant `config.yaml` into: | |
| 611 | + - `base.yaml` | |
| 612 | + - `environments/<env>.yaml` | |
| 613 | + - `tenants/*.yaml` | |
| 614 | + - `dictionaries/query_rewrite.dict` | |
| 615 | + | |
| 616 | +Result: | |
| 617 | + | |
| 618 | +- config remains unified logically, but is easier to read and maintain physically | |
| 619 | + | |
| 620 | +### Phase 6: deprecate legacy compatibility | |
| 621 | + | |
| 622 | +- deprecate `translate_to_en` and `translate_to_zh` | |
| 623 | +- deprecate env-based backend/provider selection except for explicitly approved keys | |
| 624 | +- remove old code paths after one or two release cycles | |
| 625 | + | |
| 626 | +Result: | |
| 627 | + | |
| 628 | +- the system becomes simpler instead of carrying two generations forever | |
| 629 | + | |
| 630 | +## 7. Concrete Rules To Adopt | |
| 631 | + | |
| 632 | +These rules should be documented and enforced in code review. | |
| 633 | + | |
| 634 | +### Rule 1 | |
| 635 | + | |
| 636 | +Only `config/loader.py` may load config files or `.env`. | |
| 637 | + | |
| 638 | +### Rule 2 | |
| 639 | + | |
| 640 | +Only `config/loader.py` may read `os.getenv()` for application config. | |
| 641 | + | |
| 642 | +### Rule 3 | |
| 643 | + | |
| 644 | +Business modules receive typed config objects and do not read files or env directly. | |
| 645 | + | |
| 646 | +### Rule 4 | |
| 647 | + | |
| 648 | +Each config key has one owner. | |
| 649 | + | |
| 650 | +Examples: | |
| 651 | + | |
| 652 | +- `search.query.knn_boost` belongs to search behavior config | |
| 653 | +- `services.embedding.backend` belongs to service implementation config | |
| 654 | +- `infrastructure.redis.password` belongs to env/secrets | |
| 655 | + | |
| 656 | +### Rule 5 | |
| 657 | + | |
| 658 | +Every fallback must be either: | |
| 659 | + | |
| 660 | +- declared in schema defaults, or | |
| 661 | +- rejected at startup | |
| 662 | + | |
| 663 | +No hidden fallback in runtime logic. | |
| 664 | + | |
| 665 | +### Rule 6 | |
| 666 | + | |
| 667 | +Every configuration asset must be visible in one of these places only: | |
| 668 | + | |
| 669 | +- config file | |
| 670 | +- env var | |
| 671 | +- generated runtime metadata | |
| 672 | + | |
| 673 | +Not inside parser code as an implicit constant. | |
| 674 | + | |
| 675 | +## 8. Recommended Naming Conventions | |
| 676 | + | |
| 677 | +Suggested conventions: | |
| 678 | + | |
| 679 | +- config keys use noun-based hierarchical names | |
| 680 | +- avoid mixing transport and implementation concepts in one field | |
| 681 | +- use `endpoint` for caller-facing addresses | |
| 682 | +- use `backend` for service-internal implementation choice | |
| 683 | +- use `enabled` only for true feature toggles | |
| 684 | +- use `default_*` only when a real selection happens at runtime | |
| 685 | + | |
| 686 | +Examples: | |
| 687 | + | |
| 688 | +- good: `services.rerank.endpoint` | |
| 689 | +- good: `services.rerank.backend` | |
| 690 | +- good: `tenants.default.index_languages` | |
| 691 | +- avoid: `service_url`, `base_url`, `provider`, `backend`, and script env all meaning slightly different things without a common model | |
| 692 | + | |
| 693 | +## 9. Highest-Priority Cleanup Items | |
| 694 | + | |
| 695 | +If the team wants the shortest path to improvement, start here: | |
| 696 | + | |
| 697 | +1. build one root `AppConfig` | |
| 698 | +2. make `services_config.py` stop reparsing YAML | |
| 699 | +3. declare rewrite dictionary path explicitly and fix the current mismatch | |
| 700 | +4. remove hardcoded `["en", "zh"]` fallbacks from query/indexer logic | |
| 701 | +5. replace `/admin/config` with an effective-config endpoint | |
| 702 | +6. retire `embeddings/config.py` and `reranker/config.py` as behavior sources | |
| 703 | +7. deprecate legacy tenant translation flags | |
| 704 | + | |
| 705 | +## 10. Expected Outcome | |
| 706 | + | |
| 707 | +After the redesign: | |
| 708 | + | |
| 709 | +- developers can answer โwhere does this setting come from?โ in one step | |
| 710 | +- operators can see effective config without reading source code | |
| 711 | +- backend, indexer, translator, embedding, and reranker all share one model | |
| 712 | +- tenant behavior is explicit instead of partially implicit | |
| 713 | +- migration becomes safer because defaults and precedence are centralized | |
| 714 | +- adding a new provider/backend becomes configuration extension, not configuration archaeology | |
| 715 | + | |
| 716 | +## 11. Summary | |
| 717 | + | |
| 718 | +The current system has the right intent but not yet the right implementation shape. | |
| 719 | + | |
| 720 | +Today the main problems are: | |
| 721 | + | |
| 722 | +- duplicate config loaders | |
| 723 | +- inconsistent precedence | |
| 724 | +- duplicated defaults | |
| 725 | +- config hidden in runtime logic | |
| 726 | +- weak effective-config visibility | |
| 727 | +- leftover legacy concepts | |
| 728 | + | |
| 729 | +The recommended direction is: | |
| 730 | + | |
| 731 | +- one root typed config | |
| 732 | +- one loader pipeline | |
| 733 | +- explicit layered sources | |
| 734 | +- narrow env responsibility | |
| 735 | +- no hidden business fallbacks | |
| 736 | +- observable effective config | |
| 737 | + | |
| 738 | +That design is practical to implement incrementally in this repository and aligns well with the project's multi-tenant, multi-service, provider/backend-based architecture. | ... | ... |
embeddings/config.py
| 1 | -""" | |
| 2 | -Embedding module configuration. | |
| 1 | +"""Embedding service compatibility config derived from unified app config.""" | |
| 3 | 2 | |
| 4 | -This module is intentionally a plain Python file (no env var parsing, no extra deps). | |
| 5 | -Edit values here to configure: | |
| 6 | -- server host/port | |
| 7 | -- local model settings (paths/devices/batch sizes) | |
| 8 | -""" | |
| 3 | +from __future__ import annotations | |
| 9 | 4 | |
| 10 | 5 | from typing import Optional |
| 11 | -import os | |
| 6 | + | |
| 7 | +from config.loader import get_app_config | |
| 12 | 8 | |
| 13 | 9 | |
| 14 | 10 | class EmbeddingConfig(object): |
| 15 | - # Server | |
| 16 | - HOST = os.getenv("EMBEDDING_HOST", "0.0.0.0") | |
| 17 | - PORT = int(os.getenv("EMBEDDING_PORT", 6005)) | |
| 18 | - | |
| 19 | - # Text backend defaults | |
| 20 | - TEXT_MODEL_ID = os.getenv("TEXT_MODEL_ID", "Qwen/Qwen3-Embedding-0.6B") | |
| 21 | - # Keep TEXT_MODEL_DIR as an alias so code can refer to one canonical text model value. | |
| 22 | - TEXT_MODEL_DIR = TEXT_MODEL_ID | |
| 23 | - TEXT_DEVICE = os.getenv("TEXT_DEVICE", "cuda") # "cuda" or "cpu" | |
| 24 | - TEXT_BATCH_SIZE = int(os.getenv("TEXT_BATCH_SIZE", "32")) | |
| 25 | - TEXT_NORMALIZE_EMBEDDINGS = os.getenv("TEXT_NORMALIZE_EMBEDDINGS", "true").lower() in ("1", "true", "yes") | |
| 26 | - TEI_BASE_URL = os.getenv("TEI_BASE_URL", "http://127.0.0.1:8080") | |
| 27 | - TEI_TIMEOUT_SEC = int(os.getenv("TEI_TIMEOUT_SEC", "60")) | |
| 28 | - | |
| 29 | - # Image embeddings | |
| 30 | - # Option A: clip-as-service (Jina CLIP server, recommended) | |
| 31 | - USE_CLIP_AS_SERVICE = os.getenv("USE_CLIP_AS_SERVICE", "true").lower() in ("1", "true", "yes") | |
| 32 | - CLIP_AS_SERVICE_SERVER = os.getenv("CLIP_AS_SERVICE_SERVER", "grpc://127.0.0.1:51000") | |
| 33 | - CLIP_AS_SERVICE_MODEL_NAME = os.getenv("CLIP_AS_SERVICE_MODEL_NAME", "CN-CLIP/ViT-L-14") | |
| 34 | - | |
| 35 | - # Option B: local CN-CLIP (when USE_CLIP_AS_SERVICE=false) | |
| 36 | - IMAGE_MODEL_NAME = os.getenv("IMAGE_MODEL_NAME", "ViT-L-14") | |
| 37 | - IMAGE_DEVICE = None # type: Optional[str] # "cuda" / "cpu" / None(auto) | |
| 38 | - | |
| 39 | - # Service behavior | |
| 40 | - IMAGE_BATCH_SIZE = 8 | |
| 41 | - IMAGE_NORMALIZE_EMBEDDINGS = os.getenv("IMAGE_NORMALIZE_EMBEDDINGS", "true").lower() in ("1", "true", "yes") | |
| 11 | + def __init__(self) -> None: | |
| 12 | + app_config = get_app_config() | |
| 13 | + runtime = app_config.runtime | |
| 14 | + services = app_config.services.embedding | |
| 15 | + text_backend = services.get_backend_config() | |
| 16 | + image_backend = services.get_image_backend_config() | |
| 17 | + | |
| 18 | + self.HOST = runtime.embedding_host | |
| 19 | + self.PORT = runtime.embedding_port | |
| 20 | + | |
| 21 | + self.TEXT_MODEL_ID = str(text_backend.get("model_id") or "Qwen/Qwen3-Embedding-0.6B") | |
| 22 | + self.TEXT_MODEL_DIR = self.TEXT_MODEL_ID | |
| 23 | + self.TEXT_DEVICE = str(text_backend.get("device") or "cuda") | |
| 24 | + self.TEXT_BATCH_SIZE = int(text_backend.get("batch_size", 32)) | |
| 25 | + self.TEXT_NORMALIZE_EMBEDDINGS = bool(text_backend.get("normalize_embeddings", True)) | |
| 26 | + self.TEI_BASE_URL = str(text_backend.get("base_url") or "http://127.0.0.1:8080") | |
| 27 | + self.TEI_TIMEOUT_SEC = int(text_backend.get("timeout_sec", 60)) | |
| 28 | + | |
| 29 | + self.USE_CLIP_AS_SERVICE = services.image_backend == "clip_as_service" | |
| 30 | + self.CLIP_AS_SERVICE_SERVER = str(image_backend.get("server") or "grpc://127.0.0.1:51000") | |
| 31 | + self.CLIP_AS_SERVICE_MODEL_NAME = str(image_backend.get("model_name") or "CN-CLIP/ViT-L-14") | |
| 32 | + | |
| 33 | + self.IMAGE_MODEL_NAME = str(image_backend.get("model_name") or "ViT-L-14") | |
| 34 | + self.IMAGE_DEVICE = image_backend.get("device") # type: Optional[str] | |
| 35 | + self.IMAGE_BATCH_SIZE = int(image_backend.get("batch_size", 8)) | |
| 36 | + self.IMAGE_NORMALIZE_EMBEDDINGS = bool(image_backend.get("normalize_embeddings", True)) | |
| 42 | 37 | |
| 43 | 38 | |
| 44 | 39 | CONFIG = EmbeddingConfig() | ... | ... |
embeddings/image_encoder.py
| ... | ... | @@ -9,8 +9,8 @@ from PIL import Image |
| 9 | 9 | |
| 10 | 10 | logger = logging.getLogger(__name__) |
| 11 | 11 | |
| 12 | +from config.loader import get_app_config | |
| 12 | 13 | from config.services_config import get_embedding_image_base_url |
| 13 | -from config.env_config import REDIS_CONFIG | |
| 14 | 14 | from embeddings.cache_keys import build_image_cache_key |
| 15 | 15 | from embeddings.redis_embedding_cache import RedisEmbeddingCache |
| 16 | 16 | |
| ... | ... | @@ -24,10 +24,11 @@ class CLIPImageEncoder: |
| 24 | 24 | |
| 25 | 25 | def __init__(self, service_url: Optional[str] = None): |
| 26 | 26 | resolved_url = service_url or get_embedding_image_base_url() |
| 27 | + redis_config = get_app_config().infrastructure.redis | |
| 27 | 28 | self.service_url = str(resolved_url).rstrip("/") |
| 28 | 29 | self.endpoint = f"{self.service_url}/embed/image" |
| 29 | 30 | # Reuse embedding cache prefix, but separate namespace for images to avoid collisions. |
| 30 | - self.cache_prefix = str(REDIS_CONFIG.get("embedding_cache_prefix", "embedding")).strip() or "embedding" | |
| 31 | + self.cache_prefix = str(redis_config.embedding_cache_prefix).strip() or "embedding" | |
| 31 | 32 | logger.info("Creating CLIPImageEncoder instance with service URL: %s", self.service_url) |
| 32 | 33 | self.cache = RedisEmbeddingCache( |
| 33 | 34 | key_prefix=self.cache_prefix, | ... | ... |
embeddings/redis_embedding_cache.py
| ... | ... | @@ -20,7 +20,7 @@ try: |
| 20 | 20 | except ImportError: # pragma: no cover - runtime fallback for minimal envs |
| 21 | 21 | redis = None # type: ignore[assignment] |
| 22 | 22 | |
| 23 | -from config.env_config import REDIS_CONFIG | |
| 23 | +from config.loader import get_app_config | |
| 24 | 24 | from embeddings.bf16 import decode_embedding_from_redis, encode_embedding_for_redis |
| 25 | 25 | |
| 26 | 26 | logger = logging.getLogger(__name__) |
| ... | ... | @@ -37,7 +37,8 @@ class RedisEmbeddingCache: |
| 37 | 37 | ): |
| 38 | 38 | self.key_prefix = (key_prefix or "").strip() or "embedding" |
| 39 | 39 | self.namespace = (namespace or "").strip() |
| 40 | - self.expire_time = expire_time or timedelta(days=REDIS_CONFIG.get("cache_expire_days", 180)) | |
| 40 | + redis_config = get_app_config().infrastructure.redis | |
| 41 | + self.expire_time = expire_time or timedelta(days=redis_config.cache_expire_days) | |
| 41 | 42 | |
| 42 | 43 | if redis_client is not None: |
| 43 | 44 | self.redis_client = redis_client |
| ... | ... | @@ -50,13 +51,13 @@ class RedisEmbeddingCache: |
| 50 | 51 | |
| 51 | 52 | try: |
| 52 | 53 | client = redis.Redis( |
| 53 | - host=REDIS_CONFIG.get("host", "localhost"), | |
| 54 | - port=REDIS_CONFIG.get("port", 6479), | |
| 55 | - password=REDIS_CONFIG.get("password"), | |
| 54 | + host=redis_config.host, | |
| 55 | + port=redis_config.port, | |
| 56 | + password=redis_config.password, | |
| 56 | 57 | decode_responses=False, |
| 57 | - socket_timeout=REDIS_CONFIG.get("socket_timeout", 1), | |
| 58 | - socket_connect_timeout=REDIS_CONFIG.get("socket_connect_timeout", 1), | |
| 59 | - retry_on_timeout=REDIS_CONFIG.get("retry_on_timeout", False), | |
| 58 | + socket_timeout=redis_config.socket_timeout, | |
| 59 | + socket_connect_timeout=redis_config.socket_connect_timeout, | |
| 60 | + retry_on_timeout=redis_config.retry_on_timeout, | |
| 60 | 61 | health_check_interval=10, |
| 61 | 62 | ) |
| 62 | 63 | client.ping() | ... | ... |
embeddings/server.py
| ... | ... | @@ -470,16 +470,8 @@ def load_models(): |
| 470 | 470 | if backend_name == "tei": |
| 471 | 471 | from embeddings.text_embedding_tei import TEITextModel |
| 472 | 472 | |
| 473 | - base_url = ( | |
| 474 | - os.getenv("TEI_BASE_URL") | |
| 475 | - or backend_cfg.get("base_url") | |
| 476 | - or CONFIG.TEI_BASE_URL | |
| 477 | - ) | |
| 478 | - timeout_sec = int( | |
| 479 | - os.getenv("TEI_TIMEOUT_SEC") | |
| 480 | - or backend_cfg.get("timeout_sec") | |
| 481 | - or CONFIG.TEI_TIMEOUT_SEC | |
| 482 | - ) | |
| 473 | + base_url = backend_cfg.get("base_url") or CONFIG.TEI_BASE_URL | |
| 474 | + timeout_sec = int(backend_cfg.get("timeout_sec") or CONFIG.TEI_TIMEOUT_SEC) | |
| 483 | 475 | logger.info("Loading text backend: tei (base_url=%s)", base_url) |
| 484 | 476 | _text_model = TEITextModel( |
| 485 | 477 | base_url=str(base_url), |
| ... | ... | @@ -488,11 +480,7 @@ def load_models(): |
| 488 | 480 | elif backend_name == "local_st": |
| 489 | 481 | from embeddings.text_embedding_sentence_transformers import Qwen3TextModel |
| 490 | 482 | |
| 491 | - model_id = ( | |
| 492 | - os.getenv("TEXT_MODEL_ID") | |
| 493 | - or backend_cfg.get("model_id") | |
| 494 | - or CONFIG.TEXT_MODEL_ID | |
| 495 | - ) | |
| 483 | + model_id = backend_cfg.get("model_id") or CONFIG.TEXT_MODEL_ID | |
| 496 | 484 | logger.info("Loading text backend: local_st (model=%s)", model_id) |
| 497 | 485 | _text_model = Qwen3TextModel(model_id=str(model_id)) |
| 498 | 486 | _start_text_batch_worker() | ... | ... |
embeddings/text_encoder.py
| ... | ... | @@ -9,13 +9,11 @@ import requests |
| 9 | 9 | |
| 10 | 10 | logger = logging.getLogger(__name__) |
| 11 | 11 | |
| 12 | +from config.loader import get_app_config | |
| 12 | 13 | from config.services_config import get_embedding_text_base_url |
| 13 | 14 | from embeddings.cache_keys import build_text_cache_key |
| 14 | 15 | from embeddings.redis_embedding_cache import RedisEmbeddingCache |
| 15 | 16 | |
| 16 | -# Try to import REDIS_CONFIG, but allow import to fail | |
| 17 | -from config.env_config import REDIS_CONFIG | |
| 18 | - | |
| 19 | 17 | |
| 20 | 18 | class TextEmbeddingEncoder: |
| 21 | 19 | """ |
| ... | ... | @@ -24,10 +22,11 @@ class TextEmbeddingEncoder: |
| 24 | 22 | |
| 25 | 23 | def __init__(self, service_url: Optional[str] = None): |
| 26 | 24 | resolved_url = service_url or get_embedding_text_base_url() |
| 25 | + redis_config = get_app_config().infrastructure.redis | |
| 27 | 26 | self.service_url = str(resolved_url).rstrip("/") |
| 28 | 27 | self.endpoint = f"{self.service_url}/embed/text" |
| 29 | - self.expire_time = timedelta(days=REDIS_CONFIG.get("cache_expire_days", 180)) | |
| 30 | - self.cache_prefix = str(REDIS_CONFIG.get("embedding_cache_prefix", "embedding")).strip() or "embedding" | |
| 28 | + self.expire_time = timedelta(days=redis_config.cache_expire_days) | |
| 29 | + self.cache_prefix = str(redis_config.embedding_cache_prefix).strip() or "embedding" | |
| 31 | 30 | logger.info("Creating TextEmbeddingEncoder instance with service URL: %s", self.service_url) |
| 32 | 31 | |
| 33 | 32 | self.cache = RedisEmbeddingCache( | ... | ... |
indexer/document_transformer.py
indexer/incremental_service.py
| ... | ... | @@ -13,7 +13,7 @@ from indexer.mapping_generator import get_tenant_index_name |
| 13 | 13 | from indexer.indexer_logger import ( |
| 14 | 14 | get_indexer_logger, log_index_request, log_index_result, log_spu_processing |
| 15 | 15 | ) |
| 16 | -from config import ConfigLoader | |
| 16 | +from config import get_app_config | |
| 17 | 17 | from translation import create_translation_client |
| 18 | 18 | |
| 19 | 19 | # Configure logger |
| ... | ... | @@ -51,7 +51,7 @@ class IncrementalIndexerService: |
| 51 | 51 | |
| 52 | 52 | def _eager_init(self) -> None: |
| 53 | 53 | """Strict eager initialization. Any dependency failure should fail fast.""" |
| 54 | - self._config = ConfigLoader("config/config.yaml").load_config() | |
| 54 | + self._config = get_app_config().search | |
| 55 | 55 | self._searchable_option_dimensions = ( |
| 56 | 56 | getattr(self._config.spu_config, "searchable_option_dimensions", None) |
| 57 | 57 | or ["option1", "option2", "option3"] | ... | ... |
indexer/indexing_utils.py
| ... | ... | @@ -7,7 +7,7 @@ |
| 7 | 7 | import logging |
| 8 | 8 | from typing import Dict, Any, Optional |
| 9 | 9 | from sqlalchemy import Engine, text |
| 10 | -from config import ConfigLoader | |
| 10 | +from config import get_app_config | |
| 11 | 11 | from config.tenant_config_loader import get_tenant_config_loader |
| 12 | 12 | from indexer.document_transformer import SPUDocumentTransformer |
| 13 | 13 | from translation import create_translation_client |
| ... | ... | @@ -92,8 +92,7 @@ def create_document_transformer( |
| 92 | 92 | or config is None |
| 93 | 93 | ): |
| 94 | 94 | if config is None: |
| 95 | - config_loader = ConfigLoader() | |
| 96 | - config = config_loader.load_config() | |
| 95 | + config = get_app_config().search | |
| 97 | 96 | |
| 98 | 97 | if searchable_option_dimensions is None: |
| 99 | 98 | searchable_option_dimensions = config.spu_config.searchable_option_dimensions | ... | ... |
indexer/mapping_generator.py
| ... | ... | @@ -9,7 +9,7 @@ import json |
| 9 | 9 | import logging |
| 10 | 10 | from pathlib import Path |
| 11 | 11 | |
| 12 | -from config.env_config import ES_INDEX_NAMESPACE | |
| 12 | +from config.loader import get_app_config | |
| 13 | 13 | |
| 14 | 14 | logger = logging.getLogger(__name__) |
| 15 | 15 | |
| ... | ... | @@ -30,7 +30,7 @@ def get_tenant_index_name(tenant_id: str) -> str: |
| 30 | 30 | ๅ ถไธญ ES_INDEX_NAMESPACE ็ฑ config.env_config.ES_INDEX_NAMESPACE ๆงๅถ๏ผ |
| 31 | 31 | ็จไบๅบๅ prod/uat/test ็ญไธๅ่ฟ่ก็ฏๅขใ |
| 32 | 32 | """ |
| 33 | - prefix = ES_INDEX_NAMESPACE or "" | |
| 33 | + prefix = get_app_config().runtime.index_namespace or "" | |
| 34 | 34 | return f"{prefix}search_products_tenant_{tenant_id}" |
| 35 | 35 | |
| 36 | 36 | ... | ... |
indexer/product_enrich.py
| ... | ... | @@ -20,7 +20,7 @@ import redis |
| 20 | 20 | import requests |
| 21 | 21 | from pathlib import Path |
| 22 | 22 | |
| 23 | -from config.env_config import REDIS_CONFIG | |
| 23 | +from config.loader import get_app_config | |
| 24 | 24 | from config.tenant_config_loader import SOURCE_LANG_CODE_MAP |
| 25 | 25 | from indexer.product_enrich_prompts import ( |
| 26 | 26 | SYSTEM_MESSAGE, |
| ... | ... | @@ -91,19 +91,20 @@ logger.info("Verbose LLM logs are written to: %s", verbose_log_file) |
| 91 | 91 | |
| 92 | 92 | |
| 93 | 93 | # Redis ็ผๅญ๏ผ็จไบ anchors / ่ฏญไนๅฑๆง๏ผ |
| 94 | -ANCHOR_CACHE_PREFIX = REDIS_CONFIG.get("anchor_cache_prefix", "product_anchors") | |
| 95 | -ANCHOR_CACHE_EXPIRE_DAYS = int(REDIS_CONFIG.get("anchor_cache_expire_days", 30)) | |
| 94 | +_REDIS_CONFIG = get_app_config().infrastructure.redis | |
| 95 | +ANCHOR_CACHE_PREFIX = _REDIS_CONFIG.anchor_cache_prefix | |
| 96 | +ANCHOR_CACHE_EXPIRE_DAYS = int(_REDIS_CONFIG.anchor_cache_expire_days) | |
| 96 | 97 | _anchor_redis: Optional[redis.Redis] = None |
| 97 | 98 | |
| 98 | 99 | try: |
| 99 | 100 | _anchor_redis = redis.Redis( |
| 100 | - host=REDIS_CONFIG.get("host", "localhost"), | |
| 101 | - port=REDIS_CONFIG.get("port", 6479), | |
| 102 | - password=REDIS_CONFIG.get("password"), | |
| 101 | + host=_REDIS_CONFIG.host, | |
| 102 | + port=_REDIS_CONFIG.port, | |
| 103 | + password=_REDIS_CONFIG.password, | |
| 103 | 104 | decode_responses=True, |
| 104 | - socket_timeout=REDIS_CONFIG.get("socket_timeout", 1), | |
| 105 | - socket_connect_timeout=REDIS_CONFIG.get("socket_connect_timeout", 1), | |
| 106 | - retry_on_timeout=REDIS_CONFIG.get("retry_on_timeout", False), | |
| 105 | + socket_timeout=_REDIS_CONFIG.socket_timeout, | |
| 106 | + socket_connect_timeout=_REDIS_CONFIG.socket_connect_timeout, | |
| 107 | + retry_on_timeout=_REDIS_CONFIG.retry_on_timeout, | |
| 107 | 108 | health_check_interval=10, |
| 108 | 109 | ) |
| 109 | 110 | _anchor_redis.ping() | ... | ... |
main.py
| ... | ... | @@ -16,8 +16,7 @@ import json |
| 16 | 16 | # Add parent directory to path |
| 17 | 17 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| 18 | 18 | |
| 19 | -from config import ConfigLoader | |
| 20 | -from config.env_config import ES_CONFIG | |
| 19 | +from config import get_app_config | |
| 21 | 20 | from utils import ESClient |
| 22 | 21 | from search import Searcher |
| 23 | 22 | from suggestion import SuggestionIndexBuilder |
| ... | ... | @@ -61,8 +60,7 @@ def cmd_serve_indexer(args): |
| 61 | 60 | def cmd_search(args): |
| 62 | 61 | """Test search from command line.""" |
| 63 | 62 | # Load config |
| 64 | - config_loader = ConfigLoader("config/config.yaml") | |
| 65 | - config = config_loader.load_config() | |
| 63 | + config = get_app_config().search | |
| 66 | 64 | |
| 67 | 65 | # Initialize ES and searcher |
| 68 | 66 | es_client = ESClient(hosts=[args.es_host]) |
| ... | ... | @@ -106,8 +104,9 @@ def cmd_search(args): |
| 106 | 104 | def cmd_build_suggestions(args): |
| 107 | 105 | """Build/update suggestion index for a tenant.""" |
| 108 | 106 | # Initialize ES client with optional authentication |
| 109 | - es_username = os.getenv("ES_USERNAME") or ES_CONFIG.get("username") | |
| 110 | - es_password = os.getenv("ES_PASSWORD") or ES_CONFIG.get("password") | |
| 107 | + es_cfg = get_app_config().infrastructure.elasticsearch | |
| 108 | + es_username = es_cfg.username | |
| 109 | + es_password = es_cfg.password | |
| 111 | 110 | if es_username and es_password: |
| 112 | 111 | es_client = ESClient(hosts=[args.es_host], username=es_username, password=es_password) |
| 113 | 112 | else: |
| ... | ... | @@ -117,11 +116,12 @@ def cmd_build_suggestions(args): |
| 117 | 116 | return 1 |
| 118 | 117 | |
| 119 | 118 | # Build DB config directly from environment to avoid dotenv dependency |
| 120 | - db_host = os.getenv("DB_HOST") | |
| 121 | - db_port = int(os.getenv("DB_PORT", "3306")) | |
| 122 | - db_name = os.getenv("DB_DATABASE") | |
| 123 | - db_user = os.getenv("DB_USERNAME") | |
| 124 | - db_pass = os.getenv("DB_PASSWORD") | |
| 119 | + db_cfg = get_app_config().infrastructure.database | |
| 120 | + db_host = db_cfg.host | |
| 121 | + db_port = db_cfg.port | |
| 122 | + db_name = db_cfg.database | |
| 123 | + db_user = db_cfg.username | |
| 124 | + db_pass = db_cfg.password | |
| 125 | 125 | if not all([db_host, db_name, db_user, db_pass]): |
| 126 | 126 | print("ERROR: DB_HOST/DB_PORT/DB_DATABASE/DB_USERNAME/DB_PASSWORD must be set in environment") |
| 127 | 127 | return 1 |
| ... | ... | @@ -170,7 +170,7 @@ def main(): |
| 170 | 170 | serve_parser = subparsers.add_parser('serve', help='Start API service (multi-tenant)') |
| 171 | 171 | serve_parser.add_argument('--host', default='0.0.0.0', help='Host to bind to') |
| 172 | 172 | serve_parser.add_argument('--port', type=int, default=6002, help='Port to bind to') |
| 173 | - serve_parser.add_argument('--es-host', default=ES_CONFIG.get('host', 'http://localhost:9200'), help='Elasticsearch host') | |
| 173 | + serve_parser.add_argument('--es-host', default=get_app_config().infrastructure.elasticsearch.host, help='Elasticsearch host') | |
| 174 | 174 | serve_parser.add_argument('--reload', action='store_true', help='Enable auto-reload') |
| 175 | 175 | |
| 176 | 176 | # Serve-indexer command |
| ... | ... | @@ -180,14 +180,14 @@ def main(): |
| 180 | 180 | ) |
| 181 | 181 | serve_indexer_parser.add_argument('--host', default='0.0.0.0', help='Host to bind to') |
| 182 | 182 | serve_indexer_parser.add_argument('--port', type=int, default=6004, help='Port to bind to') |
| 183 | - serve_indexer_parser.add_argument('--es-host', default=ES_CONFIG.get('host', 'http://localhost:9200'), help='Elasticsearch host') | |
| 183 | + serve_indexer_parser.add_argument('--es-host', default=get_app_config().infrastructure.elasticsearch.host, help='Elasticsearch host') | |
| 184 | 184 | serve_indexer_parser.add_argument('--reload', action='store_true', help='Enable auto-reload') |
| 185 | 185 | |
| 186 | 186 | # Search command |
| 187 | 187 | search_parser = subparsers.add_parser('search', help='Test search from command line') |
| 188 | 188 | search_parser.add_argument('query', help='Search query') |
| 189 | 189 | search_parser.add_argument('--tenant-id', required=True, help='Tenant ID (required)') |
| 190 | - search_parser.add_argument('--es-host', default=ES_CONFIG.get('host', 'http://localhost:9200'), help='Elasticsearch host') | |
| 190 | + search_parser.add_argument('--es-host', default=get_app_config().infrastructure.elasticsearch.host, help='Elasticsearch host') | |
| 191 | 191 | search_parser.add_argument('--size', type=int, default=10, help='Number of results') |
| 192 | 192 | search_parser.add_argument('--no-translation', action='store_true', help='Disable translation') |
| 193 | 193 | search_parser.add_argument('--no-embedding', action='store_true', help='Disable embeddings') |
| ... | ... | @@ -199,7 +199,7 @@ def main(): |
| 199 | 199 | help='Build tenant suggestion index (full/incremental)' |
| 200 | 200 | ) |
| 201 | 201 | suggest_build_parser.add_argument('--tenant-id', required=True, help='Tenant ID') |
| 202 | - suggest_build_parser.add_argument('--es-host', default=ES_CONFIG.get('host', 'http://localhost:9200'), help='Elasticsearch host') | |
| 202 | + suggest_build_parser.add_argument('--es-host', default=get_app_config().infrastructure.elasticsearch.host, help='Elasticsearch host') | |
| 203 | 203 | suggest_build_parser.add_argument( |
| 204 | 204 | '--mode', |
| 205 | 205 | choices=['full', 'incremental'], | ... | ... |
query/query_parser.py
| ... | ... | @@ -336,13 +336,13 @@ class QueryParser: |
| 336 | 336 | translations = {} |
| 337 | 337 | translation_futures = {} |
| 338 | 338 | translation_executor = None |
| 339 | - index_langs = ["en", "zh"] | |
| 339 | + index_langs: List[str] = [] | |
| 340 | 340 | try: |
| 341 | 341 | # ๆ นๆฎ็งๆท้ ็ฝฎ็ index_languages ๅณๅฎ็ฟป่ฏ็ฎๆ ่ฏญ่จ |
| 342 | 342 | from config.tenant_config_loader import get_tenant_config_loader |
| 343 | 343 | tenant_loader = get_tenant_config_loader() |
| 344 | 344 | tenant_cfg = tenant_loader.get_tenant_config(tenant_id or "default") |
| 345 | - raw_index_langs = tenant_cfg.get("index_languages") or ["en", "zh"] | |
| 345 | + raw_index_langs = tenant_cfg.get("index_languages") or [] | |
| 346 | 346 | index_langs = [] |
| 347 | 347 | seen_langs = set() |
| 348 | 348 | for lang in raw_index_langs: | ... | ... |
reranker/backends/dashscope_rerank.py
| ... | ... | @@ -63,43 +63,19 @@ class DashScopeRerankBackend: |
| 63 | 63 | - max_retries: int, default 1 |
| 64 | 64 | - retry_backoff_sec: float, default 0.2 |
| 65 | 65 | |
| 66 | - Env overrides: | |
| 67 | - - RERANK_DASHSCOPE_ENDPOINT | |
| 68 | - - RERANK_DASHSCOPE_MODEL | |
| 69 | - - RERANK_DASHSCOPE_TIMEOUT_SEC | |
| 70 | - - RERANK_DASHSCOPE_TOP_N_CAP | |
| 71 | - - RERANK_DASHSCOPE_BATCHSIZE | |
| 72 | 66 | """ |
| 73 | 67 | |
| 74 | 68 | def __init__(self, config: Dict[str, Any]) -> None: |
| 75 | 69 | self._config = config or {} |
| 76 | - self._model_name = str( | |
| 77 | - os.getenv("RERANK_DASHSCOPE_MODEL") | |
| 78 | - or self._config.get("model_name") | |
| 79 | - or "qwen3-rerank" | |
| 80 | - ) | |
| 70 | + self._model_name = str(self._config.get("model_name") or "qwen3-rerank") | |
| 81 | 71 | self._endpoint = str( |
| 82 | - os.getenv("RERANK_DASHSCOPE_ENDPOINT") | |
| 83 | - or self._config.get("endpoint") | |
| 84 | - or "https://dashscope.aliyuncs.com/compatible-api/v1/reranks" | |
| 72 | + self._config.get("endpoint") or "https://dashscope.aliyuncs.com/compatible-api/v1/reranks" | |
| 85 | 73 | ).strip() |
| 86 | 74 | self._api_key_env = str(self._config.get("api_key_env") or "").strip() |
| 87 | 75 | self._api_key = str(os.getenv(self._api_key_env) or "").strip().strip('"').strip("'") |
| 88 | - self._timeout_sec = float( | |
| 89 | - os.getenv("RERANK_DASHSCOPE_TIMEOUT_SEC") | |
| 90 | - or self._config.get("timeout_sec") | |
| 91 | - or 15.0 | |
| 92 | - ) | |
| 93 | - self._top_n_cap = int( | |
| 94 | - os.getenv("RERANK_DASHSCOPE_TOP_N_CAP") | |
| 95 | - or self._config.get("top_n_cap") | |
| 96 | - or 0 | |
| 97 | - ) | |
| 98 | - self._batchsize = int( | |
| 99 | - os.getenv("RERANK_DASHSCOPE_BATCHSIZE") | |
| 100 | - or self._config.get("batchsize") | |
| 101 | - or 0 | |
| 102 | - ) | |
| 76 | + self._timeout_sec = float(self._config.get("timeout_sec") or 15.0) | |
| 77 | + self._top_n_cap = int(self._config.get("top_n_cap") or 0) | |
| 78 | + self._batchsize = int(self._config.get("batchsize") or 0) | |
| 103 | 79 | self._instruct = str(self._config.get("instruct") or "").strip() |
| 104 | 80 | self._max_retries = int(self._config.get("max_retries", 1)) |
| 105 | 81 | self._retry_backoff_sec = float(self._config.get("retry_backoff_sec", 0.2)) | ... | ... |
reranker/config.py
| 1 | -"""Reranker service configuration (simple Python config).""" | |
| 1 | +"""Reranker service compatibility config derived from unified app config.""" | |
| 2 | 2 | |
| 3 | -import os | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from config.loader import get_app_config | |
| 4 | 6 | |
| 5 | 7 | |
| 6 | 8 | class RerankerConfig(object): |
| 7 | - # Server | |
| 8 | - HOST = os.getenv("RERANKER_HOST", "0.0.0.0") | |
| 9 | - PORT = int(os.getenv("RERANKER_PORT", 6007)) | |
| 10 | - | |
| 11 | - # Model | |
| 12 | - MODEL_NAME = "Qwen/Qwen3-Reranker-0.6B" | |
| 13 | - DEVICE = None # None -> auto (cuda if available) | |
| 14 | - USE_FP16 = True | |
| 15 | - BATCH_SIZE = 64 | |
| 16 | - MAX_LENGTH = 512 | |
| 17 | - CACHE_DIR = "./model_cache" | |
| 18 | - ENABLE_WARMUP = True | |
| 19 | - | |
| 20 | - # Request limits | |
| 21 | - MAX_DOCS = 1000 | |
| 22 | - | |
| 23 | - # Output | |
| 24 | - NORMALIZE = True | |
| 9 | + def __init__(self) -> None: | |
| 10 | + app_config = get_app_config() | |
| 11 | + runtime = app_config.runtime | |
| 12 | + service = app_config.services.rerank | |
| 13 | + backend = service.get_backend_config() | |
| 14 | + request = service.request | |
| 15 | + | |
| 16 | + self.HOST = runtime.reranker_host | |
| 17 | + self.PORT = runtime.reranker_port | |
| 18 | + | |
| 19 | + self.MODEL_NAME = str(backend.get("model_name") or "Qwen/Qwen3-Reranker-0.6B") | |
| 20 | + self.DEVICE = backend.get("device") | |
| 21 | + self.USE_FP16 = bool(backend.get("use_fp16", True)) | |
| 22 | + self.BATCH_SIZE = int(backend.get("batch_size", backend.get("infer_batch_size", 64))) | |
| 23 | + self.MAX_LENGTH = int(backend.get("max_length", 512)) | |
| 24 | + self.CACHE_DIR = str(backend.get("cache_dir") or "./model_cache") | |
| 25 | + self.ENABLE_WARMUP = bool(backend.get("enable_warmup", True)) | |
| 26 | + | |
| 27 | + self.MAX_DOCS = int(request.get("max_docs", 1000)) | |
| 28 | + self.NORMALIZE = bool(request.get("normalize", True)) | |
| 25 | 29 | |
| 26 | 30 | |
| 27 | 31 | CONFIG = RerankerConfig() | ... | ... |
suggestion/builder.py
| ... | ... | @@ -18,7 +18,7 @@ from typing import Any, Dict, Iterator, List, Optional, Tuple |
| 18 | 18 | |
| 19 | 19 | from sqlalchemy import text |
| 20 | 20 | |
| 21 | -from config.env_config import ES_INDEX_NAMESPACE | |
| 21 | +from config.loader import get_app_config | |
| 22 | 22 | from config.tenant_config_loader import get_tenant_config_loader |
| 23 | 23 | from suggestion.mapping import build_suggestion_mapping |
| 24 | 24 | from utils.es_client import ESClient |
| ... | ... | @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) |
| 27 | 27 | |
| 28 | 28 | |
| 29 | 29 | def _index_prefix() -> str: |
| 30 | - return ES_INDEX_NAMESPACE or "" | |
| 30 | + return get_app_config().runtime.index_namespace or "" | |
| 31 | 31 | |
| 32 | 32 | |
| 33 | 33 | def get_suggestion_alias_name(tenant_id: str) -> str: | ... | ... |
translation/backends/deepl.py
| ... | ... | @@ -3,7 +3,6 @@ |
| 3 | 3 | from __future__ import annotations |
| 4 | 4 | |
| 5 | 5 | import logging |
| 6 | -import os | |
| 7 | 6 | import re |
| 8 | 7 | from typing import List, Optional, Sequence, Tuple, Union |
| 9 | 8 | |
| ... | ... | @@ -24,7 +23,7 @@ class DeepLTranslationBackend: |
| 24 | 23 | timeout: float, |
| 25 | 24 | glossary_id: Optional[str] = None, |
| 26 | 25 | ) -> None: |
| 27 | - self.api_key = api_key or os.getenv("DEEPL_AUTH_KEY") | |
| 26 | + self.api_key = api_key | |
| 28 | 27 | self.api_url = api_url |
| 29 | 28 | self.timeout = float(timeout) |
| 30 | 29 | self.glossary_id = glossary_id | ... | ... |
translation/backends/llm.py
| ... | ... | @@ -3,13 +3,11 @@ |
| 3 | 3 | from __future__ import annotations |
| 4 | 4 | |
| 5 | 5 | import logging |
| 6 | -import os | |
| 7 | 6 | import time |
| 8 | 7 | from typing import List, Optional, Sequence, Union |
| 9 | 8 | |
| 10 | 9 | from openai import OpenAI |
| 11 | 10 | |
| 12 | -from config.env_config import DASHSCOPE_API_KEY | |
| 13 | 11 | from translation.languages import LANGUAGE_LABELS |
| 14 | 12 | from translation.prompts import TRANSLATION_PROMPTS |
| 15 | 13 | from translation.scenes import normalize_scene_name |
| ... | ... | @@ -52,11 +50,13 @@ class LLMTranslationBackend: |
| 52 | 50 | model: str, |
| 53 | 51 | timeout_sec: float, |
| 54 | 52 | base_url: str, |
| 53 | + api_key: Optional[str], | |
| 55 | 54 | ) -> None: |
| 56 | 55 | self.capability_name = capability_name |
| 57 | 56 | self.model = model |
| 58 | 57 | self.timeout_sec = float(timeout_sec) |
| 59 | 58 | self.base_url = base_url |
| 59 | + self.api_key = api_key | |
| 60 | 60 | self.client = self._create_client() |
| 61 | 61 | |
| 62 | 62 | @property |
| ... | ... | @@ -64,12 +64,11 @@ class LLMTranslationBackend: |
| 64 | 64 | return True |
| 65 | 65 | |
| 66 | 66 | def _create_client(self) -> Optional[OpenAI]: |
| 67 | - api_key = DASHSCOPE_API_KEY or os.getenv("DASHSCOPE_API_KEY") | |
| 68 | - if not api_key: | |
| 67 | + if not self.api_key: | |
| 69 | 68 | logger.warning("DASHSCOPE_API_KEY not set; llm translation unavailable") |
| 70 | 69 | return None |
| 71 | 70 | try: |
| 72 | - return OpenAI(api_key=api_key, base_url=self.base_url) | |
| 71 | + return OpenAI(api_key=self.api_key, base_url=self.base_url) | |
| 73 | 72 | except Exception as exc: |
| 74 | 73 | logger.error("Failed to initialize llm translation client: %s", exc, exc_info=True) |
| 75 | 74 | return None | ... | ... |
translation/backends/qwen_mt.py
| ... | ... | @@ -3,14 +3,12 @@ |
| 3 | 3 | from __future__ import annotations |
| 4 | 4 | |
| 5 | 5 | import logging |
| 6 | -import os | |
| 7 | 6 | import re |
| 8 | 7 | import time |
| 9 | 8 | from typing import List, Optional, Sequence, Union |
| 10 | 9 | |
| 11 | 10 | from openai import OpenAI |
| 12 | 11 | |
| 13 | -from config.env_config import DASHSCOPE_API_KEY | |
| 14 | 12 | from translation.languages import QWEN_LANGUAGE_CODES |
| 15 | 13 | |
| 16 | 14 | logger = logging.getLogger(__name__) |
| ... | ... | @@ -64,7 +62,7 @@ class QwenMTTranslationBackend: |
| 64 | 62 | @staticmethod |
| 65 | 63 | def _default_api_key(model: str) -> Optional[str]: |
| 66 | 64 | del model |
| 67 | - return DASHSCOPE_API_KEY or os.getenv("DASHSCOPE_API_KEY") | |
| 65 | + return None | |
| 68 | 66 | |
| 69 | 67 | def translate( |
| 70 | 68 | self, | ... | ... |
translation/cache.py
| ... | ... | @@ -6,9 +6,12 @@ import hashlib |
| 6 | 6 | import logging |
| 7 | 7 | from typing import Mapping, Optional |
| 8 | 8 | |
| 9 | -import redis | |
| 9 | +try: | |
| 10 | + import redis | |
| 11 | +except ImportError: # pragma: no cover - runtime fallback for minimal envs | |
| 12 | + redis = None # type: ignore[assignment] | |
| 10 | 13 | |
| 11 | -from config.env_config import REDIS_CONFIG | |
| 14 | +from config.loader import get_app_config | |
| 12 | 15 | |
| 13 | 16 | logger = logging.getLogger(__name__) |
| 14 | 17 | |
| ... | ... | @@ -70,15 +73,19 @@ class TranslationCache: |
| 70 | 73 | |
| 71 | 74 | @staticmethod |
| 72 | 75 | def _init_redis_client() -> Optional[redis.Redis]: |
| 76 | + if redis is None: | |
| 77 | + logger.warning("redis package is not installed; translation cache disabled") | |
| 78 | + return None | |
| 79 | + redis_config = get_app_config().infrastructure.redis | |
| 73 | 80 | try: |
| 74 | 81 | client = redis.Redis( |
| 75 | - host=REDIS_CONFIG.get("host", "localhost"), | |
| 76 | - port=REDIS_CONFIG.get("port", 6479), | |
| 77 | - password=REDIS_CONFIG.get("password"), | |
| 82 | + host=redis_config.host, | |
| 83 | + port=redis_config.port, | |
| 84 | + password=redis_config.password, | |
| 78 | 85 | decode_responses=True, |
| 79 | - socket_timeout=REDIS_CONFIG.get("socket_timeout", 1), | |
| 80 | - socket_connect_timeout=REDIS_CONFIG.get("socket_connect_timeout", 1), | |
| 81 | - retry_on_timeout=REDIS_CONFIG.get("retry_on_timeout", False), | |
| 86 | + socket_timeout=redis_config.socket_timeout, | |
| 87 | + socket_connect_timeout=redis_config.socket_connect_timeout, | |
| 88 | + retry_on_timeout=redis_config.retry_on_timeout, | |
| 82 | 89 | health_check_interval=10, |
| 83 | 90 | ) |
| 84 | 91 | client.ping() | ... | ... |
translation/client.py
| ... | ... | @@ -7,7 +7,7 @@ from typing import List, Optional, Sequence, Union |
| 7 | 7 | |
| 8 | 8 | import requests |
| 9 | 9 | |
| 10 | -from config.services_config import get_translation_config | |
| 10 | +from config.loader import get_app_config | |
| 11 | 11 | from translation.settings import normalize_translation_model, normalize_translation_scene |
| 12 | 12 | |
| 13 | 13 | logger = logging.getLogger(__name__) |
| ... | ... | @@ -24,7 +24,7 @@ class TranslationServiceClient: |
| 24 | 24 | default_scene: Optional[str] = None, |
| 25 | 25 | timeout_sec: Optional[float] = None, |
| 26 | 26 | ) -> None: |
| 27 | - cfg = get_translation_config() | |
| 27 | + cfg = get_app_config().services.translation.as_dict() | |
| 28 | 28 | self.base_url = str(base_url or cfg["service_url"]).rstrip("/") |
| 29 | 29 | self.default_model = normalize_translation_model(cfg, default_model or cfg["default_model"]) |
| 30 | 30 | self.default_scene = normalize_translation_scene(cfg, default_scene or cfg["default_scene"]) | ... | ... |
translation/service.py
| ... | ... | @@ -5,7 +5,8 @@ from __future__ import annotations |
| 5 | 5 | import logging |
| 6 | 6 | from typing import Dict, List, Optional |
| 7 | 7 | |
| 8 | -from config.services_config import get_translation_config | |
| 8 | +from config.loader import get_app_config | |
| 9 | +from config.schema import AppConfig | |
| 9 | 10 | from translation.cache import TranslationCache |
| 10 | 11 | from translation.protocols import TranslateInput, TranslateOutput, TranslationBackendProtocol |
| 11 | 12 | from translation.settings import ( |
| ... | ... | @@ -22,8 +23,9 @@ logger = logging.getLogger(__name__) |
| 22 | 23 | class TranslationService: |
| 23 | 24 | """Owns translation backends and routes calls by model and scene.""" |
| 24 | 25 | |
| 25 | - def __init__(self, config: Optional[TranslationConfig] = None) -> None: | |
| 26 | - self.config = config or get_translation_config() | |
| 26 | + def __init__(self, config: Optional[TranslationConfig] = None, app_config: Optional[AppConfig] = None) -> None: | |
| 27 | + self._app_config = app_config or get_app_config() | |
| 28 | + self.config = config or self._app_config.services.translation.as_dict() | |
| 27 | 29 | self._enabled_capabilities = self._collect_enabled_capabilities() |
| 28 | 30 | if not self._enabled_capabilities: |
| 29 | 31 | raise ValueError("No enabled translation backends found in services.translation.capabilities") |
| ... | ... | @@ -85,7 +87,7 @@ class TranslationService: |
| 85 | 87 | capability_name=name, |
| 86 | 88 | model=str(cfg["model"]).strip(), |
| 87 | 89 | base_url=str(cfg["base_url"]).strip(), |
| 88 | - api_key=cfg.get("api_key"), | |
| 90 | + api_key=self._app_config.infrastructure.secrets.dashscope_api_key, | |
| 89 | 91 | timeout=int(cfg["timeout_sec"]), |
| 90 | 92 | glossary_id=cfg.get("glossary_id"), |
| 91 | 93 | ) |
| ... | ... | @@ -94,7 +96,7 @@ class TranslationService: |
| 94 | 96 | from translation.backends.deepl import DeepLTranslationBackend |
| 95 | 97 | |
| 96 | 98 | return DeepLTranslationBackend( |
| 97 | - api_key=cfg.get("api_key"), | |
| 99 | + api_key=self._app_config.infrastructure.secrets.deepl_auth_key, | |
| 98 | 100 | api_url=str(cfg["api_url"]).strip(), |
| 99 | 101 | timeout=float(cfg["timeout_sec"]), |
| 100 | 102 | glossary_id=cfg.get("glossary_id"), |
| ... | ... | @@ -108,6 +110,7 @@ class TranslationService: |
| 108 | 110 | model=str(cfg["model"]).strip(), |
| 109 | 111 | timeout_sec=float(cfg["timeout_sec"]), |
| 110 | 112 | base_url=str(cfg["base_url"]).strip(), |
| 113 | + api_key=self._app_config.infrastructure.secrets.dashscope_api_key, | |
| 111 | 114 | ) |
| 112 | 115 | |
| 113 | 116 | def _create_local_nllb_backend(self, *, name: str, cfg: Dict[str, object]) -> TranslationBackendProtocol: | ... | ... |
utils/es_client.py
| ... | ... | @@ -5,10 +5,9 @@ Elasticsearch client wrapper. |
| 5 | 5 | from elasticsearch import Elasticsearch |
| 6 | 6 | from elasticsearch.helpers import bulk |
| 7 | 7 | from typing import Dict, Any, List, Optional |
| 8 | -import os | |
| 9 | 8 | import logging |
| 10 | 9 | |
| 11 | -from config.env_config import ES_CONFIG | |
| 10 | +from config.loader import get_app_config | |
| 12 | 11 | |
| 13 | 12 | logger = logging.getLogger(__name__) |
| 14 | 13 | |
| ... | ... | @@ -33,7 +32,7 @@ class ESClient: |
| 33 | 32 | **kwargs: Additional ES client parameters |
| 34 | 33 | """ |
| 35 | 34 | if hosts is None: |
| 36 | - hosts = [os.getenv('ES_HOST', 'http://localhost:9200')] | |
| 35 | + hosts = [get_app_config().infrastructure.elasticsearch.host] | |
| 37 | 36 | |
| 38 | 37 | # Build client config |
| 39 | 38 | client_config = { |
| ... | ... | @@ -325,16 +324,9 @@ def get_es_client_from_env() -> ESClient: |
| 325 | 324 | Returns: |
| 326 | 325 | ESClient instance |
| 327 | 326 | """ |
| 328 | - if ES_CONFIG: | |
| 329 | - return ESClient( | |
| 330 | - hosts=[ES_CONFIG['host']], | |
| 331 | - username=ES_CONFIG.get('username'), | |
| 332 | - password=ES_CONFIG.get('password') | |
| 333 | - ) | |
| 334 | - else: | |
| 335 | - # Fallback to env variables | |
| 336 | - return ESClient( | |
| 337 | - hosts=[os.getenv('ES_HOST', 'http://localhost:9200')], | |
| 338 | - username=os.getenv('ES_USERNAME'), | |
| 339 | - password=os.getenv('ES_PASSWORD') | |
| 340 | - ) | |
| 327 | + cfg = get_app_config().infrastructure.elasticsearch | |
| 328 | + return ESClient( | |
| 329 | + hosts=[cfg.host], | |
| 330 | + username=cfg.username, | |
| 331 | + password=cfg.password, | |
| 332 | + ) | ... | ... |