erdoganpeker Claude Opus 4.7 (1M context) commited on
Commit
41449fe
·
1 Parent(s): 7fbd2a0

fix(wave9): 10 P0 prod bugs from 5-agent audit

Browse files

Backend:
- main.py _process_sync: per-image try/except; single failure no longer
aborts entire batch (was returning 500 on N>=2 photos with custom model).
- main.py update_inspection: model_versions added to JSONB column list,
was silently dropping every completed inspect's model metadata.
- ml_service.py check_model_files_available: fix REGISTRY ImportError
(symbol does not exist; use get_registry() public API).
- ml_service.py _analyze_via_roboflow: emit canonical Inspection schema
with full summary skeleton, severity object, cost stub, type_tr — was
crashing frontend with undefined.level_tr / undefined.min_tl.
- config.py s3_public_endpoint: default '' (was localhost:9000 leaking
into prod URLs). Empty falls back to s3_endpoint (B2 host).
- worker.py: detect rediss:// and set broker_use_ssl with ssl_cert_reqs=NONE
(Upstash TLS Redis was 503'ing every async inspect).
- main.py thumbnail_url: filter out local:// URLs from list response.

Frontend:
- api.ts: dynamic timeout for /inspect — sync mode 5min minimum or 60s
per file (was 60s fixed, multi-photo always timed out client-side).
- use-inspection-polling.ts: maxDuration 3min -> 10min (20 foto CPU batch
can take 12min).
- SeverityBadge: guard undefined level (Roboflow path) — was crashing
React tree -> ErrorBoundary -> 'sunucu hatasi' UI.
- CostDisplay: guard undefined total_cost_range_tl (Roboflow path).
- ImageWithOverlay: render placeholder for local:// URLs instead of
silently broken <img>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

apps/web/lib/api.ts CHANGED
@@ -347,11 +347,18 @@ export const inspections = {
347
  // it automatically so that the multipart boundary token is included in
348
  // the header. Setting `Content-Type: multipart/form-data` here strips the
349
  // boundary and the backend rejects the upload as malformed.
 
 
 
 
 
 
350
  const res = await client().post<
351
  InspectionCreateResponse | SyncInspectionResponse
352
  >(path, form, {
353
  headers: apiKey ? { 'X-API-Key': apiKey } : undefined,
354
  signal,
 
355
  onUploadProgress: (evt) => {
356
  if (onUploadProgress && evt.total) {
357
  onUploadProgress(evt.loaded, evt.total);
 
347
  // it automatically so that the multipart boundary token is included in
348
  // the header. Setting `Content-Type: multipart/form-data` here strips the
349
  // boundary and the backend rejects the upload as malformed.
350
+ // CPU-only HF Spaces: tek inference 9-36s; N foto * 45s + upload süresi
351
+ // global 60s timeout'u aşar → "Bağlantı zaman aşımı" yanlış göstergesi.
352
+ // Per-call uzatma: en az 5dk, foto başına 60s. Sync mode'da kritik.
353
+ const dynamicTimeout = mode === 'sync'
354
+ ? Math.max(300_000, files.length * 60_000)
355
+ : 120_000;
356
  const res = await client().post<
357
  InspectionCreateResponse | SyncInspectionResponse
358
  >(path, form, {
359
  headers: apiKey ? { 'X-API-Key': apiKey } : undefined,
360
  signal,
361
+ timeout: dynamicTimeout,
362
  onUploadProgress: (evt) => {
363
  if (onUploadProgress && evt.total) {
364
  onUploadProgress(evt.loaded, evt.total);
apps/web/lib/use-inspection-polling.ts CHANGED
@@ -25,7 +25,8 @@ export interface UseInspectionPollingOptions {
25
  intervalMs?: number;
26
  /**
27
  * Max polling duration in ms before giving up.
28
- * Default 180_000 (3 min) — large enough for 20-image async batches.
 
29
  */
30
  maxDurationMs?: number;
31
  /** Max single interval after exponential backoff. Default 8000. */
@@ -57,7 +58,7 @@ export function useInspectionPolling(
57
  ): PollingReturn {
58
  const {
59
  intervalMs = 1_500,
60
- maxDurationMs = 180_000,
61
  maxIntervalMs = 8_000,
62
  enabled = true,
63
  } = opts;
 
25
  intervalMs?: number;
26
  /**
27
  * Max polling duration in ms before giving up.
28
+ * Default 600_000 (10 min) — CPU inference can be 9-36s/foto; 20 foto
29
+ * batch = up to 12 dakika. 3dk default kullanıcıyı erken "timeout" yapıyordu.
30
  */
31
  maxDurationMs?: number;
32
  /** Max single interval after exponential backoff. Default 8000. */
 
58
  ): PollingReturn {
59
  const {
60
  intervalMs = 1_500,
61
+ maxDurationMs = 600_000,
62
  maxIntervalMs = 8_000,
63
  enabled = true,
64
  } = opts;
packages/ui/src/components/CostDisplay.tsx CHANGED
@@ -22,8 +22,13 @@ const CONF_COLOR: Record<'high' | 'medium' | 'low', string> = {
22
  };
23
 
24
  export function CostDisplay({ summary, className }: Props) {
25
- const [min, max] = summary.total_cost_range_tl;
26
- const mid = summary.total_cost_midpoint_tl ?? (min + max) / 2;
 
 
 
 
 
27
 
28
  return (
29
  <div
 
22
  };
23
 
24
  export function CostDisplay({ summary, className }: Props) {
25
+ // Guard: Roboflow / pretrained path total_cost_range_tl döndürmeyebilir;
26
+ // undefined destructure crash önlenir.
27
+ const range = Array.isArray(summary?.total_cost_range_tl)
28
+ ? summary.total_cost_range_tl
29
+ : [0, 0];
30
+ const [min, max] = [Number(range[0]) || 0, Number(range[1]) || 0];
31
+ const mid = summary?.total_cost_midpoint_tl ?? (min + max) / 2;
32
 
33
  return (
34
  <div
packages/ui/src/components/ImageWithOverlay.tsx CHANGED
@@ -177,18 +177,26 @@ export function ImageWithOverlay({
177
  )}
178
  style={{ aspectRatio }}
179
  >
180
- <img
181
- src={imageUrl}
182
- alt={alt}
183
- draggable={false}
184
- loading="lazy"
185
- decoding="async"
186
- className="block h-full w-full select-none object-contain"
187
- onLoad={(e) => {
188
- const t = e.currentTarget;
189
- setImgDim({ w: t.naturalWidth, h: t.naturalHeight });
190
- }}
191
- />
 
 
 
 
 
 
 
 
192
  <canvas
193
  ref={canvasRef}
194
  className="pointer-events-none absolute inset-0 h-full w-full"
 
177
  )}
178
  style={{ aspectRatio }}
179
  >
180
+ {/* Guard: backend STORAGE_OPTIONAL'da `local://skipped/...` URL'i
181
+ dönebilir; browser bunu fetch edemez — placeholder göster. */}
182
+ {imageUrl && !imageUrl.startsWith('local://') ? (
183
+ <img
184
+ src={imageUrl}
185
+ alt={alt}
186
+ draggable={false}
187
+ loading="lazy"
188
+ decoding="async"
189
+ className="block h-full w-full select-none object-contain"
190
+ onLoad={(e) => {
191
+ const t = e.currentTarget;
192
+ setImgDim({ w: t.naturalWidth, h: t.naturalHeight });
193
+ }}
194
+ />
195
+ ) : (
196
+ <div className="flex h-full w-full items-center justify-center bg-slate-100 text-sm text-slate-500">
197
+ Görsel kaydedilmedi (storage opsiyonel mod)
198
+ </div>
199
+ )}
200
  <canvas
201
  ref={canvasRef}
202
  className="pointer-events-none absolute inset-0 h-full w-full"
packages/ui/src/components/SeverityBadge.tsx CHANGED
@@ -44,6 +44,9 @@ export function SeverityBadge({
44
  showDot = false,
45
  showIcon = true,
46
  }: Props) {
 
 
 
47
  const Icon = ICONS[level];
48
  return (
49
  <span
@@ -57,7 +60,7 @@ export function SeverityBadge({
57
  {showDot && (
58
  <span className={cn('h-1.5 w-1.5 rounded-full', DOT_COLORS[level])} aria-hidden />
59
  )}
60
- {showIcon && (
61
  <Icon
62
  className={cn(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5', ICON_COLORS[level])}
63
  aria-hidden
 
44
  showDot = false,
45
  showIcon = true,
46
  }: Props) {
47
+ // Guard: backend bazı path'lerde severity dönmeyebilir (Roboflow) — null
48
+ // veya bilinmeyen değer için render yapma (React.createElement crash önlenir).
49
+ if (!level || !(level in STYLES)) return null;
50
  const Icon = ICONS[level];
51
  return (
52
  <span
 
60
  {showDot && (
61
  <span className={cn('h-1.5 w-1.5 rounded-full', DOT_COLORS[level])} aria-hidden />
62
  )}
63
+ {showIcon && Icon && (
64
  <Icon
65
  className={cn(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5', ICON_COLORS[level])}
66
  aria-hidden
services/backend/config.py CHANGED
@@ -101,9 +101,12 @@ class Settings(BaseSettings):
101
  ml_unload_after_inference: bool = False
102
 
103
  # ---- S3 storage ----
104
- # Render production: AWS S3 ya da Cloudflare R2. Dev: MinIO.
105
  s3_endpoint: str = "http://minio:9000"
106
- s3_public_endpoint: str = "http://localhost:9000"
 
 
 
107
  s3_access_key: str = "minioadmin"
108
  s3_secret_key: str = "minioadmin"
109
  s3_bucket: str = "inspections"
 
101
  ml_unload_after_inference: bool = False
102
 
103
  # ---- S3 storage ----
104
+ # Production: AWS S3 / R2 / B2. Dev: MinIO.
105
  s3_endpoint: str = "http://minio:9000"
106
+ # NOT: production'da MUTLAKA S3_PUBLIC_ENDPOINT env'i set edilmeli;
107
+ # default localhost'tu — kullanici-yuzlu image URL'leri prod'da kirildi.
108
+ # Bos string fallback: storage.get_image_url s3_endpoint'i kullanir.
109
+ s3_public_endpoint: str = ""
110
  s3_access_key: str = "minioadmin"
111
  s3_secret_key: str = "minioadmin"
112
  s3_bucket: str = "inspections"
services/backend/main.py CHANGED
@@ -360,8 +360,10 @@ def update_inspection(inspection_id: str, **fields) -> None:
360
  return
361
  sets = []
362
  values: list = []
 
 
363
  for k, v in fields.items():
364
- if k in ("result", "image_urls"):
365
  v = _json.dumps(v) if v is not None else None
366
  sets.append(f"{k} = %s")
367
  values.append(v)
@@ -959,34 +961,81 @@ async def _process_sync(files: List[UploadFile], auth: AuthContext,
959
  results: List[dict] = []
960
  per_image: List[dict] = []
961
 
 
962
  for i, f in enumerate(files):
963
- content, url = await _store_upload(f, inspection_id, i)
 
 
 
 
 
 
 
 
 
 
 
 
 
964
  image_urls.append(url)
965
- img = _decode_image(content, i)
 
 
 
 
 
 
 
 
 
 
966
  # GPU inference event-loop'u bloklamasin -> threadpool
 
967
  try:
968
- # ml_pipeline.analyze(image, retries=2, source=...)
969
  r = await asyncio.to_thread(ml_pipeline.analyze, img, 2, model)
970
  except RuntimeError as e:
971
- # ML pipeline yuklenememis (pipeline.py yok vb)
972
- logger.error("ML pipeline runtime hatasi: %s", e)
973
- raise HTTPException(status_code=503, detail="ML servisi su an kullanilamiyor")
 
 
 
 
 
 
 
 
974
  except Exception as e: # noqa: BLE001
975
- # CUDA OOM, model exception vs — kullaniciya net mesaj
976
  msg = str(e).lower()
977
  if "out of memory" in msg or "cuda" in msg:
978
- logger.error("GPU OOM rid-unknown: %s", e)
979
- raise HTTPException(status_code=503, detail="GPU bellegi yetersiz, daha kucuk goruntu deneyin")
980
- logger.exception("ML analyze hatasi: %s", e)
981
- raise HTTPException(status_code=500, detail=f"Analiz hatasi: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
982
  if isinstance(r, dict):
983
  img_blk = r.get("image") if isinstance(r.get("image"), dict) else {}
984
- # Pipeline'in legacy "<inline>" donmesi durumunda override et.
985
  if (not img_blk.get("url")) or img_blk.get("url") == "<inline>":
986
  img_blk["url"] = url
987
  r["image"] = img_blk
988
  results.append(r)
989
- # Per-image kart kaydi (frontend "N foto = N sonuc" beklentisi).
990
  per_image.append({
991
  "index": i,
992
  "url": url,
@@ -998,7 +1047,20 @@ async def _process_sync(files: List[UploadFile], auth: AuthContext,
998
  "multi_part_damages": (r.get("multi_part_damages") if isinstance(r, dict) else []) or [],
999
  })
1000
 
1001
- aggregated = aggregate_results(results) if len(results) > 1 else dict(results[0])
 
 
 
 
 
 
 
 
 
 
 
 
 
1002
  # inspection_id'yi zorla yerlestir
1003
  aggregated["inspection_id"] = inspection_id
1004
  aggregated["images"] = per_image
@@ -1267,7 +1329,8 @@ async def list_inspections(
1267
  iu = r.get("image_urls")
1268
  if isinstance(iu, list) and iu:
1269
  cand = iu[0]
1270
- if isinstance(cand, str) and cand and cand != "<inline>":
 
1271
  thumb = cand
1272
 
1273
  inspection_id_val = r["id"] if "id" in r else r.get("inspection_id")
 
360
  return
361
  sets = []
362
  values: list = []
363
+ # JSONB sutunlar — psycopg2 dict adapt edemiyor, manuel json-dump.
364
+ _JSONB_COLS = {"result", "image_urls", "model_versions", "metadata"}
365
  for k, v in fields.items():
366
+ if k in _JSONB_COLS:
367
  v = _json.dumps(v) if v is not None else None
368
  sets.append(f"{k} = %s")
369
  values.append(v)
 
961
  results: List[dict] = []
962
  per_image: List[dict] = []
963
 
964
+ failed_indices: List[int] = []
965
  for i, f in enumerate(files):
966
+ try:
967
+ content, url = await _store_upload(f, inspection_id, i)
968
+ except HTTPException:
969
+ raise # 400/413 - validation, kullaniciya net don
970
+ except Exception as e: # noqa: BLE001
971
+ logger.exception("Store upload hatasi index=%d: %s", i, e)
972
+ per_image.append({
973
+ "index": i, "url": None, "status": "failed",
974
+ "error": f"Goruntu yuklenemedi: {e}",
975
+ "image": {"url": None}, "parts": [], "summary": {},
976
+ "unassigned_damages": [], "multi_part_damages": [],
977
+ })
978
+ failed_indices.append(i)
979
+ continue
980
  image_urls.append(url)
981
+ try:
982
+ img = _decode_image(content, i)
983
+ except HTTPException as e:
984
+ per_image.append({
985
+ "index": i, "url": url, "status": "failed",
986
+ "error": str(e.detail),
987
+ "image": {"url": url}, "parts": [], "summary": {},
988
+ "unassigned_damages": [], "multi_part_damages": [],
989
+ })
990
+ failed_indices.append(i)
991
+ continue
992
  # GPU inference event-loop'u bloklamasin -> threadpool
993
+ # Per-image error containment: tek foto fail tum batch'i ucurmasin.
994
  try:
 
995
  r = await asyncio.to_thread(ml_pipeline.analyze, img, 2, model)
996
  except RuntimeError as e:
997
+ logger.error("ML pipeline runtime hatasi index=%d: %s", i, e)
998
+ if len(files) == 1:
999
+ raise HTTPException(status_code=503, detail="ML servisi su an kullanilamiyor")
1000
+ per_image.append({
1001
+ "index": i, "url": url, "status": "failed",
1002
+ "error": f"ML servisi: {e}",
1003
+ "image": {"url": url}, "parts": [], "summary": {},
1004
+ "unassigned_damages": [], "multi_part_damages": [],
1005
+ })
1006
+ failed_indices.append(i)
1007
+ continue
1008
  except Exception as e: # noqa: BLE001
 
1009
  msg = str(e).lower()
1010
  if "out of memory" in msg or "cuda" in msg:
1011
+ logger.error("GPU OOM index=%d: %s", i, e)
1012
+ if len(files) == 1:
1013
+ raise HTTPException(status_code=503, detail="GPU bellegi yetersiz, daha kucuk goruntu deneyin")
1014
+ per_image.append({
1015
+ "index": i, "url": url, "status": "failed",
1016
+ "error": "GPU bellegi yetersiz",
1017
+ "image": {"url": url}, "parts": [], "summary": {},
1018
+ "unassigned_damages": [], "multi_part_damages": [],
1019
+ })
1020
+ failed_indices.append(i)
1021
+ continue
1022
+ logger.exception("ML analyze hatasi index=%d: %s", i, e)
1023
+ if len(files) == 1:
1024
+ raise HTTPException(status_code=500, detail=f"Analiz hatasi: {e}")
1025
+ per_image.append({
1026
+ "index": i, "url": url, "status": "failed",
1027
+ "error": f"Analiz hatasi: {e}",
1028
+ "image": {"url": url}, "parts": [], "summary": {},
1029
+ "unassigned_damages": [], "multi_part_damages": [],
1030
+ })
1031
+ failed_indices.append(i)
1032
+ continue
1033
  if isinstance(r, dict):
1034
  img_blk = r.get("image") if isinstance(r.get("image"), dict) else {}
 
1035
  if (not img_blk.get("url")) or img_blk.get("url") == "<inline>":
1036
  img_blk["url"] = url
1037
  r["image"] = img_blk
1038
  results.append(r)
 
1039
  per_image.append({
1040
  "index": i,
1041
  "url": url,
 
1047
  "multi_part_damages": (r.get("multi_part_damages") if isinstance(r, dict) else []) or [],
1048
  })
1049
 
1050
+ # En az 1 sonuc yoksa toplu fail; sıralamayı sayfanın bozulmaması için
1051
+ # results bos olsa bile per_image listesi UI'a doner.
1052
+ if not results:
1053
+ raise HTTPException(status_code=500, detail=(
1054
+ f"Hicbir goruntu analiz edilemedi ({len(failed_indices)} adet basarisiz)"
1055
+ ))
1056
+
1057
+ try:
1058
+ aggregated = aggregate_results(results) if len(results) > 1 else dict(results[0])
1059
+ except Exception as e: # noqa: BLE001
1060
+ logger.exception("aggregate_results crash: %s", e)
1061
+ # Fallback: ilk sonucu ham dondur, per_image kullanici icin yeterli
1062
+ aggregated = dict(results[0])
1063
+ aggregated["_aggregation_error"] = str(e)
1064
  # inspection_id'yi zorla yerlestir
1065
  aggregated["inspection_id"] = inspection_id
1066
  aggregated["images"] = per_image
 
1329
  iu = r.get("image_urls")
1330
  if isinstance(iu, list) and iu:
1331
  cand = iu[0]
1332
+ if isinstance(cand, str) and cand and cand != "<inline>" \
1333
+ and not cand.startswith("local://"):
1334
  thumb = cand
1335
 
1336
  inspection_id_val = r["id"] if "id" in r else r.get("inspection_id")
services/backend/ml_service.py CHANGED
@@ -25,6 +25,7 @@ import logging
25
  import sys
26
  import threading
27
  import time
 
28
  from pathlib import Path
29
  from typing import Any, Optional
30
 
@@ -340,8 +341,9 @@ class MLPipeline:
340
  """Pretrained Roboflow scratch_dent v3 modelini HTTP API ile cagir.
341
 
342
  Custom pipeline atlatilir; ne damage modeli ne parts ne severity
343
- yuklenir. Sadece bbox-detection sonucu doner. Frontend bunu
344
- "Roboflow ile basit hasar tespiti" olarak gosterir.
 
345
  """
346
  import cv2 # local import
347
  from roboflow_inference import ( # type: ignore
@@ -361,30 +363,106 @@ class MLPipeline:
361
  raise RuntimeError("Goruntu JPEG encode edilemedi")
362
  img_bytes = buf.tobytes()
363
 
364
- damages = run_roboflow_damage_inference(
365
  img_bytes,
366
  workspace="carpro",
367
  project="car-scratch-and-dent",
368
  version=3,
369
  )
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  return {
372
- "image_size": {"width": int(w), "height": int(h)},
 
373
  "parts": [],
374
- "damages": damages,
 
375
  "summary": {
376
- "total_damage_count": len(damages),
377
- "estimated_repair_days": 0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  "model_source": "roboflow",
379
  "note": (
380
  "Roboflow scratch_dent v3 hosted inference. "
381
- "Parca segmentasyonu ve siddet siniflandirmasi yok; "
382
- "sadece basit hasar bbox tespiti."
383
  ),
384
  },
 
385
  "model_versions": {
386
  "pretrained_source": "roboflow_cardd_scratch_dent_v3",
 
387
  },
 
388
  }
389
 
390
 
@@ -533,29 +611,37 @@ def check_model_files_available(model_id: Optional[str]) -> tuple[bool, str]:
533
  if not model_id or model_id == DEFAULT_MODEL_ID:
534
  return True, ""
535
  try:
536
- from pretrained_registry import REGISTRY # type: ignore
537
- entry = REGISTRY.get(model_id) if isinstance(REGISTRY, dict) else None
538
- if entry is None:
539
- for m in list_available_models():
540
- if m.get("id") == model_id:
541
- if m.get("available") is False:
542
- return False, (
543
- f"'{m.get('name', model_id)}' modeli bu deploy'da "
544
- "indirilmemis. Lutfen 'Kendi Modellerim' (custom) "
545
- "secenegini kullanin."
546
- )
547
- return True, ""
548
- return False, f"Model bulunamadi: {model_id}"
549
- if hasattr(entry, "is_available") and not entry.is_available():
550
- return False, (
551
- f"'{getattr(entry, 'name', model_id)}' modeli bu deploy'da "
552
- "indirilmemis. Lutfen 'Kendi Modellerim' (custom) "
553
- "secenegini kullanin."
554
- )
555
- return True, ""
 
 
 
 
 
 
 
 
556
  except Exception as exc: # noqa: BLE001
557
  logger.warning("check_model_files_available hata: %s", exc)
558
- return True, ""
559
 
560
 
561
  def resolve_model_id(model_id: Optional[str]) -> str:
 
25
  import sys
26
  import threading
27
  import time
28
+ from datetime import datetime, timezone
29
  from pathlib import Path
30
  from typing import Any, Optional
31
 
 
341
  """Pretrained Roboflow scratch_dent v3 modelini HTTP API ile cagir.
342
 
343
  Custom pipeline atlatilir; ne damage modeli ne parts ne severity
344
+ yuklenir. Frontend Inspection schema uyumlu bir cikti uretir:
345
+ Roboflow detection'lari `unassigned_damages` listesine konur
346
+ (parts boş kalir, schema match olur).
347
  """
348
  import cv2 # local import
349
  from roboflow_inference import ( # type: ignore
 
363
  raise RuntimeError("Goruntu JPEG encode edilemedi")
364
  img_bytes = buf.tobytes()
365
 
366
+ raw_damages = run_roboflow_damage_inference(
367
  img_bytes,
368
  workspace="carpro",
369
  project="car-scratch-and-dent",
370
  version=3,
371
  )
372
 
373
+ # TR ceviri + frontend Damage schema'sina map et
374
+ TR = {"dent": "Göçük", "scratch": "Çizik", "damage": "Hasar"}
375
+ SEVERITY_TR = {"hafif": "Hafif", "orta": "Orta", "agir": "Ağır"}
376
+
377
+ unassigned: list[dict[str, Any]] = []
378
+ for d in raw_damages:
379
+ x1, y1, x2, y2 = d.get("bbox", [0, 0, 0, 0])
380
+ bbox_w = max(0.0, x2 - x1)
381
+ bbox_h = max(0.0, y2 - y1)
382
+ area_ratio = (bbox_w * bbox_h) / max(1.0, float(w) * float(h))
383
+ # Basit kural-tabanli severity (confidence + alan)
384
+ conf = float(d.get("confidence", 0.0))
385
+ if area_ratio > 0.15 or conf > 0.85:
386
+ sev_level, sev_conf = "orta", 0.5
387
+ elif area_ratio > 0.05 or conf > 0.7:
388
+ sev_level, sev_conf = "hafif", 0.5
389
+ else:
390
+ sev_level, sev_conf = "hafif", 0.4
391
+
392
+ dtype = d.get("class") or "damage"
393
+ unassigned.append({
394
+ "id": d.get("id", 0),
395
+ "type": dtype,
396
+ "type_tr": TR.get(dtype, dtype.title()),
397
+ "confidence": conf,
398
+ "bbox": d.get("bbox", [0, 0, 0, 0]),
399
+ "polygon": d.get("polygon", []) or [],
400
+ "polygon_normalized": [], # detection-only model
401
+ "area_ratio": area_ratio,
402
+ "severity": {
403
+ "level": sev_level,
404
+ "level_tr": SEVERITY_TR.get(sev_level, sev_level.title()),
405
+ "confidence": sev_conf,
406
+ "method": "rule_based_roboflow",
407
+ },
408
+ "cost": {
409
+ "min_tl": 0,
410
+ "max_tl": 0,
411
+ "confidence": "low",
412
+ "source": "roboflow_no_cost",
413
+ },
414
+ "is_multi_part": False,
415
+ "is_low_confidence_match": False,
416
+ "source": "roboflow",
417
+ })
418
+
419
+ total_damage = len(unassigned)
420
+ most_severe = None
421
+ most_severe_tr = None
422
+ if unassigned:
423
+ # En agir level'i bul
424
+ order = {"hafif": 1, "orta": 2, "agir": 3}
425
+ top = max(unassigned, key=lambda u: order.get(u["severity"]["level"], 0))
426
+ most_severe = top["severity"]["level"]
427
+ most_severe_tr = top["severity"]["level_tr"]
428
+
429
  return {
430
+ "timestamp": datetime.now(timezone.utc).isoformat(),
431
+ "image": {"url": None, "width": int(w), "height": int(h)},
432
  "parts": [],
433
+ "unassigned_damages": unassigned,
434
+ "multi_part_damages": [],
435
  "summary": {
436
+ "total_parts_inspected": 0,
437
+ "damaged_parts_count": 0,
438
+ "clean_parts_count": 0,
439
+ "total_damage_count": total_damage,
440
+ "unknown_part_damages_count": total_damage,
441
+ "multi_part_damages_count": 0,
442
+ "most_severe_level": most_severe,
443
+ "most_severe_level_tr": most_severe_tr,
444
+ "total_damage_area_ratio": sum(u["area_ratio"] for u in unassigned),
445
+ "total_cost_range_tl": [0, 0],
446
+ "total_cost_midpoint_tl": 0,
447
+ "cost_confidence": "low",
448
+ "repair_recommendation": "manual_review" if unassigned else "hasar_yok",
449
+ "repair_recommendation_tr": (
450
+ "Manuel inceleme önerilir (Roboflow: maliyet yok)"
451
+ if unassigned else "Hasar tespit edilmedi"
452
+ ),
453
+ "estimated_repair_days": 1 if unassigned else 0,
454
  "model_source": "roboflow",
455
  "note": (
456
  "Roboflow scratch_dent v3 hosted inference. "
457
+ "Parça segmentasyonu / maliyet hesaplaması yapılmaz."
 
458
  ),
459
  },
460
+ "visualization_urls": {"annotated": None, "parts": None, "damages": None},
461
  "model_versions": {
462
  "pretrained_source": "roboflow_cardd_scratch_dent_v3",
463
+ "requested_model": "pretrained_roboflow_cardd",
464
  },
465
+ "model_source": "roboflow",
466
  }
467
 
468
 
 
611
  if not model_id or model_id == DEFAULT_MODEL_ID:
612
  return True, ""
613
  try:
614
+ # Registry'den entry'i al modul `get_registry()` veya direkt erisim
615
+ # destekliyor. REGISTRY sembolu yok; eski kodda hata kaynagiydi.
616
+ from pretrained_registry import get_registry # type: ignore
617
+ reg = get_registry()
618
+ entry = None
619
+ # PretrainedRegistry sinifinda get() metodu var
620
+ if hasattr(reg, "get"):
621
+ entry = reg.get(model_id)
622
+ # Eger entry uygunsa dosya/key check
623
+ if entry is not None and hasattr(entry, "is_available"):
624
+ if not entry.is_available():
625
+ return False, (
626
+ f"'{getattr(entry, 'name', model_id)}' modeli bu deploy'da "
627
+ "indirilmemis. Lutfen 'Kendi Modellerim' (custom) "
628
+ "secenegini kullanin."
629
+ )
630
+ return True, ""
631
+ # Source-level model_id ise (pretrained_roboflow_cardd vs)
632
+ for m in list_available_models():
633
+ if m.get("id") == model_id:
634
+ if m.get("available") is False:
635
+ return False, (
636
+ f"'{m.get('name', model_id)}' modeli bu deploy'da "
637
+ "indirilmemis. Lutfen 'Kendi Modellerim' (custom) "
638
+ "secenegini kullanin."
639
+ )
640
+ return True, ""
641
+ return False, f"Model bulunamadi: {model_id}"
642
  except Exception as exc: # noqa: BLE001
643
  logger.warning("check_model_files_available hata: %s", exc)
644
+ return True, "" # fail-open; gerçek inference patlarsa orada handle
645
 
646
 
647
  def resolve_model_id(model_id: Optional[str]) -> str:
services/backend/worker.py CHANGED
@@ -36,10 +36,17 @@ logger = logging.getLogger(__name__)
36
 
37
  # ============================ Celery app ============================
38
 
 
 
 
 
 
 
 
39
  celery_app = Celery(
40
  "arac_hasar",
41
- broker=settings.redis_url,
42
- backend=settings.redis_url,
43
  )
44
 
45
  celery_app.conf.update(
@@ -48,15 +55,17 @@ celery_app.conf.update(
48
  result_serializer="json",
49
  timezone="Europe/Istanbul",
50
  enable_utc=True,
51
- # Limitler — uzun pipeline'lara karsi guvenli
52
  task_soft_time_limit=180,
53
  task_time_limit=240,
54
  task_acks_late=True,
55
  task_reject_on_worker_lost=True,
56
- worker_prefetch_multiplier=1, # GPU bound: bir seferde 1 task al
57
- worker_max_tasks_per_child=50, # Memory leak korumasi
58
  broker_connection_retry_on_startup=True,
59
  )
 
 
 
60
 
61
 
62
  # ============================ Pub-sub helper ============================
 
36
 
37
  # ============================ Celery app ============================
38
 
39
+ _broker_url = settings.redis_url
40
+ # Upstash/managed Redis genelde rediss:// (TLS) — kombu default insecure
41
+ # warning veriyor + 'ssl_cert_reqs' eksik error firlatiyor. Explicit set.
42
+ import ssl as _ssl
43
+ _uses_tls = _broker_url.startswith("rediss://")
44
+ _ssl_opts = {"ssl_cert_reqs": _ssl.CERT_NONE} if _uses_tls else None
45
+
46
  celery_app = Celery(
47
  "arac_hasar",
48
+ broker=_broker_url,
49
+ backend=_broker_url,
50
  )
51
 
52
  celery_app.conf.update(
 
55
  result_serializer="json",
56
  timezone="Europe/Istanbul",
57
  enable_utc=True,
 
58
  task_soft_time_limit=180,
59
  task_time_limit=240,
60
  task_acks_late=True,
61
  task_reject_on_worker_lost=True,
62
+ worker_prefetch_multiplier=1,
63
+ worker_max_tasks_per_child=50,
64
  broker_connection_retry_on_startup=True,
65
  )
66
+ if _ssl_opts:
67
+ celery_app.conf.broker_use_ssl = _ssl_opts
68
+ celery_app.conf.redis_backend_use_ssl = _ssl_opts
69
 
70
 
71
  # ============================ Pub-sub helper ============================