Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation: - Local memory backend (Zep-compatible): memory services/models, local graph builder + updater, AgentActivity seam, import-boundary isolation; Zep stays default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven. - Durable product persistence: projects/simulations/reports schema (migration 0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project read-first + ArtifactStore abstraction; durable JobQueue + worker.py. - SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM accounting), redacted AuditService, idempotency, CORS allowlist, safe API errors, single-use PasswordResetService + endpoints (covers invite-pending). - Exactly 3 roles (super_admin/admin/user) with tenant authz policy. - Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with admin/super-admin route guards, th/en i18n. - Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep. Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap). No commit of credentials; secrets handled via env/.env.example. Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel container build of deploy topology.
83 lines
2.8 KiB
Python
83 lines
2.8 KiB
Python
"""Language policy shared by API locale negotiation and background jobs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Iterable
|
|
|
|
SUPPORTED_LOCALES = ("th", "en")
|
|
DEFAULT_LOCALE = "th"
|
|
|
|
# Legacy values are intentionally mapped to the default locale so an old
|
|
# browser preference cannot re-enable an unsupported product language.
|
|
_LEGACY_LOCALE_ALIASES = {
|
|
"zh": DEFAULT_LOCALE,
|
|
"zh-cn": DEFAULT_LOCALE,
|
|
"zh-tw": DEFAULT_LOCALE,
|
|
}
|
|
|
|
|
|
def normalize_locale(value: object, default: str = DEFAULT_LOCALE) -> str:
|
|
"""Return one supported base locale, failing closed to ``default``."""
|
|
safe_default = default if default in SUPPORTED_LOCALES else DEFAULT_LOCALE
|
|
if not isinstance(value, str):
|
|
return safe_default
|
|
|
|
normalized = value.strip().replace("_", "-").lower()
|
|
if not normalized:
|
|
return safe_default
|
|
if normalized in _LEGACY_LOCALE_ALIASES:
|
|
return _LEGACY_LOCALE_ALIASES[normalized]
|
|
if normalized in SUPPORTED_LOCALES:
|
|
return normalized
|
|
|
|
base_locale = normalized.split("-", 1)[0]
|
|
if base_locale in SUPPORTED_LOCALES:
|
|
return base_locale
|
|
if base_locale in _LEGACY_LOCALE_ALIASES:
|
|
return _LEGACY_LOCALE_ALIASES[base_locale]
|
|
return safe_default
|
|
|
|
|
|
def locale_from_accept_language(header: object) -> str:
|
|
"""Choose the best supported locale from an HTTP Accept-Language value."""
|
|
if not isinstance(header, str) or not header.strip():
|
|
return DEFAULT_LOCALE
|
|
|
|
candidates: list[tuple[float, int, str]] = []
|
|
for position, raw_item in enumerate(header.split(",")):
|
|
parts = [part.strip() for part in raw_item.split(";")]
|
|
language = parts[0]
|
|
quality = 1.0
|
|
for parameter in parts[1:]:
|
|
key, separator, value = parameter.partition("=")
|
|
if key.strip().lower() != "q" or not separator:
|
|
continue
|
|
try:
|
|
quality = float(value.strip())
|
|
except ValueError:
|
|
quality = 0.0
|
|
break
|
|
|
|
if quality <= 0:
|
|
continue
|
|
normalized_language = language.strip().replace("_", "-").lower()
|
|
base_locale = normalized_language.split("-", 1)[0]
|
|
if base_locale in SUPPORTED_LOCALES:
|
|
# Higher q wins; original order breaks ties.
|
|
candidates.append((quality, -position, base_locale))
|
|
|
|
if not candidates:
|
|
return DEFAULT_LOCALE
|
|
candidates.sort(reverse=True)
|
|
return candidates[0][2]
|
|
|
|
|
|
def is_supported_locale(value: object) -> bool:
|
|
"""Return whether ``value`` is already a canonical supported locale."""
|
|
return isinstance(value, str) and value in SUPPORTED_LOCALES
|
|
|
|
|
|
def supported_locales() -> Iterable[str]:
|
|
"""Expose supported locales without allowing callers to mutate the tuple."""
|
|
return SUPPORTED_LOCALES
|