senthil2421 commited on
Commit
8302f42
Β·
1 Parent(s): 99e3f1b

fix: remove project dependencies and redundant imports to fix server startup

Browse files
__pycache__/config.cpython-310.pyc CHANGED
Binary files a/__pycache__/config.cpython-310.pyc and b/__pycache__/config.cpython-310.pyc differ
 
__pycache__/main.cpython-310.pyc CHANGED
Binary files a/__pycache__/main.cpython-310.pyc and b/__pycache__/main.cpython-310.pyc differ
 
adapters/__init__.py ADDED
File without changes
adapters/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (143 Bytes). View file
 
adapters/__pycache__/base.cpython-310.pyc ADDED
Binary file (1.31 kB). View file
 
adapters/__pycache__/hf_adapter.cpython-310.pyc ADDED
Binary file (13 kB). View file
 
adapters/__pycache__/onnx_adapter.cpython-310.pyc ADDED
Binary file (5.27 kB). View file
 
adapters/__pycache__/roboflow_adapter.cpython-310.pyc ADDED
Binary file (10.9 kB). View file
 
adapters/base.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ adapters/base.py β€” Abstract base class every source adapter must implement.
3
+ Enforces a stable contract so the registry never knows which adapter runs.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+ from models.model import Model
10
+
11
+
12
+ class BaseAdapter(ABC):
13
+ """Fetch models from an external source and normalize to the Model schema."""
14
+
15
+ source_name: str = "unknown"
16
+
17
+ @abstractmethod
18
+ async def fetch_models(self) -> list[Model]:
19
+ """Return a list of normalized Model objects from the source."""
20
+ ...
21
+
22
+ def _format_size(self, bytes_: int) -> str:
23
+ """Human-readable file size."""
24
+ for unit in ("B", "KB", "MB", "GB", "TB"):
25
+ if bytes_ < 1024:
26
+ return f"{bytes_:.1f} {unit}"
27
+ bytes_ //= 1024
28
+ return f"{bytes_} PB"
adapters/hf_adapter.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ adapters/hf_adapter.py β€” Hugging Face Hub adapter.
3
+ Fetches real models via the public HF API and normalises them to our schema.
4
+
5
+ Rate-limits respected via polite delays. Requires no authentication for
6
+ publicly accessible models; set HF_TOKEN env var for higher rate-limits.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import re
12
+ from typing import Any
13
+
14
+
15
+ def _is_shard_file(filename: str) -> bool:
16
+ """Return True for sharded weight files like model-00001-of-00003.safetensors."""
17
+ return bool(re.search(r"-\d{5}-of-\d{5}\.", filename))
18
+
19
+ import httpx
20
+ from tenacity import retry, stop_after_attempt, wait_exponential
21
+
22
+ from adapters.base import BaseAdapter
23
+ from config import settings
24
+ from models.model import Model, ModelMetrics, ModelVersion
25
+ from observability.logger import get_logger
26
+
27
+ log = get_logger("hf_adapter")
28
+
29
+ # ── Task mapping: HF pipeline_tag β†’ our internal task ─────────────────────────
30
+ HF_TASK_MAP: dict[str, str] = {
31
+ "object-detection": "detection",
32
+ "image-classification": "classification",
33
+ "image-segmentation": "segmentation",
34
+ "text-to-image": "generation",
35
+ "image-to-image": "generation",
36
+ "image-feature-extraction": "embedding",
37
+ }
38
+
39
+ # Tasks we actively fetch
40
+ FETCH_TASKS: list[str] = list(HF_TASK_MAP.keys())
41
+
42
+ # ── Framework detection ────────────────────────────────────────────────────────
43
+ def _detect_framework(tags: list[str], model_id: str) -> str:
44
+ tag_str = " ".join(tags + [model_id]).lower()
45
+ if "onnx" in tag_str: return "onnx"
46
+ if "tflite" in tag_str: return "tflite"
47
+ if "coreml" in tag_str: return "coreml"
48
+ if "tensorflow" in tag_str or "tf" in tag_str: return "tensorflow"
49
+ return "pytorch" # HF default
50
+
51
+ # ── Hardware detection ─────────────────────────────────────────────────────────
52
+ def _detect_hardware(tags: list[str]) -> list[str]:
53
+ hw: list[str] = []
54
+ tag_str = " ".join(tags).lower()
55
+ if any(k in tag_str for k in ("cuda", "gpu")): hw.append("gpu")
56
+ if "edge" in tag_str or "mobile" in tag_str: hw.append("edge")
57
+ if "cpu" in tag_str: hw.append("cpu")
58
+ if not hw: hw.append("gpu") # safe default
59
+ return hw
60
+
61
+ # ── Internal tag normalisation ─────────────────────────────────────────────────
62
+ QUALITY_TAG_MAP = {
63
+ "state-of-the-art": "sota",
64
+ "lightweight": "lightweight",
65
+ "tiny": "tiny",
66
+ "fast": "fastest",
67
+ "real-time": "real-time",
68
+ "accuracy": "high-accuracy",
69
+ }
70
+
71
+ def _normalise_tags(raw_tags: list[str], pipeline: str) -> list[str]:
72
+ out: list[str] = []
73
+ for t in raw_tags:
74
+ t_lower = t.lower()
75
+ for keyword, mapped in QUALITY_TAG_MAP.items():
76
+ if keyword in t_lower:
77
+ out.append(mapped)
78
+ # keep relevant library / dataset tags
79
+ if any(t_lower.startswith(p) for p in ("dataset:", "license:", "language:")):
80
+ continue
81
+ out.append(t_lower)
82
+ # add pipeline as tag
83
+ if pipeline:
84
+ out.append(pipeline.replace("-", "_"))
85
+ return list(dict.fromkeys(out)) # deduplicate, preserve order
86
+
87
+
88
+ class HFAdapter(BaseAdapter):
89
+ source_name = "hf"
90
+
91
+ def __init__(self) -> None:
92
+ headers = {"Accept": "application/json"}
93
+ if settings.hf_token:
94
+ headers["Authorization"] = f"Bearer {settings.hf_token}"
95
+ self._client = httpx.AsyncClient(
96
+ base_url=settings.hf_api_base,
97
+ headers=headers,
98
+ timeout=30,
99
+ )
100
+
101
+ @retry(
102
+ stop=stop_after_attempt(3),
103
+ wait=wait_exponential(multiplier=1, min=2, max=10),
104
+ reraise=True,
105
+ )
106
+ async def _fetch_task_page(
107
+ self, pipeline_tag: str, limit: int = 100
108
+ ) -> list[dict[str, Any]]:
109
+ params = {
110
+ "pipeline_tag": pipeline_tag,
111
+ "sort": "downloads",
112
+ "direction": -1, # descending
113
+ "limit": limit,
114
+ "full": "True",
115
+ }
116
+ log.info("hf_fetch_task", pipeline_tag=pipeline_tag, limit=limit)
117
+ resp = await self._client.get("/models", params=params)
118
+ resp.raise_for_status()
119
+ return resp.json()
120
+
121
+ @retry(
122
+ stop=stop_after_attempt(3),
123
+ wait=wait_exponential(multiplier=1, min=2, max=10),
124
+ reraise=True,
125
+ )
126
+ async def _fetch_model_detail(self, model_id: str) -> dict[str, Any]:
127
+ resp = await self._client.get(f"/models/{model_id}", params={"full": "True"})
128
+ resp.raise_for_status()
129
+ raw = resp.json()
130
+
131
+ siblings: list[dict[str, Any]] = raw.get("siblings") or []
132
+ has_any_size = any(isinstance(s, dict) and s.get("size") for s in siblings)
133
+ if not has_any_size:
134
+ try:
135
+ tree = await self._fetch_model_tree(model_id, revision="main")
136
+ size_by_path: dict[str, int] = {
137
+ (t.get("path") or ""): int(t.get("size") or 0)
138
+ for t in (tree or [])
139
+ if isinstance(t, dict)
140
+ }
141
+
142
+ patched: list[dict[str, Any]] = []
143
+ for s in siblings:
144
+ if not isinstance(s, dict):
145
+ continue
146
+ fn = s.get("rfilename") or s.get("path") or ""
147
+ if fn and not s.get("size") and fn in size_by_path:
148
+ s = {**s, "size": size_by_path[fn]}
149
+ patched.append(s)
150
+ raw["siblings"] = patched
151
+ except Exception:
152
+ pass
153
+
154
+ return raw
155
+
156
+ @retry(
157
+ stop=stop_after_attempt(3),
158
+ wait=wait_exponential(multiplier=1, min=2, max=10),
159
+ reraise=True,
160
+ )
161
+ async def _fetch_model_tree(self, model_id: str, *, revision: str = "main") -> list[dict[str, Any]]:
162
+ resp = await self._client.get(f"/models/{model_id}/tree/{revision}")
163
+ resp.raise_for_status()
164
+ data = resp.json()
165
+ if isinstance(data, list):
166
+ return data
167
+ return []
168
+
169
+ def _parse_safe_tensors_size(self, siblings: list[dict]) -> int:
170
+ """Estimate model size from sibling file list."""
171
+ total = 0
172
+ weight_exts = (".pt", ".pth", ".safetensors", ".bin", ".onnx", ".tflite", ".mlmodel")
173
+ for s in siblings or []:
174
+ filename = s.get("rfilename", "").lower()
175
+ if filename.endswith(weight_exts):
176
+ total += s.get("size", 0)
177
+
178
+ if total > 0:
179
+ return total
180
+
181
+ # If no size found in siblings, check if it's in the root dict (sometimes HF API does this)
182
+ return 0 # Return 0 if not found, we'll handle fallback in _make_model
183
+
184
+ @retry(
185
+ stop=stop_after_attempt(3),
186
+ wait=wait_exponential(multiplier=1, min=2, max=10),
187
+ reraise=True,
188
+ )
189
+ async def _fetch_model_card(self, model_id: str) -> str:
190
+ """Fetch model card (README.md) content for real-time description."""
191
+ url = f"{settings.hf_hub_url}/{model_id}/raw/main/README.md"
192
+ try:
193
+ resp = await self._client.get(url)
194
+ if resp.status_code == 200:
195
+ return resp.text
196
+ except Exception:
197
+ pass
198
+ return ""
199
+
200
+ def _extract_description(self, readme: str, raw: dict[str, Any]) -> str:
201
+ """Extract a clean description from README or card data."""
202
+ if readme:
203
+ # Simple heuristic: take first paragraph that isn't frontmatter
204
+ lines = readme.split("\n")
205
+ in_frontmatter = False
206
+ for line in lines:
207
+ if line.strip() == "---":
208
+ in_frontmatter = not in_frontmatter
209
+ continue
210
+ if not in_frontmatter and line.strip() and not line.startswith("#"):
211
+ return line.strip()[:500]
212
+
213
+ card_data = raw.get("cardData") or {}
214
+ description: str = (
215
+ (card_data.get("summary") or "")
216
+ or (card_data.get("description") or "")
217
+ or (raw.get("description") or "")
218
+ ).strip()
219
+ return description
220
+
221
+ def _estimate_metrics(self, model_id: str, task: str) -> ModelMetrics:
222
+ """
223
+ Product-Grade Metrics Estimation.
224
+ Uses model name heuristics to provide realistic data for common architectures.
225
+ """
226
+ metrics = ModelMetrics()
227
+ m_id = model_id.lower()
228
+
229
+ # Base latency/vram estimates by architecture
230
+ if "vit" in m_id or "dinov2" in m_id:
231
+ metrics.latency_ms = 45.5 if "base" in m_id else 85.2 if "large" in m_id else 25.0
232
+ metrics.vram_gb = 1.2 if "base" in m_id else 2.4 if "large" in m_id else 0.8
233
+ metrics.accuracy = 82.4 if "base" in m_id else 84.5
234
+ elif "segformer" in m_id:
235
+ # b0, b1, b2, b3, b4, b5
236
+ if "b0" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.accuracy = 12.0, 0.4, 35.0
237
+ elif "b1" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.accuracy = 18.0, 0.6, 40.0
238
+ elif "b5" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.accuracy = 45.0, 1.8, 50.0
239
+ else: metrics.latency_ms, metrics.vram_gb, metrics.accuracy = 25.0, 1.0, 42.0
240
+ elif "convnext" in m_id:
241
+ metrics.latency_ms = 15.0 if "tiny" in m_id else 30.0
242
+ metrics.vram_gb = 0.5 if "tiny" in m_id else 1.2
243
+ metrics.accuracy = 81.0 if "tiny" in m_id else 83.5
244
+ elif "yolo" in m_id:
245
+ # n, s, m, l, x
246
+ if "yolov8n" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.mAP = 1.5, 0.2, 37.3
247
+ elif "yolov8s" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.mAP = 2.8, 0.4, 44.9
248
+ elif "yolov8m" in m_id: metrics.latency_ms, metrics.vram_gb, metrics.mAP = 6.2, 0.9, 50.2
249
+ else: metrics.latency_ms, metrics.vram_gb, metrics.mAP = 10.0, 1.5, 52.0
250
+
251
+ # Generic task-based fallbacks if still empty
252
+ if metrics.latency_ms is None:
253
+ if task == "classification": metrics.latency_ms, metrics.accuracy = 20.0, 75.0
254
+ elif task == "detection": metrics.latency_ms, metrics.mAP = 35.0, 45.0
255
+ elif task == "embedding": metrics.latency_ms = 40.0
256
+ elif task == "generation": metrics.latency_ms = 1500.0
257
+
258
+ return metrics
259
+
260
+ def _make_model(self, raw: dict[str, Any], pipeline_tag: str) -> Model | None:
261
+ model_id: str = raw.get("id") or raw.get("modelId", "")
262
+ if not model_id:
263
+ return None
264
+
265
+ task = HF_TASK_MAP.get(pipeline_tag)
266
+ if not task:
267
+ return None
268
+ tags_raw: list[str] = raw.get("tags") or []
269
+ framework = _detect_framework(tags_raw, model_id)
270
+ hardware = _detect_hardware(tags_raw)
271
+ tags = _normalise_tags(tags_raw, pipeline_tag)
272
+
273
+ # Size
274
+ siblings: list[dict] = raw.get("siblings") or []
275
+ size = self._parse_safe_tensors_size(siblings)
276
+ if size == 0:
277
+ # Fallback based on model type if size not found
278
+ if "large" in model_id.lower(): size = 1_200_000_000
279
+ elif "base" in model_id.lower(): size = 500_000_000
280
+ elif "small" in model_id.lower() or "tiny" in model_id.lower(): size = 150_000_000
281
+ else: size = 450_000_000 # More realistic general default than exactly 500MB
282
+
283
+ # Provider β€” author part of model_id
284
+ provider = model_id.split("/")[0] if "/" in model_id else "community"
285
+
286
+ # safe name
287
+ name = model_id.split("/")[-1] if "/" in model_id else model_id
288
+ # Clean ugly names
289
+ name = re.sub(r"[-_]+", "-", name).strip("-")
290
+
291
+ downloads = raw.get("downloads") or 0
292
+ likes = raw.get("likes") or 0
293
+
294
+ # Fabricate a sensible version from last modified
295
+ last_mod: str = raw.get("lastModified") or raw.get("createdAt") or ""
296
+ release_date = last_mod[:10] if last_mod else "2024-01-01"
297
+ sha8 = (raw.get("sha") or "main")[:8]
298
+
299
+ # Build versions from weight files in the repo (one per distinct weight file)
300
+ weight_exts = (".pt", ".pth", ".safetensors", ".bin", ".onnx", ".tflite", ".mlmodel")
301
+ weight_files = [
302
+ s for s in siblings
303
+ if s.get("rfilename", "").lower().endswith(weight_exts)
304
+ and not _is_shard_file(s.get("rfilename", ""))
305
+ ]
306
+
307
+ if len(weight_files) > 1:
308
+ versions = []
309
+ for s in weight_files[:15]:
310
+ filename = s["rfilename"]
311
+ # Detect variant from filename (n, s, m, l, x, or specific labels)
312
+ variant_label = "Stable"
313
+ fn_lower = filename.lower()
314
+ if any(x in fn_lower for x in ["-n.", "_n.", "nano"]): variant_label = "Nano"
315
+ elif any(x in fn_lower for x in ["-s.", "_s.", "small"]): variant_label = "Small"
316
+ elif any(x in fn_lower for x in ["-m.", "_m.", "medium"]): variant_label = "Medium"
317
+ elif any(x in fn_lower for x in ["-l.", "_l.", "large"]): variant_label = "Large"
318
+ elif any(x in fn_lower for x in ["-x.", "_x.", "xlarge", "huge"]): variant_label = "XLarge"
319
+
320
+ versions.append(ModelVersion(
321
+ version=filename.replace(".", "_"),
322
+ label=variant_label,
323
+ description=f"Model variant: {filename}",
324
+ releaseDate=release_date,
325
+ changelog=None,
326
+ ))
327
+ else:
328
+ versions = [
329
+ ModelVersion(
330
+ version=sha8,
331
+ label="Latest",
332
+ description="Primary model weight file.",
333
+ releaseDate=release_date,
334
+ changelog=None,
335
+ )
336
+ ]
337
+
338
+ # Description from card data
339
+ description = self._extract_description("", raw)
340
+ if not description:
341
+ description = f"{task.capitalize()} model by {provider}."
342
+
343
+ # Metrics Estimation
344
+ metrics = self._estimate_metrics(model_id, task)
345
+
346
+ return Model(
347
+ id = model_id.replace("/", "_").lower(),
348
+ name = name,
349
+ task = task,
350
+ framework = framework,
351
+ source = "hf",
352
+ provider = provider,
353
+ description = description,
354
+ download_url = f"https://huggingface.co/{model_id}",
355
+ size = size,
356
+ size_label = self._format_size(size),
357
+ tags = tags,
358
+ hardware = hardware,
359
+ status = "available",
360
+ downloaded = False,
361
+ downloads = downloads,
362
+ rating = min(5.0, (likes / 200) + 3.5) if likes else None,
363
+ liked = False,
364
+ metrics = metrics,
365
+ versions = versions,
366
+ )
367
+
368
+ async def fetch_models(self) -> list[Model]:
369
+ models: list[Model] = []
370
+ seen_ids: set[str] = set()
371
+
372
+ for pipeline_tag in FETCH_TASKS:
373
+ try:
374
+ raw_list = await self._fetch_task_page(
375
+ pipeline_tag, limit=settings.hf_models_per_task
376
+ )
377
+ for idx, raw in enumerate(raw_list):
378
+ # Enrich top-N per task with full model detail so siblings include sizes.
379
+ if idx < 10:
380
+ original_id = raw.get("id") or raw.get("modelId")
381
+ if original_id:
382
+ try:
383
+ raw = await self._fetch_model_detail(original_id)
384
+ except Exception:
385
+ pass
386
+
387
+ m = self._make_model(raw, pipeline_tag)
388
+ if m and m.id not in seen_ids:
389
+ # Try to fetch real-time description for the first 5 models of each task
390
+ if len([mod for mod in models if mod.task == m.task]) < 5:
391
+ original_id = raw.get("id") or raw.get("modelId")
392
+ if original_id:
393
+ readme = await self._fetch_model_card(original_id)
394
+ if readme:
395
+ m.description = self._extract_description(readme, raw)
396
+
397
+ seen_ids.add(m.id)
398
+ models.append(m)
399
+ # Be polite to HF API
400
+ await asyncio.sleep(0.3)
401
+ except Exception as exc:
402
+ log.warning(
403
+ "hf_fetch_task_failed",
404
+ pipeline_tag=pipeline_tag,
405
+ error=str(exc),
406
+ )
407
+
408
+ log.info("hf_fetch_complete", total=len(models))
409
+ return models
410
+
411
+ async def __aenter__(self) -> "HFAdapter":
412
+ return self
413
+
414
+ async def __aexit__(self, *_: Any) -> None:
415
+ await self._client.aclose()
adapters/onnx_adapter.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ adapters/onnx_adapter.py β€” ONNX Model Zoo adapter.
3
+ Fetches the curated list of ONNX Zoo models from the GitHub API.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from tenacity import retry, stop_after_attempt, wait_exponential
11
+
12
+ from adapters.base import BaseAdapter
13
+ from models.model import Model, ModelMetrics, ModelVersion
14
+ from observability.logger import get_logger
15
+
16
+ log = get_logger("onnx_adapter")
17
+
18
+ # Curated ONNX Zoo models with metadata + download URLs (GitHub API is rate-limited without auth)
19
+ ONNX_CURATED: list[dict[str, Any]] = [
20
+ {
21
+ "id": "onnx_resnet50",
22
+ "name": "ResNet-50",
23
+ "task": "classification",
24
+ "provider": "ONNX Zoo",
25
+ "description": "ResNet-50 v1 image classification model in ONNX format.",
26
+ "download_url": "https://github.com/onnx/models/raw/main/validated/vision/classification/resnet/model/resnet50-v2-7.onnx",
27
+ "size": 102_000_000,
28
+ "tags": ["resnet", "imagenet", "classification"],
29
+ "hardware": ["gpu", "cpu"],
30
+ "metrics": {"latency_ms": 14.2, "top1": 74.9},
31
+ "downloads": 250_000,
32
+ "versions": [{"version": "1.0", "label": "Stable", "releaseDate": "2023-06-01"}],
33
+ },
34
+ {
35
+ "id": "onnx_yolov8n",
36
+ "name": "YOLOv8n",
37
+ "task": "detection",
38
+ "provider": "Ultralytics",
39
+ "description": "Ultralytics YOLOv8 Nano β€” real-time object detection, ONNX export.",
40
+ "download_url": "https://github.com/ultralytics/yolov8/releases/download/v8.0.0/yolov8n.onnx",
41
+ "size": 6_200_000,
42
+ "tags": ["yolo", "real-time", "fastest", "edge"],
43
+ "hardware": ["gpu", "cpu", "edge"],
44
+ "metrics": {"latency_ms": 3.1, "mAP": 37.3},
45
+ "downloads": 420_000,
46
+ "versions": [{"version": "8.0", "label": "Latest", "releaseDate": "2023-09-15"}],
47
+ },
48
+ {
49
+ "id": "onnx_mobilenet_v3",
50
+ "name": "MobileNetV3-Large",
51
+ "task": "classification",
52
+ "provider": "Google",
53
+ "description": "MobileNetV3-Large for efficient on-device image classification.",
54
+ "download_url": "https://github.com/onnx/models/raw/main/validated/vision/classification/mobilenet/model/mobilenetv3-large-1.11.onnx",
55
+ "size": 22_000_000,
56
+ "tags": ["mobilenet", "lightweight", "edge", "efficient"],
57
+ "hardware": ["cpu", "edge"],
58
+ "metrics": {"latency_ms": 5.8, "top1": 75.2, "fps": 180},
59
+ "downloads": 310_000,
60
+ "versions": [{"version": "3.0", "label": "Latest", "releaseDate": "2023-01-01"}],
61
+ },
62
+ {
63
+ "id": "onnx_bert_base_uncased",
64
+ "name": "BERT-Base-Uncased",
65
+ "task": "nlp",
66
+ "provider": "Google",
67
+ "description": "BERT base model fine-tuned for NLP inference in ONNX format.",
68
+ "download_url": "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx",
69
+ "size": 438_000_000,
70
+ "tags": ["bert", "nlp", "transformer"],
71
+ "hardware": ["gpu", "cpu"],
72
+ "metrics": {"latency_ms": 42.0},
73
+ "downloads": 198_000,
74
+ "versions": [{"version": "1.0", "label": "Stable", "releaseDate": "2022-11-01"}],
75
+ },
76
+ {
77
+ "id": "onnx_efficientnet_b0",
78
+ "name": "EfficientNet-B0",
79
+ "task": "classification",
80
+ "provider": "Google Brain",
81
+ "description": "EfficientNet-B0 for scalable image classification.",
82
+ "download_url": "https://github.com/onnx/models/raw/main/validated/vision/classification/efficientnet-lite/model/efficientnet-lite4-11.onnx",
83
+ "size": 20_000_000,
84
+ "tags": ["efficientnet", "efficient", "high-accuracy"],
85
+ "hardware": ["gpu", "cpu"],
86
+ "metrics": {"latency_ms": 10.4, "top1": 77.1},
87
+ "downloads": 145_000,
88
+ "versions": [{"version": "1.0", "label": "Stable", "releaseDate": "2023-03-01"}],
89
+ },
90
+ {
91
+ "id": "onnx_sam_vit_b",
92
+ "name": "SAM ViT-B",
93
+ "task": "segmentation",
94
+ "provider": "Meta AI",
95
+ "description": "Segment Anything Model (ViT-B) for universal image segmentation.",
96
+ "download_url": "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth",
97
+ "size": 375_000_000,
98
+ "tags": ["sam", "segmentation", "sota"],
99
+ "hardware": ["gpu"],
100
+ "metrics": {"latency_ms": 68.0},
101
+ "downloads": 88_000,
102
+ "versions": [{"version": "1.0", "label": "Latest", "releaseDate": "2023-04-05"}],
103
+ },
104
+ {
105
+ "id": "onnx_clip_vit_b32",
106
+ "name": "CLIP ViT-B/32",
107
+ "task": "embedding",
108
+ "provider": "OpenAI",
109
+ "description": "CLIP image + text embedding model for zero-shot classification.",
110
+ "download_url": "https://openaipublic.blob.core.windows.net/clip/models/40d365715913c9da98579312b702a82c18be219cc2a73407c4526f58eba4f386/ViT-B-32.pt",
111
+ "size": 338_000_000,
112
+ "tags": ["clip", "embedding", "multimodal"],
113
+ "hardware": ["gpu", "cpu"],
114
+ "metrics": {"latency_ms": 25.0},
115
+ "downloads": 275_000,
116
+ "versions": [{"version": "1.0", "label": "Stable", "releaseDate": "2023-01-01"}],
117
+ },
118
+ {
119
+ "id": "onnx_whisper_tiny",
120
+ "name": "Whisper Tiny",
121
+ "task": "nlp",
122
+ "provider": "OpenAI",
123
+ "description": "Whisper Tiny speech-to-text model in ONNX format.",
124
+ "download_url": "https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424930e36a852c0/tiny.pt",
125
+ "size": 39_000_000,
126
+ "tags": ["whisper", "speech", "lightweight"],
127
+ "hardware": ["cpu", "edge"],
128
+ "metrics": {"latency_ms": 100.0},
129
+ "downloads": 167_000,
130
+ "versions": [{"version": "20231117", "label": "Latest", "releaseDate": "2023-11-17"}],
131
+ },
132
+ ]
133
+
134
+
135
+ class ONNXAdapter(BaseAdapter):
136
+ source_name = "onnx"
137
+
138
+ async def fetch_models(self) -> list[Model]:
139
+ models: list[Model] = []
140
+ for raw in ONNX_CURATED:
141
+ try:
142
+ versions = [
143
+ ModelVersion(
144
+ version=v["version"],
145
+ label=v.get("label", "Stable"),
146
+ releaseDate=v.get("releaseDate", ""),
147
+ )
148
+ for v in raw.get("versions", [])
149
+ ]
150
+ metrics_raw = raw.get("metrics", {})
151
+ m = Model(
152
+ id = raw["id"],
153
+ name = raw["name"],
154
+ task = raw["task"],
155
+ framework = "onnx",
156
+ source = "onnx",
157
+ provider = raw.get("provider", "ONNX Zoo"),
158
+ description = raw.get("description", ""),
159
+ download_url = raw.get("download_url"),
160
+ size = raw.get("size", 0),
161
+ size_label = self._format_size(raw.get("size", 0)),
162
+ tags = raw.get("tags", []),
163
+ hardware = raw.get("hardware", ["gpu"]),
164
+ status = "available",
165
+ downloaded = False,
166
+ downloads = raw.get("downloads"),
167
+ rating = 4.2,
168
+ metrics = ModelMetrics(**metrics_raw),
169
+ versions = versions,
170
+ )
171
+ models.append(m)
172
+ except Exception as exc:
173
+ log.warning("onnx_parse_failed", model_id=raw.get("id"), error=str(exc))
174
+
175
+ log.info("onnx_fetch_complete", total=len(models))
176
+ return models
adapters/roboflow_adapter.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ adapters/roboflow_adapter.py β€” Roboflow Universe API client.
3
+
4
+ Responsibilities:
5
+ - Fetch dataset metadata (search, workspace listings, project details)
6
+ - Normalise responses β†’ Dataset domain model
7
+ - Cache results in roboflow_cache table (TTL-aware)
8
+ - Handle pagination, rate limits, and errors robustly
9
+
10
+ Roboflow API reference: https://docs.roboflow.com/api-reference/
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import time
17
+ from typing import Any
18
+
19
+ import httpx
20
+ from tenacity import retry, stop_after_attempt, wait_exponential
21
+
22
+ from database.connection import get_db
23
+ from models.dataset import Dataset, DatasetFormat, DatasetSource, DatasetStatus, DatasetTask
24
+ from observability.logger import audit, get_logger
25
+
26
+ log = get_logger("roboflow_adapter")
27
+
28
+ _ROBOFLOW_BASE = "https://api.roboflow.com"
29
+ _UNIVERSE_BASE = "https://universe.roboflow.com"
30
+ _DEFAULT_TTL = 3600 # 1 hour
31
+
32
+ # ── Task mapping from Roboflow annotation_type ───────────────────────────────
33
+
34
+ _TASK_MAP: dict[str, DatasetTask] = {
35
+ "object-detection": DatasetTask.detection,
36
+ "instance-segmentation": DatasetTask.segmentation,
37
+ "semantic-segmentation": DatasetTask.segmentation,
38
+ "classification": DatasetTask.classification,
39
+ "keypoint-detection": DatasetTask.keypoints,
40
+ "multiclass-classification": DatasetTask.classification,
41
+ }
42
+
43
+ _FORMAT_MAP: dict[str, DatasetFormat] = {
44
+ "yolov5": DatasetFormat.yolo,
45
+ "yolov7": DatasetFormat.yolo,
46
+ "yolov8": DatasetFormat.yolo,
47
+ "yolov9": DatasetFormat.yolo,
48
+ "coco": DatasetFormat.coco,
49
+ "voc": DatasetFormat.voc,
50
+ "tfrecord": DatasetFormat.tfrecord,
51
+ "csv": DatasetFormat.csv,
52
+ "createml": DatasetFormat.json,
53
+ "multiclass": DatasetFormat.csv,
54
+ }
55
+
56
+
57
+ def _cache_key(parts: list[str]) -> str:
58
+ raw = "|".join(parts)
59
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]
60
+
61
+
62
+ def _fmt_bytes(n: int) -> str:
63
+ for unit in ("B", "KB", "MB", "GB", "TB"):
64
+ if n < 1024:
65
+ return f"{n:.1f} {unit}"
66
+ n /= 1024
67
+ return f"{n:.1f} PB"
68
+
69
+
70
+ # ── Cache helpers ─────────────────────────────────────────────────────────────
71
+
72
+ async def _cache_get(key: str) -> dict[str, Any] | None:
73
+ db = await get_db()
74
+ async with db.execute(
75
+ "SELECT payload, fetched_at, ttl_secs FROM roboflow_cache WHERE cache_key = ?",
76
+ (key,),
77
+ ) as cur:
78
+ row = await cur.fetchone()
79
+ if row is None:
80
+ return None
81
+ fetched = time.mktime(time.strptime(row["fetched_at"], "%Y-%m-%d %H:%M:%S"))
82
+ if time.time() - fetched > row["ttl_secs"]:
83
+ return None # expired
84
+ return json.loads(row["payload"])
85
+
86
+
87
+ async def _cache_set(key: str, payload: dict[str, Any], ttl: int = _DEFAULT_TTL) -> None:
88
+ db = await get_db()
89
+ await db.execute(
90
+ """INSERT OR REPLACE INTO roboflow_cache (cache_key, payload, ttl_secs)
91
+ VALUES (?, ?, ?)""",
92
+ (key, json.dumps(payload), ttl),
93
+ )
94
+ await db.commit()
95
+
96
+
97
+ # ── HTTP client factory ───────────────────────────────────────────────────────
98
+
99
+ def _make_client(api_key: str) -> httpx.AsyncClient:
100
+ return httpx.AsyncClient(
101
+ base_url=_ROBOFLOW_BASE,
102
+ params={"api_key": api_key},
103
+ timeout=30.0,
104
+ headers={"User-Agent": "MLForge/1.0"},
105
+ )
106
+
107
+
108
+ # ── Roboflow Adapter ──────────────────────────────────────────────────────────
109
+
110
+ class RoboflowAdapter:
111
+ """
112
+ Stateless adapter for the Roboflow API.
113
+ All methods accept api_key explicitly to support per-user keys.
114
+ """
115
+
116
+ # ── Search (Universe) ─────────────────────────────────────────────────────
117
+
118
+ @staticmethod
119
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
120
+ async def search_datasets(
121
+ api_key: str,
122
+ query: str = "",
123
+ workspace: str | None = None,
124
+ page: int = 0,
125
+ page_size: int = 50,
126
+ ) -> list[Dataset]:
127
+ """
128
+ Search Roboflow Universe for datasets.
129
+ Returns normalised Dataset objects.
130
+ """
131
+ ck = _cache_key(["search", query, str(workspace), str(page), str(page_size)])
132
+ cached = await _cache_get(ck)
133
+ if cached:
134
+ log.debug("roboflow_cache_hit", key=ck, query=query)
135
+ return [Dataset(**d) for d in cached]
136
+
137
+ params: dict[str, Any] = {
138
+ "api_key": api_key,
139
+ "q": query or "*",
140
+ "from": page * page_size,
141
+ "size": page_size,
142
+ }
143
+ if workspace:
144
+ params["workspace"] = workspace
145
+
146
+ async with _make_client(api_key) as client:
147
+ try:
148
+ resp = await client.get("/", params=params)
149
+ resp.raise_for_status()
150
+ data = resp.json()
151
+ except httpx.HTTPStatusError as e:
152
+ log.error("roboflow_api_error", status=e.response.status_code, query=query)
153
+ await audit("roboflow_error", {"query": query, "status": e.response.status_code}, level="error")
154
+ raise
155
+
156
+ datasets = []
157
+ for item in data.get("results", []):
158
+ try:
159
+ ds = RoboflowAdapter._normalise_search_result(item)
160
+ datasets.append(ds)
161
+ except Exception as exc:
162
+ log.warning("normalise_error", item_id=item.get("id"), error=str(exc))
163
+
164
+ await _cache_set(ck, [d.model_dump() for d in datasets])
165
+ await audit("roboflow_search", {"query": query, "count": len(datasets)})
166
+ return datasets
167
+
168
+ # ── Workspace datasets listing ────────────────────────────────────────────
169
+
170
+ @staticmethod
171
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
172
+ async def list_workspace_datasets(
173
+ api_key: str,
174
+ workspace: str,
175
+ ) -> list[Dataset]:
176
+ """List all datasets in a Roboflow workspace."""
177
+ ck = _cache_key(["workspace", workspace])
178
+ cached = await _cache_get(ck)
179
+ if cached:
180
+ return [Dataset(**d) for d in cached]
181
+
182
+ async with _make_client(api_key) as client:
183
+ try:
184
+ resp = await client.get(f"/{workspace}")
185
+ resp.raise_for_status()
186
+ data = resp.json()
187
+ except httpx.HTTPStatusError as e:
188
+ log.error("roboflow_workspace_error", workspace=workspace, status=e.response.status_code)
189
+ raise
190
+
191
+ datasets = []
192
+ for proj in data.get("workspace", {}).get("projects", []):
193
+ try:
194
+ ds = RoboflowAdapter._normalise_project(proj, workspace)
195
+ datasets.append(ds)
196
+ except Exception as exc:
197
+ log.warning("normalise_project_error", project=proj.get("id"), error=str(exc))
198
+
199
+ await _cache_set(ck, [d.model_dump() for d in datasets])
200
+ return datasets
201
+
202
+ # ── Single project detail ─────────────────────────────────────────────────
203
+
204
+ @staticmethod
205
+ @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
206
+ async def get_project(
207
+ api_key: str,
208
+ workspace: str,
209
+ project_id: str,
210
+ ) -> Dataset | None:
211
+ """Fetch full metadata for a single Roboflow project."""
212
+ ck = _cache_key(["project", workspace, project_id])
213
+ cached = await _cache_get(ck)
214
+ if cached:
215
+ return Dataset(**cached)
216
+
217
+ async with _make_client(api_key) as client:
218
+ try:
219
+ resp = await client.get(f"/{workspace}/{project_id}")
220
+ resp.raise_for_status()
221
+ data = resp.json()
222
+ except httpx.HTTPStatusError as e:
223
+ if e.response.status_code == 404:
224
+ return None
225
+ raise
226
+
227
+ proj_data = data.get("project", data)
228
+ ds = RoboflowAdapter._normalise_project(proj_data, workspace)
229
+ await _cache_set(ck, ds.model_dump())
230
+ return ds
231
+
232
+ # ── Download URL builder ──────────────────────────────────────────────────
233
+
234
+ @staticmethod
235
+ async def get_download_url(
236
+ api_key: str,
237
+ workspace: str,
238
+ project_id: str,
239
+ version: int,
240
+ export_format: str = "yolov8",
241
+ ) -> str:
242
+ """
243
+ Fetch the export download link from Roboflow for the specified format.
244
+ Uses the official Roboflow SDK to handle authentication and URL resolution.
245
+ """
246
+ try:
247
+ from roboflow import Roboflow
248
+ rf = Roboflow(api_key=api_key)
249
+ project = rf.workspace(workspace).project(project_id)
250
+ version_obj = project.version(version)
251
+
252
+ # The SDK's download method usually downloads to disk,
253
+ # but we can get the underlying export info.
254
+ # We'll use a thread to run the SDK call since it's blocking.
255
+ import asyncio
256
+ def _get_link():
257
+ return version_obj.export(export_format).download_link
258
+
259
+ link = await asyncio.to_thread(_get_link)
260
+ if not link:
261
+ raise ValueError(f"No download link returned for {workspace}/{project_id} v{version}")
262
+ return link
263
+ except Exception as e:
264
+ log.error("roboflow_sdk_error", error=str(e))
265
+ # Fallback to manual API if SDK fails or isn't installed correctly
266
+ async with _make_client(api_key) as client:
267
+ resp = await client.get(
268
+ f"/{workspace}/{project_id}/{version}/{export_format}"
269
+ )
270
+ resp.raise_for_status()
271
+ data = resp.json()
272
+
273
+ link = export.get("link") or ""
274
+ if not link:
275
+ # If 'link' is missing, check if it's a Universe-style project and try to resolve manually
276
+ # Roboflow manual resolution often follows: universe.roboflow.com/ds/[id]?key=[api_key]
277
+ if "project" in data:
278
+ pid = data["project"].get("id")
279
+ if pid:
280
+ link = f"https://universe.roboflow.com/ds/{pid}?key={api_key}"
281
+
282
+ if not link:
283
+ raise ValueError(f"No download link returned for {workspace}/{project_id} v{version}")
284
+
285
+ # Ensure the link includes the API key correctly
286
+ if "universe.roboflow.com" in link:
287
+ if "key=" not in link:
288
+ separator = "&" if "?" in link else "?"
289
+ link = f"{link}{separator}key={api_key}"
290
+ elif f"key={api_key}" not in link:
291
+ # Replace old key if it exists but is wrong
292
+ import re
293
+ link = re.sub(r"key=[^&]+", f"key={api_key}", link)
294
+
295
+ return link
296
+
297
+ # ── Normalisation helpers ─────────────────────────────────────────────────
298
+
299
+ @staticmethod
300
+ def _normalise_search_result(item: dict[str, Any]) -> Dataset:
301
+ """Map a Universe search result β†’ Dataset."""
302
+ ann_type = item.get("annotation", {}).get("type", "object-detection")
303
+ rf_task = _TASK_MAP.get(ann_type, DatasetTask.detection)
304
+ class_names = [c.get("name", "") for c in item.get("classes", [])]
305
+ images = item.get("images", 0) or 0
306
+
307
+ return Dataset(
308
+ id = item.get("id", "").replace("/", "__"),
309
+ name = item.get("name", "Unnamed"),
310
+ description = item.get("description", ""),
311
+ task = rf_task,
312
+ format = DatasetFormat.yolo,
313
+ source = DatasetSource.roboflow,
314
+ status = DatasetStatus.available,
315
+ images = images,
316
+ classes = len(class_names),
317
+ class_names = class_names,
318
+ size_bytes = 0,
319
+ size_label = "β€”",
320
+ tags = item.get("tags", []),
321
+ roboflow_id = item.get("id", ""),
322
+ created_at = item.get("created", ""),
323
+ updated_at = item.get("updated", ""),
324
+ )
325
+
326
+ @staticmethod
327
+ def _normalise_project(proj: dict[str, Any], workspace: str) -> Dataset:
328
+ """Map a workspace project β†’ Dataset."""
329
+ ann_type = proj.get("annotation", "object-detection")
330
+ rf_task = _TASK_MAP.get(ann_type, DatasetTask.detection)
331
+ class_names = [c.get("name", c) if isinstance(c, dict) else c
332
+ for c in proj.get("classes", [])]
333
+ project_id = proj.get("id", proj.get("name", "unknown"))
334
+ rf_id = f"{workspace}/{project_id}"
335
+ images = proj.get("images", 0) or 0
336
+
337
+ return Dataset(
338
+ id = rf_id.replace("/", "__"),
339
+ name = proj.get("name", project_id),
340
+ description = proj.get("description", ""),
341
+ task = rf_task,
342
+ format = DatasetFormat.yolo,
343
+ source = DatasetSource.roboflow,
344
+ status = DatasetStatus.available,
345
+ images = images,
346
+ classes = len(class_names),
347
+ class_names = class_names,
348
+ size_bytes = 0,
349
+ size_label = "β€”",
350
+ roboflow_id = rf_id,
351
+ created_at = proj.get("created", ""),
352
+ updated_at = proj.get("updated", ""),
353
+ )
api/routes/__pycache__/datasets.cpython-310.pyc CHANGED
Binary files a/api/routes/__pycache__/datasets.cpython-310.pyc and b/api/routes/__pycache__/datasets.cpython-310.pyc differ
 
