Spaces:
Running
Running
| from fastapi import FastAPI, HTTPException, UploadFile | |
| from pydantic import BaseModel, ConfigDict, field_validator, model_validator | |
| from PIL import Image | |
| # PIL decompression bomb cap——與 paste_parser 一致 40M pixels(~200MP 才算 bomb)。 | |
| # 主入口 `/embed` + `/ocr/label` 都走 `Image.open(file.file)` 接 user upload,沒 cap | |
| # 的話 4 KB deflated PNG 可宣告 50000×50000 RGB → PIL 嘗試 allocate ~7.5 GB 記憶體 | |
| # 把 ml-service 炸到 OOM。caller 有 INTERNAL_SECRET 但 upload 內容本質上是 user- | |
| # supplied(hub-service 代 proxy user image)。與 paste_parser 同值,避免 class | |
| # attr 在模組間互相覆蓋時誤差。memory: feedback_pil_decompression_bomb_cap.md | |
| Image.MAX_IMAGE_PIXELS = 40_000_000 | |
| from src.models.fashion_clip import embed_image, get_embedding_dim | |
| from src.services.email_parser import ParsedOrderData, parse_order_email | |
| from src.services.paste_parser import PasteParseResult, parse_paste | |
| from src.services.tryon import TryOnResult, run_tryon | |
| from src.services.stylist import ItemInfo, StylistResponse, recommend_outfits | |
| from src.services.stylist_training import launch_training_job | |
| from src.services.gap_analysis import GapAnalysisResponse, analyze_wardrobe_gaps | |
| from src.models.xgboost_valuation import ( | |
| ValuationInput, | |
| ValuationResult, | |
| predict_with_fallback as xgb_predict_with_fallback, | |
| ) | |
| from src.services.trend_predictor import TrendPrediction, predict_trend | |
| from src.services.size_recommender import ( | |
| build_fit_verdict, | |
| FitVerdict, | |
| CrowdInput, | |
| UserProfile, | |
| ) | |
| from src.services.qa_answerer import QaInput, QaOutput, answer_question | |
| from src.services.translator import TranslateInput, TranslateOutput, translate_text | |
| from src.services.chat import ChatRequest, ChatResponse, chat_respond | |
| from src.services.description_generator import DescriptionInput | |
| from src.services.newsletter_parser import ( | |
| NewsletterParseResult, | |
| parse_newsletter, | |
| ) | |
| import asyncio | |
| import hmac | |
| import logging | |
| import os | |
| from contextlib import asynccontextmanager | |
| from collections.abc import AsyncIterator | |
| from starlette.requests import Request | |
| _logger = logging.getLogger(__name__) | |
| async def lifespan(_app: FastAPI) -> AsyncIterator[None]: | |
| """啟動時初始化 ModelRegistry + 共用 DB pool + 品牌正規化快取 + 背景預載。""" | |
| # 0. 預先初始化 DB pool(F4 v2 SR-6 closer)— 在 brand_normalizer.load() | |
| # 之前,因為 normalizer 第一個 acquire pool。pool 連 DB 失敗 (Neon 暫時 | |
| # 503) 不阻塞啟動:lazy retry,warn-only。 | |
| from src.db import get_pool | |
| try: | |
| get_pool() | |
| except Exception as exc: | |
| _logger.warning("DB pool eager init failed (will lazy-retry): %s", exc) | |
| # 1. 初始化全域 ModelRegistry(所有 service 共用) | |
| from src.registry import init_registry | |
| init_registry() | |
| # 2. Pre-load brand normalizer cache | |
| try: | |
| from src.services.brand_normalizer import get_normalizer | |
| get_normalizer().load() | |
| except Exception: | |
| pass # Non-fatal: will lazy-load on first request | |
| # 3. 背景預載 capability,不阻塞 server 啟動 | |
| # 載入完成前 health endpoint 仍可回應,model 相關 endpoint 會等 lock 釋放 | |
| # | |
| # 預設 lazy(不預載)— Gemma 4 ~7GB 與 FashionSigLIP 共住在 Apple | |
| # Silicon 17GB unified memory 會觸發 Metal kernel hang(/verify/local | |
| # HTTP path 死鎖)。ModelRegistry 已 per-capability lazy load,第一次 | |
| # /chat 等請求才拉權重;只 SigLIP 工作流(backfill / paste / embed) | |
| # 永遠不會觸發 cohabitation。 | |
| # | |
| # Production 預載(HF Spaces L40S): | |
| # - ML_PRELOAD_CHAT=1 → 預載 chat (qwen3-vl-8b-instruct),避免第一個聊天請求 | |
| # ~30s 冷啟。Sprint D:chat 從 Gemma 4 切到 Qwen3-VL, | |
| # 此 env 預載的是 Qwen3-VL instance(不再連帶 warm Gemma)。 | |
| # - ML_PRELOAD_TIMESFM=1 → 預載 trend_predict (TimesFM 2.5),避免第一個趨勢請求 | |
| # ~10s 冷啟 + ForecastConfig.compile() 開銷 | |
| # - ML_PRELOAD_OCR=1 → 預載 ocr (DeepSeek-OCR-2 specialist) + ocr_reasoning | |
| # (gemma-4-e4b)。Sprint D 之前 chat 與 ocr_reasoning 共用 | |
| # gemma-4-e4b instance,ML_PRELOAD_CHAT=1 自動連帶 warm | |
| # Gemma;Sprint D 後 chat 切 Qwen3-VL,這個 sharing 斷了, | |
| # 所以 ML_PRELOAD_OCR=1 需顯式預載 ocr_reasoning 才能完整 | |
| # warm 兩階段 OCR pipeline。 | |
| _preload_caps: list[str] = [] | |
| if os.environ.get("ML_PRELOAD_CHAT") == "1": | |
| _preload_caps.append("chat") | |
| if os.environ.get("ML_PRELOAD_TIMESFM") == "1": | |
| _preload_caps.append("trend_predict") | |
| if os.environ.get("ML_PRELOAD_OCR") == "1": | |
| # Sprint D:顯式預載 ocr (DeepSeek Stage 1) + ocr_reasoning (Gemma 4 Stage 2)。 | |
| # 兩階段 OCR pipeline 完整 warm 不再透過 chat capability sharing 連帶(chat 已切 Qwen3-VL)。 | |
| _preload_caps.extend(["ocr", "ocr_reasoning"]) | |
| if _preload_caps: | |
| async def _preload_critical_models() -> None: | |
| from src.registry import get_registry | |
| reg = get_registry() | |
| for cap in _preload_caps: | |
| try: | |
| await reg.get(cap) | |
| # WARN level so HF Spaces / Render container logs surface | |
| # this — root logger defaults to WARNING and uvicorn does | |
| # not configure src.* handlers, so INFO is silently dropped. | |
| _logger.warning( | |
| "ml-service startup: pre-loaded capability '%s'", cap | |
| ) | |
| except Exception as e: | |
| _logger.warning("Pre-load '%s' failed (will lazy-load): %s", cap, e) | |
| # v17 P2 — fire-and-forget needs a done-callback so escaping | |
| # exceptions surface to structured logs. Inner coroutine wraps each | |
| # capability load in try/except, but `from src.registry import ...` | |
| # at line 74 + the for-loop itself can raise outside that guard; | |
| # without the callback those exceptions only land on stderr (not | |
| # Sentry / pino aggregation in HF Spaces) and `/health` keeps | |
| # returning OK while every model call later 500s. | |
| _preload_task = asyncio.create_task(_preload_critical_models()) | |
| def _on_preload_done(t: "asyncio.Task[None]") -> None: | |
| if t.cancelled(): | |
| return | |
| exc = t.exception() | |
| if exc is not None: | |
| # Local logger 仍寫 unredacted exc — operator 從 Render / HF | |
| # Spaces stdout 撈 trace。Sentry 只見 scrubbed label。 | |
| _logger.error("Preload task escaped guard: %s", exc, exc_info=exc) | |
| # F3 deferred sweep(2026-05-03)—— 直接 capture_exception(exc) | |
| # 會把 exc.args(psycopg OperationalError 的 Neon DSN / | |
| # huggingface_hub 401 的 hf_ token)原樣寫進 event 的 | |
| # exception.values[].value 欄位。Sentry-only reader(compromised | |
| # integration token 或 read-grant)拿到 plaintext credentials。 | |
| # 改走 capture_message + scrub_exc_for_audit() opaque label。 | |
| # Sibling site: lib/background.py:_log_unhandled_exception。 | |
| # memory: feedback_python_sentry_capture_needs_scrub。 | |
| # | |
| # AsyncioIntegration 因 t.exception() 已 consume 例外而失活, | |
| # 仍需顯式 capture 才能進告警鏈(ml-service.error-rate-spike / | |
| # .5xx-rate)。memory: feedback_sentry_done_callback_consumes_asyncio_capture。 | |
| try: | |
| import sentry_sdk | |
| from src.lib.error_scrub import scrub_exc_for_audit | |
| # F2/F5 closer (c-series wave-2, hardened in wave-3): | |
| # 必須用 isolation_scope,不是 new_scope。sentry-sdk Python | |
| # 2.x 把 scope 拆成 global / isolation / current 三層, | |
| # **breadcrumb 寫在 isolation scope**——LoggingIntegration | |
| # level=INFO 把上面 _logger.error 寫的 raw exc 字串塞進 | |
| # ambient isolation scope 的 breadcrumb buffer。 | |
| # `new_scope()` 只 fork current scope,clear_breadcrumbs() | |
| # 對 isolation scope 沒效,event 仍夾 ambient breadcrumb; | |
| # `isolation_scope()` fork isolation scope,clear_breadcrumbs() | |
| # 才真清。empirically verified on sentry-sdk 2.58.0。 | |
| # 鏡像 TS `Sentry.withScope`(TS scope model 不分層, | |
| # withScope 等同 isolation_scope)。 | |
| # memory: feedback_sentry_breadcrumb_bypasses_message_scrub。 | |
| with sentry_sdk.isolation_scope() as scope: | |
| scope.clear_breadcrumbs() | |
| sentry_sdk.capture_message( | |
| f"preload-failed: {scrub_exc_for_audit(exc)}", | |
| level="error", | |
| ) | |
| except Exception: | |
| pass | |
| _preload_task.add_done_callback(_on_preload_done) | |
| else: | |
| # WARN level — see Pre-loaded note above re: root logger default. | |
| _logger.warning( | |
| "ml-service startup: no ML_PRELOAD_* set — all capabilities lazy-load on first request" | |
| ) | |
| yield # 應用程式運行中 | |
| # Shutdown | |
| # — drain DB pool;torch 模型仍由 process exit 釋放(沒明確 unload API) | |
| from src.db import close_pool | |
| close_pool() | |
| # ── Sentry SDK 初始化 ─────────────────────────────────────────────────────── | |
| # 必須在 FastAPI 物件建立前呼叫,FastApiIntegration 才能 hook 進 ASGI middleware | |
| # 鏈。詳見 src/sentry_init.py docstring。 | |
| # | |
| # Fail-loud sentinel: 若 SENTRY_DSN 設了但 SENTRY_ENVIRONMENT 沒設, | |
| # init_sentry() 會 raise RuntimeError,避免 dev 事件灌進 production project。 | |
| from src.sentry_init import init_sentry as _init_sentry # noqa: E402 | |
| _init_sentry() | |
| app = FastAPI(title="Wardrobe OS ML Service", version="0.0.1", lifespan=lifespan) | |
| # ── Internal secret 驗證(防止公開存取 ML service) ───────────────────────── | |
| INTERNAL_SECRET = os.environ.get("INTERNAL_SECRET") | |
| async def verify_internal_secret(request: Request, call_next): | |
| """驗證 INTERNAL_SECRET header — 只有內部服務可呼叫 ML API。 | |
| 豁免:/health、/docs、/openapi.json(部署平台健康檢查) | |
| Fail-closed: 缺 INTERNAL_SECRET env 一律 503,鏡像 Node 端 receiver pattern。 | |
| (v16 P0-1:v1~v15 之前是 `if INTERNAL_SECRET and ...` — 缺 env → 跳過驗證 → ML 全公開。) | |
| """ | |
| from starlette.responses import JSONResponse | |
| path = request.url.path | |
| if path in ("/health", "/docs", "/openapi.json"): | |
| return await call_next(request) | |
| if not INTERNAL_SECRET: | |
| return JSONResponse( | |
| status_code=503, | |
| content={"detail": "ML service misconfigured: INTERNAL_SECRET not set"}, | |
| ) | |
| # 用 hmac.compare_digest 做 constant-time 比對,避免 plain `!=` 在第一個 | |
| # 不同 byte 短路導致的 timing oracle(co-located attacker 可用 sub-ms RTT | |
| # variance brute-force secret prefix)。對應 Node 端 crypto.timingSafeEqual。 | |
| # memory: feedback_timing_safe_secrets.md | |
| secret = request.headers.get("x-internal-secret") or "" | |
| if not hmac.compare_digest(secret, INTERNAL_SECRET): | |
| return JSONResponse( | |
| status_code=403, | |
| content={"detail": "Forbidden: invalid internal secret"}, | |
| ) | |
| return await call_next(request) | |
| # 允許的圖片 MIME 類型(僅接受 JPEG、PNG、WebP,防止惡意檔案上傳) | |
| ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} | |
| def _validate_image_content_type(file: UploadFile) -> None: | |
| """驗證上傳檔案的 MIME 類型是否為允許的圖片格式。 | |
| 若 content_type 不在白名單內,回傳 HTTP 415 Unsupported Media Type。 | |
| """ | |
| if file.content_type not in ALLOWED_IMAGE_TYPES: | |
| raise HTTPException( | |
| status_code=415, | |
| detail=f"Unsupported image format: {file.content_type}. Allowed: JPEG, PNG, WebP", | |
| ) | |
| async def _decode_upload_image(file: UploadFile) -> "Image.Image": | |
| """讀取 upload bytes → 在 thread pool 解碼成 RGB PIL Image。 | |
| 兩個重點(對齊 src/ssrf.py._decode_image 的既有 discipline): | |
| 1. decode(`.convert("RGB")` 會 force full decode)放 `asyncio.to_thread`, | |
| 不阻塞 event loop——大圖解碼期間不會餓死其他 coroutine。 | |
| 2. content-type header 只擋宣告的 MIME,truncated/garbage bytes(header 合法但 | |
| 內容不是圖)會在 PIL 丟 UnidentifiedImageError/OSError;宣告 pixel-bomb 會丟 | |
| DecompressionBombError。包成 HTTP 422 而非讓未捕捉例外變成 FastAPI 預設 500。 | |
| """ | |
| import io | |
| raw = await file.read() | |
| def _decode() -> "Image.Image": | |
| # 顯式設 bomb cap 在解碼函式內,不依賴 module-level 賦值(mutable class | |
| # attr 可能被其他 code path reset 成 None → 靜默關閉防線)。對齊 | |
| # paste_parser._decode_image_base64。memory: pil_bomb_cap_no_import_sideeffect | |
| Image.MAX_IMAGE_PIXELS = 40_000_000 | |
| return Image.open(io.BytesIO(raw)).convert("RGB") | |
| try: | |
| return await asyncio.to_thread(_decode) | |
| except ( | |
| Image.UnidentifiedImageError, | |
| Image.DecompressionBombError, | |
| OSError, | |
| ValueError, | |
| ) as exc: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=f"Could not decode image: {type(exc).__name__}", | |
| ) from exc | |
| class EmbeddingResponse(BaseModel): | |
| embedding: list[float] | |
| dimension: int | |
| class ModelInfoResponse(BaseModel): | |
| model: str | |
| embedding_dim: int | |
| device: str | |
| class LabelOcrResponse(BaseModel): | |
| brand_raw: str | None = None | |
| brand_normalized: str | None = None | |
| product_name: str | None = None | |
| product_code: str | None = None | |
| size: str | None = None | |
| material: str | None = None | |
| care_instructions: str | None = None | |
| country_of_origin: str | None = None | |
| async def health() -> dict[str, str]: | |
| return {"status": "ok", "service": "ml-service"} | |
| async def embed(file: UploadFile) -> EmbeddingResponse: | |
| """Generate a FashionSigLIP embedding vector from an uploaded image.""" | |
| _validate_image_content_type(file) | |
| image = await _decode_upload_image(file) | |
| vector = await asyncio.to_thread(embed_image, image) | |
| return EmbeddingResponse(embedding=vector, dimension=len(vector)) | |
| async def model_info() -> ModelInfoResponse: | |
| """Return loaded model metadata.""" | |
| # fashion_clip exports the repo string as MODEL_REPO (no MODEL_NAME symbol); | |
| # this lazy import referenced a non-existent name → ImportError → 500 on every | |
| # /model-info call. Pre-existing bug, fixed drive-by while in this file. | |
| from src.models.fashion_clip import DEVICE, MODEL_REPO as MODEL_NAME | |
| return ModelInfoResponse( | |
| model=MODEL_NAME, | |
| embedding_dim=get_embedding_dim(), | |
| device=DEVICE, | |
| ) | |
| async def registry_health() -> dict: | |
| """回傳所有 capability 的 ModelRegistry 載入狀態。 | |
| 【回傳格式】 | |
| { | |
| "models": { | |
| "<capability>": {"status": "loaded" | "not_loaded", "model": "<model_key>"}, | |
| ... | |
| } | |
| } | |
| """ | |
| from src.registry import get_registry | |
| # get_registry() 在 startup 後一定存在;若測試環境尚未初始化則回空 dict | |
| try: | |
| reg = get_registry() | |
| except RuntimeError: | |
| return {"models": {}} | |
| return {"models": reg.health()} | |
| async def ocr_label(file: UploadFile) -> LabelOcrResponse: | |
| """從洗標照片提取商品資訊(Gemma 4 E4B 單步視覺 OCR),並正規化品牌名稱。""" | |
| from src.services.label_ocr import extract_label | |
| from src.services.brand_normalizer import normalize_brand | |
| _validate_image_content_type(file) | |
| image = await _decode_upload_image(file) | |
| # extract_label 已改為 async(透過 ModelRegistry 取得兩個模型),直接 await | |
| result = await extract_label(image) | |
| return LabelOcrResponse( | |
| brand_raw=result.brand_raw, | |
| brand_normalized=await asyncio.to_thread(normalize_brand, result.brand_raw), | |
| product_name=result.product_name, | |
| product_code=result.product_code, | |
| size=result.size, | |
| material=result.material, | |
| care_instructions=result.care_instructions, | |
| country_of_origin=result.country_of_origin, | |
| ) | |
| class EmailParseRequest(BaseModel): | |
| subject: str | |
| body: str | |
| async def parse_email(request: EmailParseRequest) -> ParsedOrderData: | |
| """解析訂單確認 email,透過 ModelRegistry 載入 Qwen2.5-0.5B + LoRA 進行推論。""" | |
| # parse_order_email 已改為 async(透過 ModelRegistry 取得模型),直接 await | |
| return await parse_order_email(request.subject, request.body) | |
| class PasteParseRequest(BaseModel): | |
| # v17 P2 — extra="forbid": typo 在 mode 欄位該 loud fail,不是靜默走 text path | |
| model_config = ConfigDict(extra="forbid") | |
| # "text" | "html" | "image_base64" — 詳見 paste_parser.Mode | |
| mode: str | |
| # 內容;text 為 plain,html 為 outerHTML,image_base64 為 base64 或 data URI | |
| content: str | |
| async def parse_paste_endpoint(request: PasteParseRequest) -> PasteParseResult: | |
| """批次匯入的剪貼簿解析器 — Gemma 4 吃下 pasted profile,回傳多筆 item。""" | |
| if request.mode not in ("text", "html", "image_base64"): | |
| raise HTTPException( | |
| status_code=422, | |
| detail="mode must be 'text', 'html', or 'image_base64'", | |
| ) | |
| return await parse_paste(request.mode, request.content) # type: ignore[arg-type] | |
| class TryOnRequest(BaseModel): | |
| # v17 P2 — extra="forbid": URL fields hit external GPU provider; typo'd | |
| # field name would 500 mid-async rather than 422. | |
| model_config = ConfigDict(extra="forbid") | |
| # v25 wire-contract fix — hub tryon worker + ml-client tryon capability 送 | |
| # `person_image_url`(不是 `user_photo_url`)。原本 required `user_photo_url` | |
| # 缺欄位 + `person_image_url` 是 forbidden extra → 每次 try-on 雙重 422。 | |
| # 對齊 caller 欄位名(extra="forbid" 保留,別退回 ignore)。 | |
| person_image_url: str | |
| garment_image_url: str | |
| # capability 簽名帶 optional `category`;worker 目前不送,但接受以免未來加上時 | |
| # 撞 forbidden-extra 422。目前不進 VTON prompt(provider 只吃兩張圖)。 | |
| category: str | None = None | |
| async def tryon(request: TryOnRequest) -> TryOnResult: | |
| """Generate a virtual try-on image using Replicate IDM-VTON.""" | |
| return await run_tryon(request.person_image_url, request.garment_image_url) | |
| class StylistRequest(BaseModel): | |
| # v17 P2 — extra="forbid": caller typo (e.g. `adapter_hf_repo` vs | |
| # `adapter_hf_repo_id`) returns 422 instead of silently ignoring → caller | |
| # would otherwise see fallback recommendations with no signal that the | |
| # adapter request was dropped. | |
| model_config = ConfigDict(extra="forbid") | |
| items: list[ItemInfo] | |
| occasion: str | None = None | |
| weather: dict | None = None # S14-4:天氣資訊(temperature, weatherCode, humidity) | |
| location: dict | None = None # S14-4:地點資訊(city, country) | |
| adapter_id: str | None = ( | |
| None # Sprint 16:LoRA adapter name(使用者 UUID 或 "default") | |
| ) | |
| adapter_hf_repo_id: str | None = None # Sprint 16:adapter 的 HF Hub 位址 | |
| async def stylist_recommend(request: StylistRequest) -> StylistResponse: | |
| """Generate outfit recommendations from user's wardrobe items (weather-aware, adapter-aware).""" | |
| return await recommend_outfits( | |
| request.items, | |
| request.occasion, | |
| request.weather, | |
| request.location, | |
| adapter_id=request.adapter_id, | |
| adapter_hf_repo_id=request.adapter_hf_repo_id, | |
| ) | |
| class GapAnalysisRequest(BaseModel): | |
| items: list[ItemInfo] | |
| async def stylist_gaps(request: GapAnalysisRequest) -> GapAnalysisResponse: | |
| """Analyze wardrobe gaps and suggest missing pieces.""" | |
| return await analyze_wardrobe_gaps(request.items) | |
| class LaunchTrainingRequest(BaseModel): | |
| user_id: str | |
| class LaunchTrainingResponse(BaseModel): | |
| available: bool | |
| job_id: str | None | |
| async def stylist_launch_training( | |
| request: LaunchTrainingRequest, | |
| ) -> LaunchTrainingResponse: | |
| """B1 — 啟動 per-user LoRA 訓練 Job(run_uv_job)。 | |
| HF launch 失敗 → 回 available=False 讓 worker 釋鎖(不捏造已訓練 adapter)。 | |
| Sentry capture 走 scrub + isolation_scope clear_breadcrumbs(HF_TOKEN/secret | |
| 永不進 event;mirror main.py preload site)。 | |
| """ | |
| try: | |
| out = launch_training_job(request.user_id) | |
| return LaunchTrainingResponse(**out) | |
| except Exception as exc: # noqa: BLE001 — fail-soft + scrubbed capture | |
| import sentry_sdk | |
| from src.lib.error_scrub import scrub_exc_for_audit | |
| with sentry_sdk.isolation_scope() as scope: | |
| scope.clear_breadcrumbs() | |
| sentry_sdk.capture_message( | |
| f"stylist-launch-training-failed: {scrub_exc_for_audit(exc)}", | |
| level="error", | |
| ) | |
| # HF launch failed → tell the worker it's unavailable so it releases the lock | |
| return LaunchTrainingResponse(available=False, job_id=None) | |
| # Cap on outstanding fire-and-forget observation tasks. record_observation | |
| # does ~3s SSRF/download + torch embed + psycopg INSERT with no back-pressure | |
| # on the request path; without a cap an attacker spamming /predict with | |
| # image_urls (mlRateLimiterCheap 30/min/user × N accounts) could grow | |
| # _BACKGROUND_TASKS unbounded and exhaust asyncio's to_thread worker pool, | |
| # blocking other ml-service endpoints. 50 is sized so that at sustained | |
| # traffic the backlog stays well below default ThreadPoolExecutor capacity | |
| # (min(32, cpu+4)) — drops are logged for ops visibility but don't break the | |
| # prediction SLA. Post-review SR-P1-1 (security-review conf 80). | |
| _MAX_PENDING_OBSERVATIONS = 50 | |
| async def valuation_predict(request: ValuationInput) -> ValuationResult: | |
| """XGBoost 估值推論(透過 ModelRegistry,fallback 規則系統)。 | |
| 【流程】 | |
| 1. predict_with_fallback 根據 n_samples 決定 rule / blended / xgboost 策略 | |
| - n < 30:純規則(weight_xgb=0,不呼叫 XGBoost) | |
| - 30 ≤ n ≤ 50:線性 ramp 混合 rule 和 xgboost | |
| - n > 50:純 xgboost(若 model 可用,否則 fallback rule) | |
| 2. 將 CPU 密集推論放在 asyncio.to_thread 避免阻塞 event loop | |
| 3. F4 v2 (A) — 若 caller 帶 image_url:FashionSigLIP 嵌入 + INSERT 到 | |
| valuation_observations 表,累積未來 image-feature 訓練資料。 | |
| fire_and_forget 排程 record_observation:observation 失敗不影響 | |
| prediction,且 ~3s 的 SSRF/download/embed/INSERT 不卡 critical path。 | |
| worker SIGKILL 會丟 in-flight task — 跟原 await 版的代價相同(兩者 | |
| 都非 durable),但換來每 call ~3s 的 latency 改善。 | |
| 無 image_url 時跳過排程(避免 N requests 都 allocate 一個 no-op task | |
| 打到 _BACKGROUND_TASKS — post-review CR-1 conf 88);超過 | |
| _MAX_PENDING_OBSERVATIONS 時 drop + log warn。 | |
| """ | |
| import asyncio | |
| from src.lib.background import background_task_count, fire_and_forget | |
| from src.services.valuation_observations import record_observation | |
| result = await asyncio.to_thread( | |
| xgb_predict_with_fallback, request, request.n_samples | |
| ) | |
| if request.image_url is not None: | |
| if background_task_count() < _MAX_PENDING_OBSERVATIONS: | |
| fire_and_forget( | |
| record_observation( | |
| inp=request, | |
| result=result, | |
| image_url=request.image_url, | |
| item_id=request.item_id, | |
| ), | |
| name="record_valuation_observation", | |
| ) | |
| else: | |
| _logger.warning( | |
| "valuation observation backlog at cap (%d); dropping", | |
| _MAX_PENDING_OBSERVATIONS, | |
| ) | |
| return result | |
| async def valuation_status() -> dict[str, bool | str]: | |
| """透過 ModelRegistry 檢查 XGBoost 模型是否已載入。""" | |
| from src.models.xgboost_valuation import _get_valuation_model | |
| _model, _encoder, available = await _get_valuation_model() | |
| return { | |
| "xgboost_available": available, | |
| "method": "xgboost" if available else "rule_based", | |
| } | |
| class TextEmbeddingRequest(BaseModel): | |
| text: str | |
| async def embed_text(request: TextEmbeddingRequest) -> EmbeddingResponse: | |
| """Generate a FashionSigLIP text embedding for semantic search. | |
| 透過 ModelRegistry 取得 embed_text capability 對應的 FashionSigLIP 實例。 | |
| """ | |
| from src.registry import get_registry | |
| import torch | |
| reg = get_registry() | |
| loaded = await reg.get("embed_text") | |
| model = loaded.model | |
| tokenizer = loaded.tokenizer | |
| device = loaded.config.get("device", "cpu") | |
| def _encode_text() -> list[float]: | |
| tokens = tokenizer([request.text]).to(device) | |
| with torch.no_grad(): | |
| features = model.encode_text(tokens) | |
| features = features / features.norm(dim=-1, keepdim=True) | |
| return features.squeeze(0).cpu().tolist() | |
| import asyncio | |
| vector = await asyncio.to_thread(_encode_text) | |
| return EmbeddingResponse(embedding=vector, dimension=len(vector)) | |
| # --- Local AI Verification (Gemma 4 Vision) --- | |
| class LocalVerifyRequest(BaseModel): | |
| # v17 P2 — extra="forbid": URL fields go through SSRF validation; a typo'd | |
| # field name (e.g. `imageUrl` camelCase vs snake_case) would silently skip | |
| # validation rather than rejecting the request. | |
| model_config = ConfigDict(extra="forbid") | |
| brand: str | None = None | |
| image_url: str | None = None | |
| label_image_url: str | None = None | |
| def _to_confidence_band(confidence: float) -> str: | |
| """Bucket Gemma's raw confidence float into a 3-level band. | |
| Codebase review v19 §ML-02. The raw float is a scalar oracle even | |
| though the endpoint is owner-gated: the owner can submit forgeries | |
| against their own item and use the float as a gradient signal to push | |
| inputs toward the "authentic" decision boundary. 3-level enum (matches | |
| anchor_band) is the minimum semantic the UI / writer needs. | |
| Thresholds: | |
| high ≥ 0.8 → Gemma is genuinely confident. | |
| medium ≥ 0.5 → leaning but not strongly so. | |
| low → coin-flip or worse. | |
| """ | |
| if confidence >= 0.8: | |
| return "high" | |
| if confidence >= 0.5: | |
| return "medium" | |
| return "low" | |
| class LocalVerifyResponse(BaseModel): | |
| result: str # "authentic" | "counterfeit" | "inconclusive" | |
| # Codebase review v19 §ML-02: raw confidence float was a scalar oracle | |
| # even on this owner-gated endpoint (memory feedback_llm_response_scalar_oracle | |
| # "Applies even to owner-only endpoints"). Now a 3-level band that | |
| # matches anchor_band's shape — log2(3) ≈ 1.5 bit/query is too thin | |
| # for gradient attacks. | |
| confidence_band: str # "high" | "medium" | "low" | |
| reasoning: str | |
| # F5 — 防偽錨點粗分級;None = 未跑錨點(空品牌 / 該品牌無錨點 / 查詢失敗)。 | |
| # 只暴露 band,不暴露精確 similarity + gallery size:scalar float 會讓 | |
| # authenticated attacker 把 response 當黑箱 oracle(梯度優化把偽品推到 | |
| # looks_authentic threshold),count 洩露 gallery size 讓他挑最弱保護品牌。 | |
| # band 三級 log2(3) ≈ 1.5 bit/query 攻擊效益太低,同時 UI 仍有可顯示的 verdict。 | |
| # F5 post-review SR2 conf P1(2026-04-23 commit 6d27790 之後的 re-review)。 | |
| anchor_band: str | None = None # "looks_authentic" | "inconclusive" | "deviates" | |
| from src.ssrf import download_image as _download_image # noqa: E402 | |
| from src.lib.prompt_safety import sanitize_for_prompt as _sanitize_for_prompt # noqa: E402 | |
| from src.lib.prompt_safety import wrap_boundary as _wrap_boundary # noqa: E402 | |
| def _sanitize_brand_for_prompt(raw: str | None, max_len: int = 80) -> str | None: | |
| """把 request.brand 做 prompt-injection hardening 再拿去塞 Gemma prompt(flatten 單行 token)。 | |
| 委派給單一來源 `src.lib.prompt_safety.sanitize_for_prompt(preserve_newlines=False)`: | |
| 控制空白(含 form-feed/vtab,flatten_form_feeds 預設 True)先轉空白 → | |
| isprintable() 濾零寬/雙向 override → whitespace 壓合 → clamp 80。 | |
| F5 post-review SR3 conf P1(`request.brand` 之前直接 f-string 進 prompt)。 | |
| """ | |
| return _sanitize_for_prompt(raw, max_len=max_len) | |
| async def verify_local(request: LocalVerifyRequest) -> LocalVerifyResponse: | |
| """Run local AI verification using Gemma 4 vision analysis. | |
| Accepts product image URL and optional label image URL. | |
| Analyzes visual authenticity cues and label consistency. | |
| Protected by SSRF validation and image size limits. | |
| """ | |
| from src.services.gemma_inference import gemma_vision | |
| images: list[Image.Image] = [] | |
| image_desc_parts: list[str] = [] | |
| # 下載商品照(含 SSRF 防護 + 大小限制) | |
| if request.image_url: | |
| try: | |
| images.append(await _download_image(request.image_url)) | |
| image_desc_parts.append("product photo") | |
| except Exception as e: | |
| _logger.warning("Failed to fetch product image: %s", e) | |
| # 下載標籤照 | |
| if request.label_image_url: | |
| try: | |
| images.append(await _download_image(request.label_image_url)) | |
| image_desc_parts.append("care label/tag photo") | |
| except Exception as e: | |
| _logger.warning("Failed to fetch label image: %s", e) | |
| # Section 3 I: 防偽驗證 inconclusive+0.5 fallback 長得跟真實 verdict 一模一樣, | |
| # 生產環境吐出假驗證會誤導使用者 + Provenance Chain。strict default — | |
| # 只有 DEV_MOCK_ML=true 才走 inconclusive,否則 503。 | |
| if not images: | |
| if os.environ.get("DEV_MOCK_ML") != "true": | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "error": "ML_UNAVAILABLE", | |
| "message": "AI 驗證暫時無法使用,請稍後再試", | |
| "capability": "verify_local", | |
| }, | |
| ) | |
| return LocalVerifyResponse( | |
| result="inconclusive", | |
| confidence_band=_to_confidence_band(0.5), | |
| reasoning="No images available for analysis", | |
| ) | |
| # SR3: caller-supplied brand 不可直接 f-string 進 prompt——caller 是已認證的 | |
| # hub-service 但 brand 字串來源是 hub_items.brand(user-supplied,只有 varchar | |
| # 255 cap,沒有 prompt-safety validation)。sanitize 過之後用 `<<<...>>>` 三 | |
| # 重角括號包起來,讓 Gemma 清楚邊界,攻擊者就算 strip 後還能塞 80 chars | |
| # 也無法 override 指令(Gemma 解析時角括號內內容視為實體資料)。 | |
| brand_safe = _sanitize_brand_for_prompt(request.brand) | |
| # wrap_boundary 統一 fence + 中和字面 <<< / >>>(fence-escape,PR #197 P1) | |
| brand_context = f" for brand {_wrap_boundary(brand_safe)}" if brand_safe else "" | |
| image_context = " and ".join(image_desc_parts) | |
| # F5 — query brand anchor gallery when brand + product image both known. | |
| # We embed the product image (images[0]) and compare against stored | |
| # references; the result feeds the Gemma prompt as grounding so the VLM | |
| # doesn't have to invent its own internal reference for each brand. | |
| # anchor_result is None when brand is blank OR no anchors exist for it — | |
| # fall back to pure zero-shot in that case (backwards-compatible). | |
| # 注意:query_anchor_similarity 用 sanitized brand 查 DB 也 OK(brand_anchors | |
| # 表儲存的就是 canonical form,不含換行/控制字元)——用 raw brand 會多 | |
| # 繞 strip/whitespace 一次才 match,用 sanitized 反而少一層意外。 | |
| anchor_result = None | |
| if brand_safe and images: | |
| try: | |
| from src.registry import get_registry | |
| from src.services.counterfeit_anchor import query_anchor_similarity | |
| cap_cfg = get_registry().get_capability_config("counterfeit_anchor") | |
| anchor_result = await query_anchor_similarity( | |
| images[0], | |
| brand_safe, | |
| authentic_threshold=float(cap_cfg.get("authentic_threshold", 0.80)), | |
| deviates_threshold=float(cap_cfg.get("deviates_threshold", 0.60)), | |
| ) | |
| except Exception as e: | |
| # Anchor is advisory; failure never blocks the Gemma narrative. | |
| _logger.warning("Anchor similarity query failed: %s", e) | |
| anchor_block = "" | |
| if anchor_result is not None: | |
| # Band 是粗分級,描述要嚴謹但不洩露 scalar similarity / gallery size。 | |
| # 對 Gemma 來說「strongly matches」「borderline」「deviates」的語意就夠 | |
| # 做 prior 加權;float 只對 debug 有用,對 VLM 決策反而是噪音。 | |
| band_phrase = { | |
| "looks_authentic": "strongly matches", | |
| "inconclusive": "partially matches", | |
| "deviates": "deviates from", | |
| }.get(anchor_result.verdict_band, "is compared against") | |
| anchor_block = ( | |
| f"\nReference gallery check: the product image {band_phrase} " | |
| f"known-authentic reference photos for this brand. Treat this as " | |
| "supporting evidence, not a verdict — weigh it against your own " | |
| "visual inspection.\n" | |
| ) | |
| prompt = ( | |
| f"You are an expert authenticator. Analyze this {image_context}{brand_context}.\n" | |
| f"{anchor_block}" | |
| "Check for:\n" | |
| "1. Stitching quality and consistency\n" | |
| "2. Logo/branding accuracy (font, spacing, alignment)\n" | |
| "3. Hardware and zipper quality\n" | |
| "4. Material texture and grain patterns\n" | |
| "5. Label printing quality and text accuracy\n" | |
| "6. Overall construction and finishing\n\n" | |
| 'Return ONLY valid JSON: {"result": "authentic" or "counterfeit" or "inconclusive", ' | |
| '"confidence": 0.0-1.0, "reasoning": "brief explanation"}' | |
| ) | |
| try: | |
| main_image = images[0] | |
| extra_images = images[1:] if len(images) > 1 else None | |
| raw = await gemma_vision( | |
| "counterfeit_narrative", | |
| main_image, | |
| prompt, | |
| additional_images=extra_images, | |
| max_tokens=512, | |
| ) | |
| # 解析 JSON | |
| import json | |
| text = raw.strip() | |
| if text.startswith("```"): | |
| lines = text.split("\n") | |
| text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) | |
| start = text.index("{") | |
| end = text.rindex("}") + 1 | |
| data = json.loads(text[start:end]) | |
| result = data.get("result", "inconclusive") | |
| if result not in ("authentic", "counterfeit", "inconclusive"): | |
| result = "inconclusive" | |
| confidence = float(data.get("confidence", 0.5)) | |
| confidence = max(0.0, min(1.0, confidence)) | |
| return LocalVerifyResponse( | |
| result=result, | |
| confidence_band=_to_confidence_band(confidence), | |
| reasoning=data.get("reasoning", ""), | |
| anchor_band=(anchor_result.verdict_band if anchor_result else None), | |
| ) | |
| except Exception as e: | |
| _logger.warning("Local verification failed: %s", e) | |
| if os.environ.get("DEV_MOCK_ML") != "true": | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "error": "ML_UNAVAILABLE", | |
| "message": "AI 驗證暫時無法使用,請稍後再試", | |
| "capability": "verify_local", | |
| }, | |
| ) from e | |
| # anchor_band 在 Gemma 爆炸之前可能已經成功算出來——保留回傳,讓 | |
| # DEV_MOCK_ML mock path 至少帶得動 anchor 的分級資訊。 | |
| return LocalVerifyResponse( | |
| result="inconclusive", | |
| confidence_band=_to_confidence_band(0.5), | |
| reasoning="Verification could not be completed. Please try again.", | |
| anchor_band=(anchor_result.verdict_band if anchor_result else None), | |
| ) | |
| # --- #71: Trend Prediction --- | |
| class PricePoint(BaseModel): | |
| date: str | |
| price: float | |
| class TrendRequest(BaseModel): | |
| brand: str | |
| price_history: list[PricePoint] | None = None | |
| async def trends_predict(request: TrendRequest) -> TrendPrediction: | |
| """品牌價格趨勢預測(Chronos-Bolt-Tiny + pytrends,透過 ModelRegistry)。 | |
| 【流程】 | |
| Dev mode: 返回 mock 線性預測(TREND_DEV_MODE=true)。 | |
| Production: 從 registry 取得 Chronos 模型 → 零射時間序列預測 + Google Trends。 | |
| """ | |
| import asyncio | |
| history = None | |
| if request.price_history: | |
| history = [{"date": p.date, "price": p.price} for p in request.price_history] | |
| # predict_trend 是同步函式,放在 thread 中執行 | |
| # 內部的 _chronos_predict 會呼叫 _load_chronos()(registry fallback) | |
| return await asyncio.to_thread(predict_trend, request.brand, history) | |
| # --- #72: Size Recommendation --- | |
| class SizeRequest(BaseModel): | |
| # v17 P2 — extra="forbid": caller typo (e.g. garment_measurments) must fail | |
| # loud, not silently drop the fusion input and degrade to brand-chart only. | |
| model_config = ConfigDict(extra="forbid") | |
| brand: str | |
| category: str = "" # reserved (future category-specific charts); unused in SP1 | |
| user_profile: UserProfile | |
| garment_measurements: dict[str, dict[str, float]] | None = None | |
| crowd: CrowdInput | None = None | |
| sentiment_band: str | None = None | |
| async def size_recommend(request: SizeRequest) -> FitVerdict: | |
| """Fuse brand chart + body + garment measurements + crowd + sentiment.""" | |
| return build_fit_verdict( | |
| request.brand, | |
| request.user_profile, | |
| garment_measurements=request.garment_measurements, | |
| crowd=request.crowd, | |
| sentiment_band=request.sentiment_band, | |
| ) | |
| # --- F2: Q&A Chatbot --- | |
| async def qa_answer(request: QaInput) -> QaOutput: | |
| """Answer a buyer's question about an item using local model.""" | |
| return await answer_question(request) | |
| # --- Pivot 賣家側 #3:跨語言議價 / Q&A 翻譯 --- | |
| async def translate_endpoint(request: TranslateInput) -> TranslateOutput: | |
| """把任意語言文字翻成 target_lang(source 由模型自動偵測)。 | |
| 用途:賣家讀外語買家議價訊息 / Q&A 提問(入站翻譯)+ copilot 草稿的買家 | |
| 語言偵測(出站)。重用已 resident 的 translate capability(預設 Qwen3-VL-8B, | |
| 與 chat / qa 同一 model 鍵 → 零增量記憶體)。 | |
| request.text 是完全 user-controlled 的自由文字(經典 prompt-injection 向量)— | |
| translator.build_translate_prompt 用 <<< >>> boundary token 包住 + 嚴格 | |
| translate-only 指令 + sanitize(保留 \\n、strip 零寬/雙向/控制字元)。 | |
| 模型不可用 / 失敗 → available=False(不捏造翻譯);HTTP 仍 200,caller 見 | |
| available 旗標優雅降級(fail-soft toast,不擋手動操作)。 | |
| """ | |
| return await translate_text(request) | |
| # --- Newsletter Promo Parser --- | |
| class NewsletterParseRequest(BaseModel): | |
| email_body: str | |
| platform: str # "uniqlo" or "ssense" | |
| async def parse_newsletter_endpoint( | |
| request: NewsletterParseRequest, | |
| ) -> NewsletterParseResult: | |
| """解析品牌行銷電子報,提取折扣活動和優惠碼。parse_newsletter 已改為 async,直接 await。""" | |
| # parse_newsletter 已改為 async(透過 ModelRegistry 取得 Qwen2.5-0.5B),直接 await | |
| return await parse_newsletter(request.email_body, request.platform) | |
| # --- 多輪對話(Multi-turn Chat + Tool Calling) --- | |
| async def chat_respond_endpoint(request: ChatRequest) -> ChatResponse: | |
| """多輪對話端點(含 tool calling 支援)""" | |
| return await chat_respond(request) | |
| # --- S14-1: AI 商品描述生成 --- | |
| async def generate_description_endpoint(request: DescriptionInput) -> dict: | |
| """根據商品資訊生成 AI 商品描述。 | |
| v17 P2 — 之前簽名是 `request: dict`,FastAPI 對任意 JSON 不做驗證。 | |
| 上層 hub-service 若送錯 shape(list 而非 object)會在 `request.get(...)` | |
| 噴 `AttributeError`,而非乾淨的 422;且任意大小欄位直送 LLM prompt。 | |
| 改用既有 DescriptionInput Pydantic model(model_config 設 extra="forbid" | |
| 防止 caller 多送拼錯欄位被靜默 ignore)。 | |
| """ | |
| from src.services.description_generator import generate_description | |
| result = await generate_description(request) | |
| return {"description": result.description} | |
| # --- S14-8: 時尚趨勢分析 --- | |
| class TrendAnalysisRequest(BaseModel): | |
| # v25 wire-contract fix — hub trends-report.ts + ml-client trends.analyze 送 | |
| # `{content_items: [...]}`,但原本 required `items` → 每次週報趨勢分析 422 → | |
| # hub catch → 使用者見 502。model_validator(before) 接受 `content_items` | |
| # alias 映射到 `items`(extra="forbid" 鎖住其餘拼錯欄位)。 | |
| model_config = ConfigDict(extra="forbid") | |
| items: list[dict] | |
| def _accept_content_items_alias(cls, data: object) -> object: | |
| if isinstance(data, dict) and "items" not in data and "content_items" in data: | |
| aliased = {k: v for k, v in data.items() if k != "content_items"} | |
| aliased["items"] = data["content_items"] | |
| return aliased | |
| return data | |
| async def trends_analyze_endpoint(request: TrendAnalysisRequest) -> dict: | |
| """分析近期時尚內容,提取趨勢關鍵詞、風格方向與摘要。""" | |
| from src.services.trend_analyzer import ( | |
| ContentItem, | |
| analyze_trends, | |
| ) | |
| content_items = [ | |
| ContentItem( | |
| title=item.get("title", ""), | |
| summary=item.get("summary"), | |
| tags=item.get("tags"), | |
| ) | |
| for item in request.items | |
| ] | |
| result = await analyze_trends(content_items) | |
| return result.model_dump() | |
| # --- S14-6: 穿搭照自動匹配商品 --- | |
| class OutfitMatchRequest(BaseModel): | |
| image_url: str | |
| item_embeddings: list[dict] # [{id, embedding}] | |
| threshold: float = 0.5 | |
| top_k: int = 3 | |
| async def outfit_match_endpoint(request: OutfitMatchRequest) -> dict: | |
| """穿搭照 → FashionSigLIP → cosine similarity → 回傳匹配的商品 ID。 | |
| match_outfit_items 已改為非同步函式,直接 await 即可。 | |
| """ | |
| from src.services.outfit_matcher import ( | |
| MatchRequest, | |
| match_outfit_items, | |
| ) | |
| req = MatchRequest( | |
| image_url=request.image_url, | |
| item_embeddings=request.item_embeddings, | |
| threshold=request.threshold, | |
| top_k=request.top_k, | |
| ) | |
| result = await match_outfit_items(req) | |
| return result.model_dump() | |
| # --- A2: 零樣本視覺屬性抽取(colorway / category / features) --- | |
| class VisionAttributesRequest(BaseModel): | |
| # extra="forbid":URL 欄位走 SSRF 下載;caller typo(imageUrl camelCase vs | |
| # snake_case)要 loud fail,不靜默跳過驗證。與 verify/local 同策略。 | |
| model_config = ConfigDict(extra="forbid") | |
| image_url: str | |
| class VisionAttributesResponse(BaseModel): | |
| # available=False = 模型不可用 / 拿不到圖(不捏造);caller 見此跳過 colorway。 | |
| available: bool | |
| colorway: str | None = None | |
| category: str | None = None | |
| features: list[str] | None = None | |
| async def vision_attributes_endpoint( | |
| request: VisionAttributesRequest, | |
| ) -> VisionAttributesResponse: | |
| """A2 — 從商品照 zero-shot 抽 colorway / category / features 給 wiki 富集。 | |
| 重用已 resident 的 gemma-4-e4b vision 實例(vision_attributes capability)。 | |
| SSRF + PIL bomb cap 全在 extract_vision_attributes → download_image 內。 | |
| 模型不可用 / 拿不到圖 → available=False(不捏造),HTTP 仍 200 讓 caller | |
| 優雅降級(caller 是 INTERNAL_SECRET hub-service,503 會被 retry 浪費;明確 | |
| available 旗標比 status code 語意更清楚)。輸出已 server 端 sanitize(S-5)。 | |
| """ | |
| from src.services.vision_attributes import extract_vision_attributes | |
| result = await extract_vision_attributes(request.image_url) | |
| return VisionAttributesResponse( | |
| available=result.available, | |
| colorway=result.colorway, | |
| category=result.category, | |
| features=result.features, | |
| ) | |
| # --- #23: LLM-assisted admin tariff suggestion (advisory) --- | |
| class TariffSuggestRequest(BaseModel): | |
| # extra="forbid":caller typo 要 loud fail,不靜默用 default(同 verify/local)。 | |
| model_config = ConfigDict(extra="forbid") | |
| country_code: str | |
| country_name: str | None = None | |
| def _validate_country_code(cls, v: str) -> str: | |
| # Defense-in-depth:country_code 直接拼進 prompt(country_name 已 sanitize, | |
| # 但 code 沒有對等防線)。即使 caller 帶有效 INTERNAL_SECRET 直打 ml-service, | |
| # 也只接受 ISO 3166-1 alpha-2,杜絕換行/注入 payload 從 code 欄繞過。 | |
| import re | |
| if not re.fullmatch(r"[A-Za-z]{2}", v): | |
| raise ValueError("country_code must be ISO 3166-1 alpha-2") | |
| return v.upper() | |
| class TariffSuggestResponse(BaseModel): | |
| # available=False = 模型不可用 / 推論失敗(不捏造);caller 見此回 503 讓 admin 手填。 | |
| available: bool | |
| duty_rate: float | None = None | |
| vat_rate: float | None = None | |
| de_minimis_usd: float | None = None | |
| rationale: str | None = None | |
| confidence: str | None = None | |
| async def tariff_suggest_endpoint( | |
| request: TariffSuggestRequest, | |
| ) -> TariffSuggestResponse: | |
| """#23 — 為一個國家「建議」import duty / VAT / de-minimis 三個費率(advisory)。 | |
| **永不寫 DB**:純 zero-shot Gemma 推論,admin 在前端核實 / 修改後才透過既有 | |
| write 端點落地。country_name 進 prompt 前 sanitize;費率 server 端 clamp; | |
| 模型不可用 / 拿不到合理輸出 → available=False(不捏造),api-gateway 端轉 503。 | |
| """ | |
| from src.services.tariff_suggester import suggest_tariff | |
| result = await suggest_tariff(request.country_code, request.country_name) | |
| return TariffSuggestResponse( | |
| available=result.available, | |
| duty_rate=result.duty_rate, | |
| vat_rate=result.vat_rate, | |
| de_minimis_usd=result.de_minimis_usd, | |
| rationale=result.rationale, | |
| confidence=result.confidence, | |
| ) | |