Files
microfish/backend/app/utils/language_policy.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
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.
2026-08-31 13:05:21 +07:00

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