api/routes/__pycache__/models.cpython-310.pyc CHANGED
Binary files a/api/routes/__pycache__/models.cpython-310.pyc and b/api/routes/__pycache__/models.cpython-310.pyc differ
 
api/routes/__pycache__/sync.cpython-310.pyc CHANGED
Binary files a/api/routes/__pycache__/sync.cpython-310.pyc and b/api/routes/__pycache__/sync.cpython-310.pyc differ
 
api/routes/datasets.py CHANGED
@@ -1,36 +1,17 @@
1
  """
2
- api/routes/datasets.py β€” Dataset Manager REST API.
3
-
4
- Routes:
5
- GET /datasets β€” list/search datasets
6
- GET /datasets/{id} β€” dataset detail
7
- POST /datasets/search/roboflow β€” search Roboflow Universe (real-time)
8
- POST /datasets/sync/roboflow β€” sync workspace datasets to local DB
9
- POST /datasets/{id}/import β€” initiate dataset import job
10
- GET /datasets/{id}/images β€” paginated viewer (images + annotations)
11
- GET /datasets/{id}/image/{img} β€” serve raw image bytes
12
- GET /datasets/jobs β€” list import jobs
13
- GET /datasets/jobs/{job_id} β€” single job status
14
- POST /datasets/{id}/star β€” toggle starred
15
- DELETE /datasets/{id} β€” delete dataset record (+ local files)
16
  """
17
  from __future__ import annotations
18
 
19
- from pathlib import Path
20
  from typing import Optional
21
  from datetime import datetime
22
 
23
- from fastapi import APIRouter, Depends, HTTPException, Query
24
- from fastapi.responses import FileResponse, Response
25
 
26
  from adapters.roboflow_adapter import RoboflowAdapter
27
  from datasets import registry as ds_reg
28
- from datasets.import_service import start_import
29
- from datasets.viewer_service import get_universal_viewer_page, get_viewer_page, resolve_image_path
30
  from models.dataset import (
31
- Dataset, DatasetJob, DatasetSummary, DatasetSource, DatasetTask,
32
- DatasetFormat, DatasetStatus, ImportRequest, ImportResponse,
33
- RoboflowSearchRequest, ViewerPage, UniversalViewerPage, row_to_dataset,
34
  )
35
  from models.analytics import DatasetAnalytics, SplitAnalytics, QualityIssues, ClassDistributionItem
36
  from observability.logger import audit, get_logger
@@ -46,18 +27,13 @@ router = APIRouter(prefix="/datasets", tags=["datasets"])
46
  async def get_dataset_analytics(dataset_id: str):
47
  """
48
  Fetch comprehensive analytics for a dataset.
49
- Exposes health scores, quality issues, and distributions.
50
  """
51
  ds = await ds_reg.get_dataset(dataset_id)
52
  if not ds:
53
  raise HTTPException(404, f"Dataset {dataset_id!r} not found")
54
 
55
- # In a real scenario, this would be computed or fetched from a dedicated analytics table.
56
- # Here we derive it from the dataset's stats and mock the detailed distributions.
57
  stats = ds.stats
58
 
59
- # Mock some distributions if they aren't in the stats yet
60
- # to match the frontend expectations in DatasetAnalytics.tsx
61
  analytics = DatasetAnalytics(
62
  dataset_id=dataset_id,
63
  healthScore=stats.health_score,
@@ -106,49 +82,12 @@ async def list_datasets(
106
  raise HTTPException(status_code=500, detail=str(exc))
107
 
108
 
109
- @router.get("/jobs", response_model=list[DatasetJob])
110
- async def list_jobs(limit: int = Query(50, ge=1, le=500)):
111
- return await ds_reg.get_all_jobs(limit=limit)
112
-
113
-
114
- @router.get("/jobs/{job_id}", response_model=DatasetJob)
115
- async def get_job(job_id: str):
116
- job = await ds_reg.get_job(job_id)
117
- if not job:
118
- raise HTTPException(404, f"Job {job_id!r} not found")
119
- return job
120
-
121
-
122
- @router.post("/jobs/{job_id}/stop")
123
- async def stop_job(job_id: str):
124
- """Cancel a running import job."""
125
- # Logic to cancel the asyncio task would go here
126
- # For now, we update the status in the DB
127
- await ds_reg.update_job(job_id, status="failed", error="Cancelled by user", ended_at=datetime.utcnow().isoformat())
128
- return {"status": "success", "message": "Job stop requested"}
129
-
130
-
131
- @router.post("/jobs/{job_id}/pause")
132
- async def pause_job(job_id: str):
133
- """Pause a running import job."""
134
- await ds_reg.update_job(job_id, status="paused")
135
- return {"status": "success", "message": "Job pause requested"}
136
-
137
-
138
- @router.post("/jobs/{job_id}/resume")
139
- async def resume_job(job_id: str):
140
- """Resume a paused import job."""
141
- await ds_reg.update_job(job_id, status="running")
142
- return {"status": "success", "message": "Job resume requested"}
143
-
144
-
145
  # ── Roboflow Search & Sync ────────────────────────────────────────────────────
146
 
147
  @router.post("/search/roboflow", response_model=list[DatasetSummary])
148
  async def search_roboflow(req: RoboflowSearchRequest):
149
  """
150
  Live search Roboflow Universe. Results are cached for 1 hour.
151
- Also upserts results into local DB so they appear in /datasets.
152
  """
153
  try:
154
  datasets = await RoboflowAdapter.search_datasets(
@@ -162,7 +101,7 @@ async def search_roboflow(req: RoboflowSearchRequest):
162
  log.error("roboflow_search_error", error=str(exc))
163
  raise HTTPException(502, f"Roboflow API error: {exc}")
164
 
165
- # Upsert to local DB
166
  await ds_reg.bulk_upsert_datasets(datasets)
167
  await audit("roboflow_search", {"query": req.query, "count": len(datasets)})
168
  return [_to_summary(d) for d in datasets]
@@ -173,7 +112,7 @@ async def sync_roboflow_workspace(
173
  api_key: str = Query(..., description="Roboflow API key"),
174
  workspace: str = Query(..., description="Workspace slug"),
175
  ):
176
- """Sync all datasets from a Roboflow workspace into local DB."""
177
  try:
178
  datasets = await RoboflowAdapter.list_workspace_datasets(api_key, workspace)
179
  except Exception as exc:
@@ -192,193 +131,7 @@ async def get_dataset(dataset_id: str):
192
  return ds
193
 
194
 
195
- # ── Import ────────────────────────────────────────────────────────────────────
196
-
197
- @router.post("/{dataset_id}/import", response_model=ImportResponse)
198
- async def import_dataset(dataset_id: str, req: ImportRequest):
199
- """
200
- Initiate a background import job for a dataset.
201
- Supports sources: roboflow | roboflow_curl | huggingface | local
202
- """
203
- req.dataset_id = dataset_id # enforce consistency
204
-
205
- # Sources that are discovered outside the registry must be auto-registered.
206
- auto_register_sources = {DatasetSource.huggingface, DatasetSource.roboflow_curl, DatasetSource.local}
207
- if req.source in auto_register_sources:
208
- ds = await ds_reg.get_dataset(dataset_id)
209
- if not ds:
210
- # Determine human-readable name
211
- if req.source == DatasetSource.huggingface and req.hf_dataset_id:
212
- name = req.hf_dataset_id
213
- roboflow_ref = req.hf_dataset_id
214
- fmt = DatasetFormat.json
215
- src = DatasetSource.huggingface
216
-
217
- elif req.source == DatasetSource.local:
218
- # local: use provided name or folder name from path
219
- # Try req.local_path first, then req.name, then fallback to dataset_id
220
- path_to_use = req.local_path or req.name or ""
221
- name = req.name or (Path(path_to_use).name if path_to_use else dataset_id)
222
- roboflow_ref = None
223
- fmt = DatasetFormat.custom
224
- src = DatasetSource.local
225
- else:
226
- # roboflow_curl: use provided dataset_name or fall back to dataset_id
227
- name = req.dataset_name or dataset_id
228
- roboflow_ref = None
229
- fmt = _curl_format_to_enum(req.curl_format)
230
- src = DatasetSource.roboflow_curl
231
-
232
- stub = Dataset(
233
- id=dataset_id,
234
- name=name,
235
- task=DatasetTask.detection,
236
- format=fmt,
237
- source=src,
238
- status=DatasetStatus.available,
239
- roboflow_id=roboflow_ref,
240
- created_at=datetime.utcnow().isoformat(),
241
- )
242
- await ds_reg.upsert_dataset(stub)
243
- log.info("dataset_auto_registered", dataset_id=dataset_id, source=str(req.source))
244
- else:
245
- ds = await ds_reg.get_dataset(dataset_id)
246
- if not ds:
247
- raise HTTPException(404, f"Dataset {dataset_id!r} not found in registry. "
248
- "Run /datasets/sync/roboflow first.")
249
-
250
- try:
251
- job_id = await start_import(req)
252
- except ValueError as exc:
253
- raise HTTPException(400, str(exc))
254
-
255
- await audit("dataset_import_requested", {"dataset_id": dataset_id, "source": str(req.source)})
256
- return ImportResponse(
257
- job_id = job_id,
258
- dataset_id = dataset_id,
259
- status = "queued",
260
- message = "Import job created successfully",
261
- )
262
-
263
-
264
- def _curl_format_to_enum(curl_format: str | None) -> DatasetFormat:
265
- """Map Roboflow export format string from cURL to DatasetFormat enum."""
266
- if not curl_format:
267
- return DatasetFormat.yolo
268
- fmt = curl_format.lower()
269
- if "yolo" in fmt:
270
- return DatasetFormat.yolo
271
- if "coco" in fmt:
272
- return DatasetFormat.coco
273
- if "voc" in fmt or "pascal" in fmt:
274
- return DatasetFormat.voc
275
- if "tfrecord" in fmt:
276
- return DatasetFormat.tfrecord
277
- if "csv" in fmt:
278
- return DatasetFormat.csv
279
- if "json" in fmt or "createml" in fmt:
280
- return DatasetFormat.json
281
- return DatasetFormat.yolo
282
-
283
-
284
- # ── Viewer ────────────────────────────────────────────────────────────────────
285
-
286
- @router.get("/{dataset_id}/universal", response_model=UniversalViewerPage)
287
- async def get_universal_items(
288
- dataset_id: str,
289
- page: int = Query(0, ge=0),
290
- page_size: int = Query(20, ge=1, le=100),
291
- split: Optional[str] = Query(None, regex="^(train|val|test)$"),
292
- class_label: Optional[str] = Query(None),
293
- ):
294
- """
295
- Polymorphic dataset item viewer (UDV).
296
- Supports Vision, NLP, and Tabular data via the Universal Dataset Item schema.
297
- """
298
- ds = await ds_reg.get_dataset(dataset_id)
299
- if not ds:
300
- raise HTTPException(404, f"Dataset {dataset_id!r} not found")
301
-
302
- # Allow viewing even if not fully imported for NLP/Tabular if files exist,
303
- # but for Vision we usually need the index.
304
- return await get_universal_viewer_page(dataset_id, page, page_size, split, class_label)
305
-
306
-
307
- @router.get("/{dataset_id}/images", response_model=ViewerPage)
308
- async def get_images(
309
- dataset_id: str,
310
- page: int = Query(0, ge=0),
311
- page_size: int = Query(20, ge=1, le=100),
312
- split: Optional[str] = Query(None, regex="^(train|val|test)$"),
313
- class_label: Optional[str] = Query(None),
314
- ):
315
- """
316
- Paginated image + annotation data for the viewer.
317
- Annotations are returned in normalised [0–1] coordinates.
318
- Supports filtering by split and class label.
319
- """
320
- ds = await ds_reg.get_dataset(dataset_id)
321
- if not ds:
322
- raise HTTPException(404, f"Dataset {dataset_id!r} not found")
323
- if ds.status != "imported":
324
- raise HTTPException(409, f"Dataset is not imported yet (status: {ds.status})")
325
-
326
- return await get_viewer_page(dataset_id, page, page_size, split, class_label)
327
-
328
-
329
- @router.get("/{dataset_id}/stats", response_model=dict)
330
- async def get_dataset_stats(dataset_id: str):
331
- """Return pre-computed class distributions and split statistics."""
332
- ds = await ds_reg.get_dataset(dataset_id)
333
- if not ds:
334
- raise HTTPException(404, f"Dataset {dataset_id!r} not found")
335
-
336
- return await ds_reg.get_dataset_stats(dataset_id)
337
-
338
-
339
- @router.get("/{dataset_id}/image/{image_id}")
340
- async def serve_image(dataset_id: str, image_id: str):
341
- """Serve raw image bytes for the viewer (cached by browser)."""
342
- path = await resolve_image_path(dataset_id, image_id)
343
- if path is None:
344
- raise HTTPException(404, "Image not found or dataset not imported")
345
-
346
- suffix = path.suffix.lower()
347
- media_types = {
348
- ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
349
- ".png": "image/png", ".bmp": "image/bmp",
350
- ".webp": "image/webp",
351
- }
352
- media_type = media_types.get(suffix, "application/octet-stream")
353
- return FileResponse(
354
- path = str(path),
355
- media_type = media_type,
356
- headers = {"Cache-Control": "public, max-age=86400"},
357
- )
358
-
359
-
360
- @router.get("/{dataset_id}/annotations", response_model=dict)
361
- async def get_annotations_summary(dataset_id: str):
362
- """Return class distribution summary from the annotations index."""
363
- from database.connection import get_db
364
- db = await get_db()
365
- async with db.execute(
366
- """SELECT label, COUNT(*) as count
367
- FROM dataset_annotations
368
- WHERE dataset_id=?
369
- GROUP BY label
370
- ORDER BY count DESC""",
371
- (dataset_id,),
372
- ) as cur:
373
- rows = await cur.fetchall()
374
- return {
375
- "dataset_id": dataset_id,
376
- "class_distribution": [{"label": r["label"], "count": r["count"]} for r in rows],
377
- "total_annotations": sum(r["count"] for r in rows),
378
- }
379
-
380
-
381
- # ── Star / Delete ─────────────────────────────────────────────────────────────
382
 
383
  @router.post("/{dataset_id}/star", response_model=dict)
384
  async def toggle_star(dataset_id: str):
@@ -386,31 +139,9 @@ async def toggle_star(dataset_id: str):
386
  return {"dataset_id": dataset_id, "starred": new_val}
387
 
388
 
389
- @router.delete("/{dataset_id}", response_model=dict)
390
- async def delete_dataset(
391
- dataset_id: str,
392
- delete_files: bool = Query(False, description="Also remove local files from disk"),
393
- ):
394
- ds = await ds_reg.get_dataset(dataset_id)
395
- if not ds:
396
- raise HTTPException(404, f"Dataset {dataset_id!r} not found")
397
-
398
- if delete_files and ds.local_path:
399
- import shutil
400
- local = Path(ds.local_path)
401
- if local.exists() and local.is_dir():
402
- shutil.rmtree(str(local), ignore_errors=True)
403
- log.info("dataset_files_deleted", path=str(local))
404
-
405
- deleted = await ds_reg.delete_dataset(dataset_id)
406
- await audit("dataset_deleted", {"dataset_id": dataset_id, "files_deleted": delete_files})
407
- return {"deleted": deleted, "dataset_id": dataset_id}
408
-
409
-
410
  # ── Helper ────────────────────────────────────────────────────────────────────
411
 
412
  def _to_summary(d: Dataset) -> DatasetSummary:
413
- # Use 0.0 as default health_score if stats is missing or health_score is not present
414
  health_score = 0.0
415
  try:
416
  if hasattr(d, 'stats') and d.stats:
 
1
  """
2
+ api/routes/datasets.py β€” Dataset Discovery REST API (Cloud Registry Version).
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
  from __future__ import annotations
5
 
 
6
  from typing import Optional
7
  from datetime import datetime
8
 
9
+ from fastapi import APIRouter, HTTPException, Query
 
10
 
11
  from adapters.roboflow_adapter import RoboflowAdapter
12
  from datasets import registry as ds_reg
 
 
13
  from models.dataset import (
14
+ Dataset, DatasetSummary, RoboflowSearchRequest,
 
 
15
  )
16
  from models.analytics import DatasetAnalytics, SplitAnalytics, QualityIssues, ClassDistributionItem
17
  from observability.logger import audit, get_logger
 
27
  async def get_dataset_analytics(dataset_id: str):
28
  """
29
  Fetch comprehensive analytics for a dataset.
 
30
  """
31
  ds = await ds_reg.get_dataset(dataset_id)
32
  if not ds:
33
  raise HTTPException(404, f"Dataset {dataset_id!r} not found")
34
 
 
 
35
  stats = ds.stats
36
 
 
 
37
  analytics = DatasetAnalytics(
38
  dataset_id=dataset_id,
39
  healthScore=stats.health_score,
 
82
  raise HTTPException(status_code=500, detail=str(exc))
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  # ── Roboflow Search & Sync ────────────────────────────────────────────────────
86
 
87
  @router.post("/search/roboflow", response_model=list[DatasetSummary])
88
  async def search_roboflow(req: RoboflowSearchRequest):
89
  """
90
  Live search Roboflow Universe. Results are cached for 1 hour.
 
91
  """
92
  try:
93
  datasets = await RoboflowAdapter.search_datasets(
 
101
  log.error("roboflow_search_error", error=str(exc))
102
  raise HTTPException(502, f"Roboflow API error: {exc}")
103
 
104
+ # Upsert to registry DB
105
  await ds_reg.bulk_upsert_datasets(datasets)
106
  await audit("roboflow_search", {"query": req.query, "count": len(datasets)})
107
  return [_to_summary(d) for d in datasets]
 
112
  api_key: str = Query(..., description="Roboflow API key"),
113
  workspace: str = Query(..., description="Workspace slug"),
114
  ):
115
+ """Sync all datasets from a Roboflow workspace into the global registry."""
116
  try:
117
  datasets = await RoboflowAdapter.list_workspace_datasets(api_key, workspace)
118
  except Exception as exc:
 
131
  return ds
132
 
133
 
134
+ # ── Star ─────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
  @router.post("/{dataset_id}/star", response_model=dict)
137
  async def toggle_star(dataset_id: str):
 
139
  return {"dataset_id": dataset_id, "starred": new_val}
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  # ── Helper ────────────────────────────────────────────────────────────────────
143
 
144
  def _to_summary(d: Dataset) -> DatasetSummary:
 
145
  health_score = 0.0
146
  try:
147
  if hasattr(d, 'stats') and d.stats:
api/routes/models.py CHANGED
@@ -1,19 +1,15 @@
1
  """
2
- api/routes/models.py β€” /models REST endpoints.
 
3
  """
4
  from __future__ import annotations
5
 
6
  from typing import Annotated
7
 
8
- from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Form
9
  from models.model import Model
10
  from observability.logger import audit, get_logger
11
- from registry.registry import count_models, get_model, list_models
12
- from projects.service import get_active_project_id, import_local_model
13
- from projects.registry import get_project
14
- from pathlib import Path
15
- import os
16
- import tempfile
17
 
18
  log = get_logger("api.models")
19
  router = APIRouter(prefix="/models", tags=["models"])
@@ -26,98 +22,30 @@ async def index(
26
  framework: Annotated[list[str] | None, Query()] = None,
27
  hardware: Annotated[list[str] | None, Query()] = None,
28
  source: Annotated[list[str] | None, Query()] = None,
29
- downloaded: Annotated[bool | None, Query()] = None,
30
  sort_by: Annotated[str, Query()] = "downloads",
31
  sort_dir: Annotated[str, Query()] = "desc",
32
  limit: Annotated[int, Query(ge=1, le=1000)] = 200,
33
  offset: Annotated[int, Query(ge=0)] = 0,
34
- project_id: Annotated[str | None, Query()] = None,
35
  ) -> list[Model]:
36
  """
37
- List and search models.
38
- Supports FTS search + server-side filtering.
39
- Target: < 100ms for up to 5 000 models.
40
  """
41
- effective_project_id = project_id or await get_active_project_id()
42
-
43
  models = await list_models(
44
  search=search,
45
  tasks=task,
46
  frameworks=framework,
47
  hardware=hardware,
48
  sources=source,
49
- downloaded=downloaded,
50
  sort_by=sort_by,
51
  sort_dir=sort_dir,
52
  limit=limit,
53
  offset=offset,
54
- project_id=effective_project_id,
55
  )
56
 
57
- # If we have an active project, derive cache state from its workspace.
58
- # This makes "downloaded" and "local_path" reflect the *current project*.
59
- if effective_project_id:
60
- proj = await get_project(effective_project_id)
61
- if proj:
62
- project_models_dir = Path(proj.path) / "models"
63
-
64
- updated: list[Model] = []
65
- for m in models:
66
- model_dir = project_models_dir / m.id
67
- if model_dir.exists() and model_dir.is_dir():
68
- # Pick the first file in the model directory (best-effort).
69
- found_file: str | None = None
70
- try:
71
- for p in model_dir.rglob("*"):
72
- if p.is_file():
73
- found_file = str(p)
74
- break
75
- except Exception:
76
- found_file = None
77
-
78
- if found_file:
79
- updated.append(m.model_copy(update={"downloaded": True, "local_path": found_file}))
80
- continue
81
-
82
- # Not present in this project β†’ treat as not cached for this project.
83
- updated.append(m.model_copy(update={"downloaded": False, "local_path": None}))
84
-
85
- models = updated
86
-
87
  await audit("api_list_models", payload={"count": len(models), "search": search})
88
  return models
89
 
90
 
91
- @router.post("/import", response_model=Model)
92
- async def import_model(
93
- name: Annotated[str, Form()],
94
- task: Annotated[str, Form()],
95
- framework: Annotated[str, Form()],
96
- file: UploadFile = File(...),
97
- ) -> Model:
98
- """Import a local model file into the active project."""
99
- # Save uploaded file to a temporary location
100
- with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file.filename or "")[1]) as tmp:
101
- content = await file.read()
102
- tmp.write(content)
103
- tmp_path = tmp.name
104
-
105
- try:
106
- model = await import_local_model(
107
- name=name,
108
- task=task,
109
- framework=framework,
110
- source_file_path=tmp_path
111
- )
112
- return model
113
- except Exception as e:
114
- log.error("model_import_failed", error=str(e))
115
- raise HTTPException(status_code=500, detail=str(e))
116
- finally:
117
- if os.path.exists(tmp_path):
118
- os.remove(tmp_path)
119
-
120
-
121
  @router.get("/{model_id}", response_model=Model)
122
  async def detail(model_id: str) -> Model:
123
  model = await get_model(model_id)
 
1
  """
2
+ api/routes/models.py β€” /models REST endpoints (Cloud Registry Version).
3
+ Provides global model discovery and metadata.
4
  """
5
  from __future__ import annotations
6
 
7
  from typing import Annotated
8
 
9
+ from fastapi import APIRouter, HTTPException, Query
10
  from models.model import Model
11
  from observability.logger import audit, get_logger
12
+ from registry.registry import get_model, list_models
 
 
 
 
 
13
 
14
  log = get_logger("api.models")
15
  router = APIRouter(prefix="/models", tags=["models"])
 
22
  framework: Annotated[list[str] | None, Query()] = None,
23
  hardware: Annotated[list[str] | None, Query()] = None,
24
  source: Annotated[list[str] | None, Query()] = None,
 
25
  sort_by: Annotated[str, Query()] = "downloads",
26
  sort_dir: Annotated[str, Query()] = "desc",
27
  limit: Annotated[int, Query(ge=1, le=1000)] = 200,
28
  offset: Annotated[int, Query(ge=0)] = 0,
 
29
  ) -> list[Model]:
30
  """
31
+ List and search models in the global registry.
 
 
32
  """
 
 
33
  models = await list_models(
34
  search=search,
35
  tasks=task,
36
  frameworks=framework,
37
  hardware=hardware,
38
  sources=source,
 
39
  sort_by=sort_by,
40
  sort_dir=sort_dir,
41
  limit=limit,
42
  offset=offset,
 
43
  )
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  await audit("api_list_models", payload={"count": len(models), "search": search})
46
  return models
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  @router.get("/{model_id}", response_model=Model)
50
  async def detail(model_id: str) -> Model:
51
  model = await get_model(model_id)
api/routes/projects.py DELETED
@@ -1,54 +0,0 @@
1
- """api/routes/projects.py β€” /projects REST endpoints."""
2
-
3
- from __future__ import annotations
4
-
5
- from fastapi import APIRouter, HTTPException, Query
6
-
7
- from models.project import Project
8
- from observability.logger import audit
9
- from projects.registry import delete_project, get_project, list_projects, touch_last_opened, upsert_project
10
- from projects.service import set_active_project
11
-
12
-
13
- router = APIRouter(prefix="/projects", tags=["projects"])
14
-
15
-
16
- @router.get("", response_model=list[Project])
17
- async def projects_list(
18
- limit: int = Query(200, ge=1, le=1000),
19
- offset: int = Query(0, ge=0),
20
- ) -> list[Project]:
21
- projects = await list_projects(limit=limit, offset=offset)
22
- await audit("api_list_projects", payload={"count": len(projects)})
23
- return projects
24
-
25
-
26
- @router.post("", response_model=Project)
27
- async def projects_upsert(project: Project) -> Project:
28
- # Ensure project.created_at and last_opened are set if missing
29
- if not project.created_at:
30
- project.created_at = datetime.now(timezone.utc).isoformat()
31
- if not project.last_opened:
32
- project.last_opened = datetime.now(timezone.utc).isoformat()
33
-
34
- await upsert_project(project)
35
- await audit("api_upsert_project", payload={"project_id": project.id})
36
- return project
37
-
38
-
39
- @router.post("/{project_id}/open", status_code=204, response_model=None)
40
- async def projects_open(project_id: str) -> None:
41
- await touch_last_opened(project_id)
42
- project = await get_project(project_id)
43
- if project:
44
- await set_active_project(project.id, project.path)
45
- await audit("api_open_project", payload={"project_id": project_id})
46
-
47
-
48
- @router.delete("/{project_id}", status_code=204, response_model=None)
49
- async def projects_delete(project_id: str) -> None:
50
- ok = await delete_project(project_id)
51
- if not ok:
52
- raise HTTPException(status_code=404, detail=f"Project '{project_id}' not found")
53
- await audit("api_delete_project", payload={"project_id": project_id})
54
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.py CHANGED
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
26
  # ── Storage ───────────────────────────────────────────────────────
27
  base_dir: Path = Path(__file__).resolve().parents[1]
28
  data_dir: Path = base_dir / "data"
 
29
  db_path: Path = data_dir / "modelzoo.db"
30
 
31
  # ── Search ────────────────────────────────────────────────────────
@@ -42,6 +43,7 @@ class Settings(BaseSettings):
42
 
43
  def ensure_dirs(self) -> None:
44
  self.data_dir.mkdir(parents=True, exist_ok=True)
 
45
 
46
 
47
  settings = Settings()
 
26
  # ── Storage ───────────────────────────────────────────────────────
27
  base_dir: Path = Path(__file__).resolve().parents[1]
28
  data_dir: Path = base_dir / "data"
29
+ logs_dir: Path = data_dir / "logs"
30
  db_path: Path = data_dir / "modelzoo.db"
31
 
32
  # ── Search ────────────────────────────────────────────────────────
 
43
 
44
  def ensure_dirs(self) -> None:
45
  self.data_dir.mkdir(parents=True, exist_ok=True)
46
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
47
 
48
 
49
  settings = Settings()
datasets/import_service.py DELETED
@@ -1,589 +0,0 @@
1
- """
2
- datasets/import_service.py β€” Dataset Import Pipeline.
3
-
4
- Pipeline stages:
5
- 1. Create job record
6
- 2. Download dataset zip (chunked, progress-tracked)
7
- 3. Extract zip safely (path-traversal protected)
8
- 4. Detect annotation format & task type
9
- 5. Index images into dataset_images table
10
- 6. Parse & store metadata (Stats only, annotations are read-on-demand)
11
- 7. Update dataset stats (images, classes, size)
12
- 8. Mark job completed / failed
13
-
14
- All stages run as background tasks.
15
- Supports Roboflow, HuggingFace, and local file/folder imports.
16
- """
17
- from __future__ import annotations
18
-
19
- import asyncio
20
- import hashlib
21
- import os
22
- import shutil
23
- import uuid
24
- import zipfile
25
- from datetime import datetime
26
- from pathlib import Path
27
- from typing import Optional, List, Dict, Any, Tuple
28
-
29
- import aiofiles
30
- import httpx
31
- from huggingface_hub import snapshot_download
32
-
33
- from config import settings
34
- from . import registry as ds_reg
35
- from .format_adapters import (
36
- YOLOAdapter, COCOAdapter, VOCAdapter, CreateMLAdapter,
37
- RoboflowClassificationAdapter, NLPAdapter, TabularAdapter
38
- )
39
- from .base_adapter import DatasetAdapter
40
- from .annotation_parser import _img_dimensions
41
- from observability.logger import audit, get_logger
42
- from models.dataset import DatasetStatus, DatasetTask, ImportRequest, Dataset
43
-
44
- log = get_logger("import_service")
45
-
46
- ADAPTERS: List[DatasetAdapter] = [
47
- YOLOAdapter(),
48
- COCOAdapter(),
49
- VOCAdapter(),
50
- CreateMLAdapter(),
51
- RoboflowClassificationAdapter(),
52
- NLPAdapter(),
53
- TabularAdapter(),
54
- ]
55
-
56
- def get_adapter_for_path(path: Path) -> DatasetAdapter | None:
57
- for adapter in ADAPTERS:
58
- if adapter.detect(path):
59
- return adapter
60
- return None
61
-
62
- async def recover_stale_jobs() -> None:
63
- """Cleanup dataset import jobs that were left in 'running' or 'queued' state."""
64
- await ds_reg.cleanup_stale_jobs()
65
-
66
- def _dataset_path(dataset_id: str) -> Path:
67
- return settings.datasets_dir / dataset_id
68
-
69
- # ── Entry Point ──────────────────────────────────────────────────────────────
70
-
71
- async def start_import(req: ImportRequest) -> str:
72
- """Entry point to initiate a background import job."""
73
- job_id = f"job-{uuid.uuid4().hex[:8]}"
74
-
75
- # Create initial job record
76
- await ds_reg.update_job(
77
- job_id,
78
- dataset_id=req.dataset_id,
79
- status="queued",
80
- progress=0,
81
- message="Import queued",
82
- type=str(req.source)
83
- )
84
-
85
- # Launch background task
86
- asyncio.create_task(_run_pipeline(job_id, req, req.dataset_name or req.dataset_id))
87
-
88
- return job_id
89
-
90
-
91
- # ── Pipeline orchestrator ────────────────────────────────────────────────────
92
-
93
- async def _run_pipeline(job_id: str, req: ImportRequest, dataset_name: str) -> None:
94
- started = datetime.utcnow().isoformat()
95
- await ds_reg.update_job(job_id, status="running", started_at=started, message="Starting import")
96
- await ds_reg.update_dataset_status(req.dataset_id, DatasetStatus.importing, progress=0.01)
97
-
98
- try:
99
- # Stage 1 – Resolve download URL or local path
100
- source_path = await _stage_acquire(job_id, req)
101
-
102
- # Stage 2 – Extract / Prepare Directory
103
- extract_dir = await _stage_extract(job_id, req.dataset_id, source_path)
104
-
105
- # Stage 3 – Detect adapter and Task
106
- await ds_reg.update_job(job_id, progress=0.55, message="Detecting dataset format...")
107
- adapter = await asyncio.to_thread(get_adapter_for_path, extract_dir)
108
-
109
- if not adapter:
110
- log.warning("no_adapter_found_generic_fallback", dataset_id=req.dataset_id)
111
- image_records = await asyncio.to_thread(_scan_images_generic, req.dataset_id, extract_dir)
112
- class_names = []
113
- task = DatasetTask.classification
114
- fmt_name = "custom"
115
- else:
116
- task = adapter.get_task(extract_dir)
117
- fmt_name = adapter.__class__.__name__.replace("Adapter", "").lower()
118
-
119
- log.info("adapter_detected", job_id=job_id, format=fmt_name, task=task)
120
- await ds_reg.update_job(job_id, progress=0.60, message=f"Parsing {fmt_name.upper()} {task.upper()}")
121
-
122
- # Stage 4 – Parse Metadata & Annotations (Streaming)
123
- class_names = await asyncio.to_thread(adapter.get_class_names, extract_dir)
124
- image_records = []
125
- all_annotations = []
126
-
127
- # Health metrics tracking
128
- hashes = {} # hash -> filename
129
- duplicates = 0
130
- empty_images = 0
131
- total_ann_count = 0
132
-
133
- for img_rec, anns in adapter.iter_items(req.dataset_id, extract_dir):
134
- # Duplicate detection via MD5 hash
135
- abs_path = extract_dir / img_rec["rel_path"]
136
- if abs_path.exists():
137
- img_hash = _calculate_hash(abs_path)
138
- if img_hash in hashes:
139
- duplicates += 1
140
- img_rec["metadata"] = json.dumps({"is_duplicate": True, "original": hashes[img_hash]})
141
- else:
142
- hashes[img_hash] = img_rec["filename"]
143
-
144
- if not anns:
145
- empty_images += 1
146
-
147
- total_ann_count += len(anns)
148
- image_records.append(img_rec)
149
- all_annotations.extend(anns)
150
-
151
- if not image_records:
152
- raise ValueError(f"No valid data files found in {extract_dir}")
153
-
154
- # Stage 5 – Indexing
155
- await ds_reg.update_job(job_id, progress=0.80, message=f"Indexing {len(image_records)} items")
156
- await ds_reg.index_images(req.dataset_id, image_records)
157
-
158
- if all_annotations:
159
- await ds_reg.update_job(job_id, progress=0.85, message=f"Indexing {len(all_annotations)} annotations")
160
- await ds_reg.bulk_insert_annotations(all_annotations)
161
-
162
- # Stage 6 – Stats & Health Analysis
163
- size_bytes = await asyncio.to_thread(_dir_size, extract_dir)
164
-
165
- # Calculate Health Score (0-100)
166
- # Factors: duplicates, empty images (for detection), class balance (TODO)
167
- score = 100.0
168
- if len(image_records) > 0:
169
- dup_penalty = (duplicates / len(image_records)) * 50
170
- empty_penalty = (empty_images / len(image_records)) * 20 if task == DatasetTask.detection else 0
171
- score = max(0.0, 100.0 - dup_penalty - empty_penalty)
172
-
173
- stats_payload = {
174
- "image_count": len(image_records),
175
- "annotation_count": total_ann_count,
176
- "class_count": len(class_names),
177
- "empty_images": empty_images,
178
- "duplicate_count": duplicates,
179
- "health_score": round(score, 1),
180
- "avg_objects": round(total_ann_count / len(image_records), 2) if image_records else 0
181
- }
182
-
183
- await ds_reg.update_dataset_stats(
184
- req.dataset_id,
185
- len(image_records),
186
- len(class_names),
187
- class_names,
188
- size_bytes,
189
- stats=stats_payload
190
- )
191
- await ds_reg.update_dataset_task(req.dataset_id, task)
192
-
193
- # Cleanup temp zip if applicable
194
- if source_path.is_file() and source_path.suffix.lower() == ".zip" and "_tmp" in str(source_path):
195
- source_path.unlink(missing_ok=True)
196
-
197
- # Stage 7 – Project Linking (Integration point)
198
- local_path = str(extract_dir)
199
- from projects.service import link_dataset_to_active_project
200
- project_ds_root = await link_dataset_to_active_project(req.dataset_id, local_path)
201
- final_local_path = str(project_ds_root) if project_ds_root and project_ds_root.exists() else local_path
202
-
203
- # Completion
204
- await ds_reg.update_job(
205
- job_id, status="completed", progress=1.0,
206
- message="Import complete", ended_at=datetime.utcnow().isoformat(),
207
- )
208
- await ds_reg.update_dataset_status(req.dataset_id, DatasetStatus.imported, progress=1.0, local_path=final_local_path)
209
- await audit("dataset_import_complete", {"job_id": job_id, "path": final_local_path}, job_id=job_id)
210
- log.info("import_complete", job_id=job_id, dataset_id=req.dataset_id)
211
-
212
- except asyncio.CancelledError:
213
- await _fail_job(job_id, req.dataset_id, "Import cancelled by user or system")
214
- raise
215
- except Exception as exc:
216
- log.error("import_failed", job_id=job_id, error=str(exc))
217
- await _fail_job(job_id, req.dataset_id, str(exc))
218
- await audit("dataset_import_error", {"job_id": job_id, "error": str(exc)}, job_id=job_id, level="error")
219
-
220
-
221
- async def _fail_job(job_id: str, dataset_id: str, error: str) -> None:
222
- await ds_reg.update_job(
223
- job_id, status="failed", error=error,
224
- ended_at=datetime.utcnow().isoformat(),
225
- message="Import failed",
226
- )
227
- await ds_reg.update_dataset_status(dataset_id, DatasetStatus.failed, progress=0.0)
228
-
229
-
230
- # ── Stage 1: Acquire source ──────────────────────────────────────────────────
231
-
232
- async def _stage_acquire(job_id: str, req: ImportRequest) -> Path:
233
- """Resolves the source (Download URL, HF Repo, or Local Path)."""
234
- await ds_reg.update_job(job_id, progress=0.05, message="Acquiring source...")
235
-
236
- if req.source in ("roboflow", "roboflow_curl"):
237
- return await _acquire_roboflow(job_id, req)
238
-
239
- if req.source == "huggingface":
240
- return await _acquire_huggingface(job_id, req)
241
-
242
- if req.source == "local":
243
- return await _acquire_local(job_id, req)
244
-
245
- raise ValueError(f"Unsupported source provider: {req.source}")
246
-
247
-
248
- async def _acquire_roboflow(job_id: str, req: ImportRequest) -> Path:
249
- """Specialized Roboflow downloader using SDK or direct link."""
250
- # Attempt SDK first (more reliable for Universe)
251
- try:
252
- from roboflow import Roboflow
253
- api_key = req.roboflow_key or (req.headers.get("Authorization") if req.headers else None)
254
- if api_key and "Bearer " in str(api_key):
255
- api_key = api_key.split("Bearer ")[-1].strip()
256
-
257
- if api_key and req.roboflow_workspace and req.roboflow_project:
258
- rf = Roboflow(api_key=api_key)
259
- project = rf.workspace(req.roboflow_workspace).project(req.roboflow_project)
260
- version_obj = project.version(req.roboflow_version or 1)
261
-
262
- tmp_target = DATASETS_ROOT / "_tmp" / f"rf-{uuid.uuid4().hex[:8]}"
263
- await ds_reg.update_job(job_id, progress=0.10, message="Downloading via Roboflow SDK...")
264
-
265
- # Threaded SDK call
266
- await asyncio.to_thread(
267
- version_obj.download,
268
- _format_to_rf_slug(str(req.format)),
269
- location=str(tmp_target)
270
- )
271
- return tmp_target
272
- except Exception as e:
273
- log.warning("roboflow_sdk_fallback", error=str(e))
274
-
275
- # Fallback to direct HTTP download
276
- url = req.download_url
277
- if not url and req.source == "roboflow":
278
- from adapters.roboflow_adapter import RoboflowAdapter
279
- url = await RoboflowAdapter.get_download_url(
280
- api_key=req.roboflow_key,
281
- workspace=req.roboflow_workspace,
282
- project_id=req.roboflow_project,
283
- version=req.roboflow_version,
284
- export_format=_format_to_rf_slug(str(req.format)),
285
- )
286
-
287
- if not url:
288
- raise ValueError("Could not resolve Roboflow download URL")
289
-
290
- return await _download_zip(job_id, req.dataset_id, url, req.headers)
291
-
292
-
293
- async def _acquire_huggingface(job_id: str, req: ImportRequest) -> Path:
294
- if not req.hf_dataset_id:
295
- raise ValueError("hf_dataset_id is missing")
296
-
297
- dest_dir = _dataset_path(req.dataset_id)
298
- dest_dir.mkdir(parents=True, exist_ok=True)
299
-
300
- await ds_reg.update_job(job_id, progress=0.10, message=f"Cloning {req.hf_dataset_id} from HF...")
301
-
302
- await asyncio.to_thread(
303
- snapshot_download,
304
- repo_id=req.hf_dataset_id,
305
- repo_type="dataset",
306
- local_dir=str(dest_dir),
307
- token=settings.hf_token,
308
- local_dir_use_symlinks=False
309
- )
310
- return dest_dir
311
-
312
-
313
- async def _acquire_local(job_id: str, req: ImportRequest) -> Path:
314
- if not req.local_path:
315
- raise ValueError("local_path is missing for local import")
316
-
317
- path = Path(os.path.normpath(req.local_path.strip().strip('"').strip("'")))
318
- if not path.exists():
319
- raise FileNotFoundError(f"Local path does not exist: {path}")
320
-
321
- return path
322
-
323
-
324
- # ── Stage 2: Extraction ──────────────────────────────────────────────────────
325
-
326
- async def _stage_extract(job_id: str, dataset_id: str, source_path: Path) -> Path:
327
- dest = _dataset_path(dataset_id)
328
- dest.mkdir(parents=True, exist_ok=True)
329
-
330
- if source_path.is_dir():
331
- if source_path == dest:
332
- return dest
333
- await ds_reg.update_job(job_id, progress=0.45, message="Copying local files...")
334
- await asyncio.to_thread(_copy_dir_contents, source_path, dest)
335
- return dest
336
-
337
- # It's a zip
338
- await ds_reg.update_job(job_id, progress=0.45, message="Extracting archive...")
339
- await ds_reg.update_dataset_status(dataset_id, DatasetStatus.extracting, progress=0.45)
340
- await asyncio.to_thread(_safe_extract, source_path, dest)
341
- return dest
342
-
343
-
344
- # ── Stage 3: Parsing (Memory-Safe) ───────────────────────────────────────────
345
-
346
- def _heuristic_task_detection(fmt: str, root: Path) -> DatasetTask:
347
- """Improved task detection based on file content."""
348
- if fmt == "csv":
349
- return DatasetTask.nlp
350
-
351
- # Check for segmentation in COCO
352
- if fmt == "coco":
353
- # Sample first few lines of JSON if possible or check file size
354
- return DatasetTask.segmentation # Heuristic: most modern COCO use cases
355
-
356
- if fmt in ("yolo", "voc"):
357
- return DatasetTask.detection
358
-
359
- return DatasetTask.classification
360
-
361
-
362
- def _parse_yolo(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
363
- class_map = YOLOParser.load_class_map(root)
364
- results = []
365
- # Generator approach to keep memory low
366
- for rel_path, image_id, split, anns in YOLOParser.iter_dataset(root, dataset_id, class_map):
367
- abs_path = root / rel_path
368
- w, h = _img_dimensions(abs_path)
369
- img_rec = {
370
- "id": image_id, "filename": Path(rel_path).name,
371
- "rel_path": str(rel_path), "width": w, "height": h,
372
- "split": split, "ann_count": len(anns),
373
- }
374
- results.append((img_rec, anns))
375
- return class_map, results
376
-
377
-
378
- def _parse_coco(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
379
- ann_files = COCOParser.find_annotation_files(root)
380
- all_classes: list[str] = []
381
- results = []
382
- for ann_file in ann_files:
383
- classes, coco_results = COCOParser.parse_file(ann_file, dataset_id)
384
- all_classes = list(dict.fromkeys(all_classes + classes))
385
- for rel_path, image_id, split, anns in coco_results:
386
- abs_path = root / rel_path
387
- w, h = _img_dimensions(abs_path)
388
- img_rec = {
389
- "id": image_id, "filename": Path(rel_path).name,
390
- "rel_path": str(rel_path), "width": w, "height": h,
391
- "split": split, "ann_count": len(anns),
392
- }
393
- results.append((img_rec, anns))
394
- return all_classes, results
395
-
396
-
397
- def _parse_voc(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
398
- class_set = set()
399
- results = []
400
- for rel_path, image_id, split, w, h, anns in VOCParser.iter_dataset(root, dataset_id):
401
- img_rec = {
402
- "id": image_id, "filename": Path(rel_path).name,
403
- "rel_path": str(rel_path), "width": w, "height": h,
404
- "split": split, "ann_count": len(anns),
405
- }
406
- results.append((img_rec, anns))
407
- for ann in anns:
408
- class_set.add(ann["label"])
409
- return sorted(list(class_set)), results
410
-
411
-
412
- def _parse_csv(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
413
- all_classes = set()
414
- results = []
415
- for csv_path in root.rglob("*.csv"):
416
- anns = CSVParser.parse_file(csv_path, dataset_id)
417
- # For CSV, each annotation is a row. We group by text entry id (image_id)
418
- anns_by_id: Dict[str, List[Dict]] = {}
419
- for ann in anns:
420
- all_classes.add(ann["label"])
421
- anns_by_id.setdefault(ann["image_id"], []).append(ann)
422
-
423
- for text_id, grouped_anns in anns_by_id.items():
424
- img_rec = {
425
- "id": text_id, "filename": csv_path.name,
426
- "rel_path": str(csv_path.relative_to(root)),
427
- "width": 0, "height": 0, "split": "train", "ann_count": len(grouped_anns),
428
- }
429
- results.append((img_rec, grouped_anns))
430
- return sorted(list(all_classes)), results
431
-
432
-
433
- def _parse_txt(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
434
- from datasets.annotation_parser import RoboflowTXTParser
435
- results = []
436
- class_set = set()
437
-
438
- for rel_path, image_id, split, anns in RoboflowTXTParser.iter_dataset(root, dataset_id):
439
- abs_path = root / rel_path
440
- w, h = _img_dimensions(abs_path)
441
- img_rec = {
442
- "id": image_id, "filename": Path(rel_path).name,
443
- "rel_path": str(rel_path), "width": w, "height": h,
444
- "split": split, "ann_count": len(anns),
445
- }
446
- results.append((img_rec, anns))
447
- for ann in anns:
448
- class_set.add(ann["label"])
449
-
450
- return sorted(list(class_set)), results
451
-
452
-
453
- def _parse_generic_folder(dataset_id: str, root: Path) -> Tuple[List[str], List[Tuple[Dict, List[Dict]]]]:
454
- """
455
- Enhanced generic folder parser. Supports:
456
- 1. root/class_name/img.jpg
457
- 2. root/train/class_name/img.jpg
458
- 3. root/images/img.jpg
459
- """
460
- results = []
461
- class_set = set()
462
- exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"}
463
-
464
- # Structural keywords to ignore as classes
465
- ignore = {"images", "labels", "train", "val", "test", "validation", "training", "valid", "testing", "unknown", "annotations"}
466
-
467
- for img_path in sorted(root.rglob("*")):
468
- if img_path.suffix.lower() not in exts:
469
- continue
470
-
471
- rel_path = img_path.relative_to(root)
472
- parts = rel_path.parts
473
-
474
- # Heuristic for class detection
475
- label = "unknown"
476
- split = "train"
477
-
478
- # Detect split if first folder is a split keyword
479
- if parts[0].lower() in ignore and len(parts) > 1:
480
- if parts[0].lower() in ("train", "training"): split = "train"
481
- elif parts[0].lower() in ("val", "valid", "validation"): split = "val"
482
- elif parts[0].lower() in ("test", "testing"): split = "test"
483
-
484
- # Check if next part is class name
485
- if len(parts) > 2 and parts[1].lower() not in ignore:
486
- label = parts[1]
487
- elif len(parts) > 1 and parts[1].lower() not in ignore:
488
- label = parts[1]
489
- elif len(parts) > 1 and parts[0].lower() not in ignore:
490
- label = parts[0]
491
-
492
- anns = []
493
- if label != "unknown":
494
- class_set.add(label)
495
- image_id = f"img-{uuid.uuid4().hex[:12]}"
496
- # Create a virtual annotation for classification
497
- from datasets.annotation_parser import _make_ann
498
- anns.append(_make_ann(image_id, dataset_id, label, ann_type="classification"))
499
- else:
500
- image_id = f"img-{uuid.uuid4().hex[:12]}"
501
-
502
- w, h = _img_dimensions(img_path)
503
- img_rec = {
504
- "id": image_id,
505
- "filename": img_path.name,
506
- "rel_path": str(rel_path),
507
- "width": w, "height": h,
508
- "split": split,
509
- "ann_count": len(anns),
510
- }
511
- results.append((img_rec, anns))
512
-
513
- return sorted(list(class_set)), results
514
-
515
-
516
- # ── Utilities ────────────────────────────────────────────────────────────────
517
-
518
- async def _download_zip(job_id: str, dataset_id: str, url: str, custom_headers: dict = None) -> Path:
519
- tmp_dir = DATASETS_ROOT / "_tmp"
520
- tmp_dir.mkdir(parents=True, exist_ok=True)
521
- zip_path = tmp_dir / f"{dataset_id}-{uuid.uuid4().hex[:8]}.zip"
522
-
523
- headers = {
524
- "User-Agent": "Mozilla/5.0 (MLForge Workbench)",
525
- "Accept": "application/zip, application/octet-stream, */*",
526
- }
527
- if custom_headers: headers.update(custom_headers)
528
-
529
- async with httpx.AsyncClient(follow_redirects=True, timeout=600.0, headers=headers) as client:
530
- async with client.stream("GET", url) as resp:
531
- resp.raise_for_status()
532
- total = int(resp.headers.get("content-length", 0)) or None
533
- downloaded = 0
534
- async with aiofiles.open(zip_path, "wb") as f:
535
- async for chunk in resp.aiter_bytes(chunk_size=settings.download_chunk_size):
536
- await f.write(chunk)
537
- downloaded += len(chunk)
538
- if total:
539
- pct = 0.10 + (downloaded / total) * 0.35 # 10% -> 45%
540
- await ds_reg.update_job(job_id, progress=round(pct, 3), message=f"Downloading: {_fmt_bytes(downloaded)} / {_fmt_bytes(total)}")
541
-
542
- return zip_path
543
-
544
-
545
- def _safe_extract(zip_path: Path, dest: Path) -> None:
546
- with zipfile.ZipFile(str(zip_path), "r") as zf:
547
- for member in zf.namelist():
548
- if os.path.isabs(member) or ".." in Path(member).parts: continue
549
- zf.extract(member, str(dest))
550
-
551
-
552
- def _copy_dir_contents(src: Path, dest: Path) -> None:
553
- for item in src.iterdir():
554
- s, d = src / item.name, dest / item.name
555
- if s.is_dir(): shutil.copytree(s, d, dirs_exist_ok=True)
556
- else: shutil.copy2(s, d)
557
-
558
-
559
- def _scan_images_generic(dataset_id: str, root: Path) -> list[dict]:
560
- records = []
561
- exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
562
- for img_path in sorted(root.rglob("*")):
563
- if img_path.suffix.lower() in exts:
564
- w, h = _img_dimensions(img_path)
565
- records.append({
566
- "id": f"img-{uuid.uuid4().hex[:12]}",
567
- "filename": img_path.name,
568
- "rel_path": str(img_path.relative_to(root)),
569
- "width": w, "height": h, "split": "train", "ann_count": 0,
570
- })
571
- return records
572
-
573
-
574
- def _dir_size(path: Path) -> int:
575
- return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
576
-
577
-
578
- def _fmt_bytes(n: int) -> str:
579
- for unit in ("B", "KB", "MB", "GB", "TB"):
580
- if n < 1024: return f"{n:.1f} {unit}"
581
- n /= 1024
582
- return f"{n:.1f} PB"
583
-
584
-
585
- def _format_to_rf_slug(fmt: str) -> str:
586
- return {"yolo": "yolov8", "coco": "coco", "voc": "voc"}.get(fmt, "yolov8")
587
-
588
- def _format_to_rf_slug(fmt: str) -> str:
589
- return {"yolo": "yolov8", "coco": "coco", "voc": "voc"}.get(fmt, "yolov8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
datasets/viewer_service.py DELETED
@@ -1,320 +0,0 @@
1
- """
2
- datasets/viewer_service.py β€” Dataset Viewer Service.
3
-
4
- Provides paginated image + annotation serving for the Dataset Viewer UI.
5
- All paths are resolved relative to the dataset's local_path for security.
6
- """
7
- from __future__ import annotations
8
-
9
- from pathlib import Path
10
-
11
- from datasets import registry as ds_reg
12
- from models.dataset import (
13
- Annotation, AnnotationType, BoundingBox, Dataset,
14
- ImageRecord, ViewerPage, DatasetFormat
15
- )
16
- from datasets.annotation_parser import YOLOParser, COCOParser, VOCParser, CSVParser
17
- from observability.logger import get_logger
18
-
19
- log = get_logger("viewer_service")
20
-
21
-
22
- from .format_adapters import NLPAdapter, TabularAdapter
23
- from models.dataset import UniversalViewerPage, UniversalDatasetItem, UniversalAnnotation, DatasetContentType, DatasetTask
24
-
25
- async def get_universal_viewer_page(
26
- dataset_id: str,
27
- page: int = 0,
28
- page_size: int = 20,
29
- split: str | None = None,
30
- class_label: str | None = None,
31
- ) -> UniversalViewerPage:
32
- """Polymorphic viewer endpoint that adapts based on dataset task."""
33
- ds = await ds_reg.get_dataset(dataset_id)
34
- if not ds:
35
- raise ValueError("Dataset not found")
36
-
37
- ds_root = Path(ds.local_path) if ds.local_path else None
38
-
39
- # 1. Vision Tasks (Detection, Seg, Pose) -> Use existing image-centric logic
40
- if ds.task in (DatasetTask.detection, DatasetTask.segmentation, DatasetTask.keypoints):
41
- # We wrap the existing get_viewer_page and transform to UniversalDatasetItem
42
- old_page = await get_viewer_page(dataset_id, page, page_size, split, class_label)
43
-
44
- items = []
45
- for img in old_page.images:
46
- items.append(UniversalDatasetItem(
47
- id=img.image_id,
48
- content_type=DatasetContentType.image,
49
- filename=img.filename,
50
- metadata={"width": img.width, "height": img.height, "split": img.split},
51
- annotations=[
52
- UniversalAnnotation(
53
- label=ann.label,
54
- type=ann.type.value if hasattr(ann.type, 'value') else str(ann.type),
55
- bbox=[ann.bbox.x, ann.bbox.y, ann.bbox.width, ann.bbox.height] if ann.bbox else None,
56
- segmentation=ann.segmentation,
57
- keypoints=ann.keypoints,
58
- confidence=ann.confidence,
59
- metadata=ann.metadata
60
- ) for ann in img.annotations
61
- ]
62
- ))
63
-
64
- return UniversalViewerPage(
65
- dataset_id=dataset_id,
66
- page=page,
67
- page_size=page_size,
68
- total=old_page.total,
69
- total_pages=old_page.total_pages,
70
- items=items
71
- )
72
-
73
- # 2. NLP Tasks (CSV, JSONL)
74
- elif ds.task == DatasetTask.nlp and ds_root:
75
- adapter = NLPAdapter()
76
- total, items = await adapter.get_items(ds_root, page, page_size)
77
- total_pages = max(1, (total + page_size - 1) // page_size)
78
- return UniversalViewerPage(
79
- dataset_id=dataset_id,
80
- page=page,
81
- page_size=page_size,
82
- total=total,
83
- total_pages=total_pages,
84
- items=items
85
- )
86
-
87
- # 3. Tabular Tasks (CSV, Parquet)
88
- elif ds.task == DatasetTask.tabular and ds_root:
89
- adapter = TabularAdapter()
90
- total, items = await adapter.get_items(ds_root, page, page_size)
91
- total_pages = max(1, (total + page_size - 1) // page_size)
92
- return UniversalViewerPage(
93
- dataset_id=dataset_id,
94
- page=page,
95
- page_size=page_size,
96
- total=total,
97
- total_pages=total_pages,
98
- items=items
99
- )
100
-
101
- # Fallback / Empty
102
- return UniversalViewerPage(
103
- dataset_id=dataset_id,
104
- page=page,
105
- page_size=page_size,
106
- total=0,
107
- total_pages=0,
108
- items=[]
109
- )
110
-
111
- async def get_viewer_page(
112
- dataset_id: str,
113
- page: int = 0,
114
- page_size: int = 20,
115
- split: str | None = None,
116
- class_label: str | None = None,
117
- ) -> ViewerPage:
118
- """
119
- Return a paginated viewer page for the dataset.
120
- Images come from the index; annotations are loaded per-image.
121
- """
122
- if page_size > 100:
123
- page_size = 100 # cap to prevent huge payloads
124
-
125
- total, image_rows = await ds_reg.get_image_page(dataset_id, page, page_size, split, class_label)
126
- ds = await ds_reg.get_dataset(dataset_id)
127
-
128
- # Check if we have an active project and if the dataset exists there
129
- from projects.service import get_active_project_path
130
- project_path = await get_active_project_path()
131
-
132
- # Dynamically load annotations from database first, fallback to filesystem if needed
133
- image_ids = [row["id"] for row in image_rows]
134
- dynamic_anns: dict[str, list[Annotation]] = {img_id: [] for img_id in image_ids}
135
-
136
- # 1. Try loading from DB index (Authoritative for analytics)
137
- try:
138
- from database.connection import get_db
139
- db = await get_db()
140
- # Fetch all annotations for these images in one go
141
- placeholders = ",".join(["?"] * len(image_ids))
142
- async with db.execute(
143
- f"SELECT * FROM dataset_annotations WHERE image_id IN ({placeholders})",
144
- image_ids
145
- ) as cur:
146
- rows = await cur.fetchall()
147
- for r in rows:
148
- dynamic_anns[r["image_id"]].append(_row_to_annotation(dict(r)))
149
- except Exception as e:
150
- log.warning("db_annotation_read_failed", error=str(e), dataset_id=dataset_id)
151
-
152
- # 2. Fallback to filesystem if no annotations found in DB and we have a path
153
- # This maintains compatibility with old datasets or specific live-read needs
154
- if all(not anns for anns in dynamic_anns.values()) and ds and ds.local_path:
155
- ds_root = Path(ds.local_path)
156
- # Use ds.local_path directly as it is now authoritative project-local path
157
- # Fallback to global removed per user request
158
-
159
- fmt = ds.format.value if hasattr(ds.format, 'value') else str(ds.format)
160
-
161
- try:
162
- if fmt == DatasetFormat.yolo.value or fmt == "yolo":
163
- class_map = YOLOParser.load_class_map(ds_root)
164
- for row in image_rows:
165
- rel_path = Path(row["rel_path"])
166
- # For YOLO, the label file is usually in a parallel 'labels' folder
167
- # or in the same folder as the image.
168
- # Roboflow structure: train/images/img.jpg -> train/labels/img.txt
169
- parts = list(rel_path.parts)
170
-
171
- label_rel = None
172
- if "images" in parts:
173
- idx = parts.index("images")
174
- parts_labels = list(parts)
175
- parts_labels[idx] = "labels"
176
- label_rel = Path(*parts_labels).with_suffix(".txt")
177
-
178
- # Fallback: same folder
179
- label_same_folder = rel_path.with_suffix(".txt")
180
-
181
- for cand_rel in [label_rel, label_same_folder]:
182
- if not cand_rel: continue
183
- label_file = ds_root / cand_rel
184
- if label_file.exists():
185
- anns = YOLOParser.parse_file(label_file, row["id"], ds.id, class_map)
186
- dynamic_anns[row["id"]] = [_row_to_annotation(a) for a in anns]
187
- break
188
-
189
- elif fmt == DatasetFormat.coco.value or fmt == "coco":
190
- jsons = COCOParser.find_annotation_files(ds_root)
191
- img_map = {row["filename"]: row["id"] for row in image_rows}
192
- for jf in jsons:
193
- _, parsed = COCOParser.parse_file(jf, ds.id)
194
- for p_rel, _, _, anns in parsed:
195
- fname = Path(p_rel).name
196
- if fname in img_map:
197
- img_id = img_map[fname]
198
- dynamic_anns[img_id].extend([_row_to_annotation(a) for a in anns])
199
-
200
- elif fmt == DatasetFormat.voc.value or fmt == "voc":
201
- for row in image_rows:
202
- img_abs = ds_root / row["rel_path"]
203
- xml_candidates = [img_abs.with_suffix(".xml")]
204
- parts = list(Path(row["rel_path"]).parts)
205
- if "JPEGImages" in parts:
206
- idx = parts.index("JPEGImages")
207
- parts[idx] = "Annotations"
208
- xml_candidates.append(ds_root.joinpath(*parts).with_suffix(".xml"))
209
-
210
- for cand in xml_candidates:
211
- if cand.exists():
212
- _, _, _, anns = VOCParser.parse_file(cand, row["id"], ds.id)
213
- dynamic_anns[row["id"]] = [_row_to_annotation(a) for a in anns]
214
- break
215
-
216
- elif fmt == "csv":
217
- for row in image_rows:
218
- csv_path = ds_root / row["rel_path"]
219
- if csv_path.exists():
220
- # For CSV/NLP, we might need a more specific way to find the exact row,
221
- # but for now we reload the file or use a cached version.
222
- # Since get_viewer_page is paginated, we'll parse the file.
223
- anns = CSVParser.parse_file(csv_path, ds.id)
224
- # Find the annotation matching this "image_id" (which is the text entry id)
225
- matching_anns = [a for a in anns if a["image_id"] == row["id"]]
226
- dynamic_anns[row["id"]] = [_row_to_annotation(a) for a in matching_anns]
227
-
228
- except Exception as e:
229
- log.error("dynamic_annotation_read_failed", error=str(e), dataset_id=dataset_id)
230
-
231
- images: list[ImageRecord] = []
232
- for row in image_rows:
233
- annotations = dynamic_anns.get(row["id"], [])
234
- images.append(ImageRecord(
235
- image_id = row["id"],
236
- filename = row["filename"],
237
- width = row["width"],
238
- height = row["height"],
239
- path = row["rel_path"],
240
- annotations = annotations,
241
- split = row["split"],
242
- ))
243
-
244
- total_pages = max(1, (total + page_size - 1) // page_size)
245
-
246
- return ViewerPage(
247
- dataset_id = dataset_id,
248
- page = page,
249
- page_size = page_size,
250
- total = total,
251
- total_pages = total_pages,
252
- images = images,
253
- )
254
-
255
-
256
- def _row_to_annotation(row: dict) -> Annotation:
257
- bbox = None
258
- if row.get("bbox_x") is not None:
259
- bbox = BoundingBox(
260
- x = row["bbox_x"],
261
- y = row["bbox_y"],
262
- width = row["bbox_w"],
263
- height = row["bbox_h"],
264
- normalised = bool(row.get("normalised", 1)),
265
- )
266
-
267
- segmentation = None
268
- if row.get("segmentation"):
269
- try:
270
- import json
271
- segmentation = json.loads(row["segmentation"])
272
- except:
273
- pass
274
-
275
- return Annotation(
276
- label = row["label"],
277
- bbox = bbox,
278
- segmentation = segmentation,
279
- confidence = row.get("confidence"),
280
- area = row.get("area"),
281
- type = AnnotationType(row.get("ann_type", "detection")),
282
- )
283
-
284
-
285
- async def resolve_image_path(dataset_id: str, image_id: str) -> Path | None:
286
- """
287
- Resolve the absolute filesystem path for an image.
288
- Prioritizes the active project's dataset folder, falling back to the global cache.
289
- Returns None if dataset not imported or image not found.
290
- """
291
- ds = await ds_reg.get_dataset(dataset_id)
292
- if ds is None or not ds.local_path:
293
- return None
294
-
295
- base_root = Path(ds.local_path)
296
- # ds.local_path is now authoritative project-local path
297
- # Fallback removed per user request
298
-
299
- from database.connection import get_db
300
- db = await get_db()
301
- async with db.execute(
302
- "SELECT rel_path FROM dataset_images WHERE id=? AND dataset_id=?",
303
- (image_id, dataset_id),
304
- ) as cur:
305
- row = await cur.fetchone()
306
- if not row:
307
- return None
308
-
309
- abs_path = base_root / row["rel_path"]
310
- if not abs_path.exists():
311
- return None
312
-
313
- # Security: ensure path is under base_root
314
- try:
315
- abs_path.resolve().relative_to(base_root.resolve())
316
- except ValueError:
317
- log.warning("path_traversal_attempt", dataset_id=dataset_id, image_id=image_id)
318
- return None
319
-
320
- return abs_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/__pycache__/analytics.cpython-310.pyc ADDED
Binary file (2.27 kB). View file
 
models/project.py DELETED
@@ -1,15 +0,0 @@
1
- """models/project.py β€” Pydantic domain model for workspace projects."""
2
-
3
- from __future__ import annotations
4
-
5
- from pydantic import BaseModel
6
-
7
-
8
- class Project(BaseModel):
9
- id: str
10
- name: str
11
- path: str
12
- created_at: str
13
- last_opened: str
14
- status: str = "idle"
15
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
projects/__init__.py ADDED
File without changes
projects/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (143 Bytes). View file
 
projects/__pycache__/registry.cpython-310.pyc ADDED
Binary file (2.76 kB). View file
 
projects/__pycache__/service.cpython-310.pyc ADDED
Binary file (5.61 kB). View file
 
projects/registry.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """projects/registry.py β€” Project persistence in SQLite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ from database.connection import get_db
8
+ from models.project import Project
9
+
10
+
11
+ async def upsert_project(project: Project) -> None:
12
+ db = await get_db()
13
+ await db.execute(
14
+ """INSERT INTO projects (id, name, path, created_at, last_opened, status)
15
+ VALUES (?,?,?,?,?,?)
16
+ ON CONFLICT(id) DO UPDATE SET
17
+ name=excluded.name,
18
+ path=excluded.path,
19
+ last_opened=excluded.last_opened,
20
+ status=excluded.status
21
+ """,
22
+ (
23
+ project.id,
24
+ project.name,
25
+ project.path,
26
+ project.created_at,
27
+ project.last_opened,
28
+ project.status,
29
+ ),
30
+ )
31
+ await db.commit()
32
+
33
+
34
+ async def list_projects(limit: int = 200, offset: int = 0) -> list[Project]:
35
+ db = await get_db()
36
+ async with db.execute(
37
+ """SELECT id, name, path, created_at, last_opened, status
38
+ FROM projects
39
+ ORDER BY datetime(last_opened) DESC
40
+ LIMIT ? OFFSET ?
41
+ """,
42
+ (limit, offset),
43
+ ) as cur:
44
+ rows = await cur.fetchall()
45
+ return [Project(**dict(r)) for r in rows]
46
+
47
+
48
+ async def delete_project(project_id: str) -> bool:
49
+ db = await get_db()
50
+ cur = await db.execute("DELETE FROM projects WHERE id = ?", (project_id,))
51
+ await db.commit()
52
+ return (cur.rowcount or 0) > 0
53
+
54
+
55
+ async def get_project(project_id: str) -> Project | None:
56
+ db = await get_db()
57
+ async with db.execute(
58
+ "SELECT id, name, path, created_at, last_opened, status FROM projects WHERE id = ?",
59
+ (project_id,),
60
+ ) as cur:
61
+ row = await cur.fetchone()
62
+ return Project(**dict(row)) if row else None
63
+
64
+
65
+ async def touch_last_opened(project_id: str) -> None:
66
+ db = await get_db()
67
+ now = datetime.now(timezone.utc).isoformat()
68
+ await db.execute(
69
+ "UPDATE projects SET last_opened = ? WHERE id = ?",
70
+ (now, project_id),
71
+ )
72
+ await db.commit()
73
+
projects/service.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ projects/service.py β€” Active project session + model workspace linking.
3
+
4
+ Tracks which project is currently open (via the `session` DB table) and
5
+ copies freshly-downloaded model files into the project's models/ folder
6
+ so the benchmark engine and other workspaces can locate them.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import uuid
13
+ from pathlib import Path
14
+ from datetime import datetime, timezone
15
+
16
+ from database.connection import get_db
17
+ from observability.logger import get_logger, audit
18
+ from models.model import Model, ModelMetrics
19
+ from registry.registry import upsert_model
20
+
21
+ log = get_logger("projects.service")
22
+
23
+
24
+ async def import_local_model(
25
+ name: str,
26
+ task: str,
27
+ framework: str,
28
+ source_file_path: str
29
+ ) -> Model:
30
+ """Import a local model file into the active project."""
31
+ project_id = await get_active_project_id()
32
+ project_path = await get_active_project_path()
33
+
34
+ if not project_id or not project_path:
35
+ raise ValueError("No active project found. Please open a project first.")
36
+
37
+ src = Path(source_file_path)
38
+ if not src.exists():
39
+ raise FileNotFoundError(f"Source model file not found: {source_file_path}")
40
+
41
+ # Create destination directory in project
42
+ model_id = f"local-{uuid.uuid4().hex[:12]}"
43
+ dest_dir = Path(project_path) / "models" / model_id
44
+ dest_dir.mkdir(parents=True, exist_ok=True)
45
+
46
+ dest_path = dest_dir / src.name
47
+ shutil.copy2(src, dest_path)
48
+
49
+ # Calculate size
50
+ size_bytes = dest_path.stat().st_size
51
+ size_label = f"{size_bytes / (1024*1024):.1f} MB" if size_bytes > 1024*1024 else f"{size_bytes / 1024:.1f} KB"
52
+
53
+ # Create model entry
54
+ now = datetime.now(timezone.utc).isoformat()
55
+ model = Model(
56
+ id=model_id,
57
+ name=name,
58
+ task=task,
59
+ framework=framework,
60
+ source="local",
61
+ provider="Local Import",
62
+ size=size_bytes,
63
+ size_label=size_label,
64
+ local_path=str(dest_path),
65
+ project_id=project_id,
66
+ downloaded=True,
67
+ status="cached",
68
+ created_at=now,
69
+ updated_at=now,
70
+ metrics=ModelMetrics()
71
+ )
72
+
73
+ await upsert_model(model)
74
+ await audit("model_imported_locally", model_id=model_id, payload={"name": name, "project_id": project_id})
75
+ log.info("model_imported_locally", model_id=model_id, name=name, path=str(dest_path))
76
+
77
+ return model
78
+
79
+
80
+ # ── Session helpers ───────────────────────────────────────────────────────────
81
+
82
+ async def set_active_project(project_id: str, project_path: str) -> None:
83
+ """Persist the currently open project in the session table."""
84
+ db = await get_db()
85
+ await db.execute(
86
+ "INSERT INTO session (key, value) VALUES ('active_project_id', ?)"
87
+ " ON CONFLICT(key) DO UPDATE SET value=excluded.value",
88
+ (project_id,),
89
+ )
90
+ await db.execute(
91
+ "INSERT INTO session (key, value) VALUES ('active_project_path', ?)"
92
+ " ON CONFLICT(key) DO UPDATE SET value=excluded.value",
93
+ (project_path,),
94
+ )
95
+ await db.commit()
96
+ log.info("active_project_set", project_id=project_id, path=project_path)
97
+
98
+
99
+ async def get_active_project_id() -> str | None:
100
+ """Return the ID of the currently open project, or None."""
101
+ db = await get_db()
102
+ async with db.execute(
103
+ "SELECT value FROM session WHERE key = 'active_project_id'"
104
+ ) as cur:
105
+ row = await cur.fetchone()
106
+ return row["value"] if row else None
107
+
108
+
109
+ async def get_active_project_path() -> str | None:
110
+ """Return the filesystem path of the currently open project, or None."""
111
+ db = await get_db()
112
+ async with db.execute(
113
+ "SELECT value FROM session WHERE key = 'active_project_path'"
114
+ ) as cur:
115
+ row = await cur.fetchone()
116
+ return row["value"] if row else None
117
+
118
+
119
+ # ── Workspace model linking ───────────────────────────────────────────────────
120
+
121
+ async def link_model_to_active_project(model_id: str, source_path: str) -> None:
122
+ """Copy the downloaded model file into the active project's models/ folder.
123
+
124
+ This is a best-effort operation β€” if no project is open, or if the copy
125
+ fails for any reason, we log and continue rather than failing the download.
126
+ """
127
+ project_path = await get_active_project_path()
128
+ if not project_path:
129
+ log.debug("link_model_skipped_no_project", model_id=model_id)
130
+ return
131
+
132
+ src = Path(source_path)
133
+ if not src.exists():
134
+ log.warning("link_model_source_missing", model_id=model_id, path=source_path)
135
+ return
136
+
137
+ dest_dir = Path(project_path) / "models" / model_id
138
+ dest_dir.mkdir(parents=True, exist_ok=True)
139
+ dest = dest_dir / src.name
140
+
141
+ if dest.exists():
142
+ log.debug("link_model_already_exists", model_id=model_id, dest=str(dest))
143
+ return
144
+
145
+ try:
146
+ shutil.copy2(src, dest)
147
+ log.info("model_linked_to_project", model_id=model_id, project=project_path, dest=str(dest))
148
+ except OSError as exc:
149
+ log.warning("link_model_copy_failed", model_id=model_id, error=str(exc))
150
+
151
+
152
+ async def link_dataset_to_active_project(dataset_id: str, source_path: str) -> None:
153
+ """Copy the imported dataset folder into the active project's datasets/ folder.
154
+
155
+ This is a best-effort operation β€” if no project is open, or if the copy
156
+ fails for any reason, we log and continue rather than failing the import.
157
+ """
158
+ project_path = await get_active_project_path()
159
+ if not project_path:
160
+ log.debug("link_dataset_skipped_no_project", dataset_id=dataset_id)
161
+ return
162
+
163
+ src = Path(source_path)
164
+ if not src.exists():
165
+ log.warning("link_dataset_source_missing", dataset_id=dataset_id, path=source_path)
166
+ return
167
+
168
+ dest_dir = Path(project_path) / "datasets" / dataset_id
169
+
170
+ if dest_dir.exists():
171
+ log.debug("link_dataset_already_exists", dataset_id=dataset_id, dest=str(dest_dir))
172
+ return
173
+
174
+ try:
175
+ if src.is_dir():
176
+ shutil.copytree(src, dest_dir, dirs_exist_ok=True)
177
+ else:
178
+ # If it's a file (e.g. zip that wasn't extracted yet), just copy it
179
+ dest_dir.parent.mkdir(parents=True, exist_ok=True)
180
+ shutil.copy2(src, dest_dir)
181
+
182
+ log.info("dataset_linked_to_project", dataset_id=dataset_id, project=project_path, dest=str(dest_dir))
183
+ return dest_dir
184
+ except OSError as exc:
185
+ log.warning("link_dataset_copy_failed", dataset_id=dataset_id, error=str(exc))
186
+ return None