Rthur2003 commited on
Commit
379d1f6
·
1 Parent(s): f73834f

feat: analysis schemas XAI inference YouTube downloader ve analysis job servisleri eklendi

Browse files
app/routes/analyze_schemas.py CHANGED
@@ -52,17 +52,24 @@ class AudioInfo(BaseModel):
52
 
53
 
54
  class ConfidenceBandModel(BaseModel):
 
 
 
 
 
 
55
  tier: str
56
  labelTr: str
57
  labelEn: str
58
- lowerBound: float
59
- upperBound: float
60
 
61
 
62
  class ModelVoteModel(BaseModel):
63
  name: str
64
  probability: float
65
  vote: str
 
 
66
 
67
 
68
  class FeatureContributionModel(BaseModel):
@@ -131,6 +138,9 @@ class AnalysisResult(BaseModel):
131
  xai: Optional[XAIExplanation] = None
132
  towerScores: Dict[str, float] = Field(default_factory=dict)
133
  metaClassifier: Optional[MetaClassifierExplanation] = None
 
 
 
134
 
135
 
136
  class AnalyzeResponse(BaseModel):
 
52
 
53
 
54
  class ConfidenceBandModel(BaseModel):
55
+ """How far the probability sits from the decision threshold.
56
+
57
+ A qualitative tier only — there is no bootstrap behind it, so no
58
+ interval bounds are reported.
59
+ """
60
+
61
  tier: str
62
  labelTr: str
63
  labelEn: str
64
+ margin: float = 0.0
 
65
 
66
 
67
  class ModelVoteModel(BaseModel):
68
  name: str
69
  probability: float
70
  vote: str
71
+ # False when the model file failed to load; probability is then a 0.5 placeholder.
72
+ available: bool = True
73
 
74
 
75
  class FeatureContributionModel(BaseModel):
 
138
  xai: Optional[XAIExplanation] = None
139
  towerScores: Dict[str, float] = Field(default_factory=dict)
140
  metaClassifier: Optional[MetaClassifierExplanation] = None
141
+ # Per-layer status: which model actually ran and how (e.g. CLAP embedding
142
+ # vs. its spectral heuristic fallback), so the UI can label each score.
143
+ layers: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
144
 
145
 
146
  class AnalyzeResponse(BaseModel):
app/services/analysis_jobs.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Background analysis jobs with real, per-step progress.
3
+
4
+ POST /api/analyze/jobs starts a job and returns its id; the client polls
5
+ GET /api/analyze/jobs/{id}. Each pipeline step reports when it starts and
6
+ finishes, so the waiting screen shows what the server is actually doing
7
+ instead of a simulated progress bar. A job also survives the client
8
+ leaving: a reloaded page can resume polling the same id.
9
+
10
+ State lives in process memory — the Space runs one replica, and a job
11
+ only needs to outlive the request that started it, not a restart.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import time
18
+ import uuid
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
21
+
22
+ JOB_TTL_SEC = 15 * 60
23
+ MAX_JOBS = 200
24
+
25
+ STEP_PENDING = "pending"
26
+ STEP_RUNNING = "running"
27
+ STEP_DONE = "done"
28
+ STEP_FAILED = "failed"
29
+ STEP_SKIPPED = "skipped"
30
+
31
+ FILE_STEPS = ["features", "vocals", "wav2vec2", "clap", "fst", "xai", "meta"]
32
+ URL_STEPS = ["download", *FILE_STEPS]
33
+
34
+
35
+ class Progress:
36
+ """Ordered step states for one analysis. A no-op sink is fine too."""
37
+
38
+ def __init__(self, steps: List[str]) -> None:
39
+ self._steps: Dict[str, str] = {s: STEP_PENDING for s in steps}
40
+ self._started: Dict[str, float] = {}
41
+ self._took: Dict[str, float] = {}
42
+
43
+ def set(self, step: str, state: str) -> None:
44
+ if step not in self._steps:
45
+ return
46
+ now = time.monotonic()
47
+ if state == STEP_RUNNING:
48
+ self._started.setdefault(step, now)
49
+ elif step in self._started and step not in self._took:
50
+ self._took[step] = round(now - self._started[step], 2)
51
+ self._steps[step] = state
52
+
53
+ def running(self, *steps: str) -> None:
54
+ for s in steps:
55
+ self.set(s, STEP_RUNNING)
56
+
57
+ def snapshot(self) -> List[Dict[str, Any]]:
58
+ out: List[Dict[str, Any]] = []
59
+ for step, state in self._steps.items():
60
+ item: Dict[str, Any] = {"id": step, "state": state}
61
+ if step in self._took:
62
+ item["seconds"] = self._took[step]
63
+ out.append(item)
64
+ return out
65
+
66
+
67
+ @dataclass
68
+ class Job:
69
+ id: str
70
+ source_type: str
71
+ progress: Progress
72
+ created: float = field(default_factory=time.time)
73
+ updated: float = field(default_factory=time.time)
74
+ status: str = "running" # running | done | error
75
+ response: Optional[Dict[str, Any]] = None
76
+ task: Optional[asyncio.Task] = None
77
+
78
+ def to_dict(self) -> Dict[str, Any]:
79
+ return {
80
+ "jobId": self.id,
81
+ "sourceType": self.source_type,
82
+ "status": self.status,
83
+ "steps": self.progress.snapshot(),
84
+ "elapsedSec": round((self.updated if self.status != "running" else time.time()) - self.created, 2),
85
+ "response": self.response,
86
+ }
87
+
88
+
89
+ class JobStore:
90
+ def __init__(self) -> None:
91
+ self._jobs: Dict[str, Job] = {}
92
+
93
+ def _prune(self) -> None:
94
+ cutoff = time.time() - JOB_TTL_SEC
95
+ for job_id in [j.id for j in self._jobs.values() if j.status != "running" and j.updated < cutoff]:
96
+ del self._jobs[job_id]
97
+ # Hard cap: drop the oldest finished jobs first.
98
+ if len(self._jobs) > MAX_JOBS:
99
+ finished = sorted((j for j in self._jobs.values() if j.status != "running"), key=lambda j: j.updated)
100
+ for job in finished[: len(self._jobs) - MAX_JOBS]:
101
+ del self._jobs[job.id]
102
+
103
+ def running_count(self) -> int:
104
+ return sum(1 for j in self._jobs.values() if j.status == "running")
105
+
106
+ def start(
107
+ self,
108
+ source_type: str,
109
+ steps: List[str],
110
+ work: Callable[[Progress], Awaitable[Dict[str, Any]]],
111
+ ) -> Job:
112
+ self._prune()
113
+ job = Job(id=uuid.uuid4().hex, source_type=source_type, progress=Progress(steps))
114
+ self._jobs[job.id] = job
115
+
116
+ async def runner() -> None:
117
+ try:
118
+ job.response = await work(job.progress)
119
+ job.status = "error" if job.response.get("errors") and not job.response.get("result") else "done"
120
+ except Exception: # noqa: BLE001 — the job must always settle
121
+ job.response = {"result": None, "warnings": [], "errors": ["internal_error"]}
122
+ job.status = "error"
123
+ finally:
124
+ job.updated = time.time()
125
+
126
+ # Keep a reference: asyncio only holds weak references to tasks.
127
+ job.task = asyncio.create_task(runner())
128
+ return job
129
+
130
+ def get(self, job_id: str) -> Optional[Job]:
131
+ self._prune()
132
+ return self._jobs.get(job_id)
133
+
134
+
135
+ job_store = JobStore()
app/services/inference_xai.py CHANGED
@@ -369,12 +369,11 @@ class FeatureContribution:
369
 
370
  @dataclass
371
  class ConfidenceBand:
372
- """Human-readable confidence tier."""
373
  tier: str # "uncertain" | "likely" | "strong" | "very_strong"
374
  label_tr: str
375
  label_en: str
376
- lower_bound: float # bootstrap CI lower
377
- upper_bound: float # bootstrap CI upper
378
 
379
 
380
  @dataclass
@@ -383,6 +382,7 @@ class ModelVote:
383
  name: str # XGBoost / LightGBM / ...
384
  probability: float
385
  vote: str # "ai" | "human"
 
386
 
387
 
388
  @dataclass
@@ -639,21 +639,24 @@ class XAIInferenceService:
639
  return m
640
 
641
  def _confidence_band(self, prob: float) -> ConfidenceBand:
642
- """Map probability to human-readable confidence tier + CI."""
643
- # Distance from 0.5 (decision boundary) determines confidence
644
- dist = abs(prob - 0.5)
645
- # Rough bootstrap CI — +/- 0.05 for very confident, +/- 0.1 for uncertain
646
- ci_width = 0.05 + (0.10 - 0.05) * (1.0 - min(dist * 2, 1.0))
647
- lower = max(0.0, prob - ci_width)
648
- upper = min(1.0, prob + ci_width)
649
-
650
- if dist < 0.10:
 
 
 
651
  tier = "uncertain"
652
  label_tr, label_en = "Belirsiz", "Uncertain"
653
- elif dist < 0.25:
654
  tier = "likely"
655
  label_tr, label_en = "Muhtemelen", "Likely"
656
- elif dist < 0.40:
657
  tier = "strong"
658
  label_tr, label_en = "Güçlü İşaret", "Strong"
659
  else:
@@ -664,8 +667,7 @@ class XAIInferenceService:
664
  tier=tier,
665
  label_tr=label_tr,
666
  label_en=label_en,
667
- lower_bound=round(lower, 3),
668
- upper_bound=round(upper, 3),
669
  )
670
 
671
  def _load_ensemble_models(self) -> None:
@@ -699,8 +701,9 @@ class XAIInferenceService:
699
  def _build_votes(self, x_scaled: "np.ndarray") -> List[ModelVote]:
700
  """Run real inference on all loaded ensemble models.
701
 
702
- Falls back to training-result approximation for any model
703
- that failed to load or raises at inference time.
 
704
  """
705
  votes: List[ModelVote] = []
706
  best_name = self.training_results.get("_best_model", "LightGBM")
@@ -708,23 +711,16 @@ class XAIInferenceService:
708
  all_names = list({**_ML_MODEL_FILES, **_DL_MODEL_FILES}.keys())
709
  for name in all_names:
710
  model = self.ensemble_models.get(name)
 
711
  if model is not None:
712
  try:
713
  prob = float(model.predict_proba(x_scaled)[0, 1])
714
  except Exception as e:
715
  logger.warning(f"Inference failed for {name}: {e}")
716
- model = None
717
-
718
- if model is None:
719
- # Fallback: approximate from training accuracy
720
- data = self.training_results.get(name, {})
721
- acc = data.get("accuracy", 0.5) if isinstance(data, dict) else 0.5
722
- # Use best model's actual prob as anchor
723
- best_data = self.training_results.get(best_name, {})
724
- best_acc = best_data.get("accuracy", 0.8) if isinstance(best_data, dict) else 0.8
725
- # Scale approximation relative to best model's training accuracy
726
- ratio = acc / best_acc if best_acc > 0 else 1.0
727
- prob = round(max(0.03, min(0.97, 0.5 + (x_scaled.flatten()[0] * 0.0 + 0.5 - 0.5) * ratio)), 3)
728
 
729
  threshold = self.threshold if name == best_name else 0.5
730
  votes.append(ModelVote(
@@ -733,7 +729,8 @@ class XAIInferenceService:
733
  vote="ai" if prob >= threshold else "human",
734
  ))
735
 
736
- return sorted(votes, key=lambda v: v.probability, reverse=True)
 
737
 
738
  def to_dict(self, result: XAIResult) -> Dict[str, Any]:
739
  """Serialize XAIResult for JSON response."""
@@ -745,8 +742,7 @@ class XAIInferenceService:
745
  "tier": result.confidence_band.tier,
746
  "labelTr": result.confidence_band.label_tr,
747
  "labelEn": result.confidence_band.label_en,
748
- "lowerBound": result.confidence_band.lower_bound,
749
- "upperBound": result.confidence_band.upper_bound,
750
  },
751
  "baseProbability": round(result.base_probability, 4),
752
  "modelVotes": [
@@ -754,6 +750,7 @@ class XAIInferenceService:
754
  "name": v.name,
755
  "probability": round(v.probability, 4),
756
  "vote": v.vote,
 
757
  }
758
  for v in result.model_votes
759
  ],
 
369
 
370
  @dataclass
371
  class ConfidenceBand:
372
+ """Human-readable confidence tier (qualitative, no interval)."""
373
  tier: str # "uncertain" | "likely" | "strong" | "very_strong"
374
  label_tr: str
375
  label_en: str
376
+ margin: float # 0 at the threshold, 1 at the far end of the verdict's side
 
377
 
378
 
379
  @dataclass
 
382
  name: str # XGBoost / LightGBM / ...
383
  probability: float
384
  vote: str # "ai" | "human"
385
+ available: bool = True # False: model failed to load, probability is a placeholder
386
 
387
 
388
  @dataclass
 
639
  return m
640
 
641
  def _confidence_band(self, prob: float) -> ConfidenceBand:
642
+ """Map probability to a qualitative tier.
643
+
644
+ Measured from the decision threshold (not 0.5): the margin is how far
645
+ the probability sits into its side of the threshold, 0 at the
646
+ threshold and 1 at 0.0 / 1.0. With a 0.5 threshold the cut points
647
+ match the old distance-from-0.5 tiers (0.1 / 0.25 / 0.4).
648
+ """
649
+ t = min(max(self.threshold, 1e-6), 1 - 1e-6)
650
+ margin = (prob - t) / (1.0 - t) if prob >= t else (t - prob) / t
651
+ margin = max(0.0, min(1.0, margin))
652
+
653
+ if margin < 0.2:
654
  tier = "uncertain"
655
  label_tr, label_en = "Belirsiz", "Uncertain"
656
+ elif margin < 0.5:
657
  tier = "likely"
658
  label_tr, label_en = "Muhtemelen", "Likely"
659
+ elif margin < 0.8:
660
  tier = "strong"
661
  label_tr, label_en = "Güçlü İşaret", "Strong"
662
  else:
 
667
  tier=tier,
668
  label_tr=label_tr,
669
  label_en=label_en,
670
+ margin=round(margin, 3),
 
671
  )
672
 
673
  def _load_ensemble_models(self) -> None:
 
701
  def _build_votes(self, x_scaled: "np.ndarray") -> List[ModelVote]:
702
  """Run real inference on all loaded ensemble models.
703
 
704
+ A model that failed to load or raises at inference time still gets
705
+ a row, marked unavailable with a neutral 0.5 placeholder — never an
706
+ invented probability.
707
  """
708
  votes: List[ModelVote] = []
709
  best_name = self.training_results.get("_best_model", "LightGBM")
 
711
  all_names = list({**_ML_MODEL_FILES, **_DL_MODEL_FILES}.keys())
712
  for name in all_names:
713
  model = self.ensemble_models.get(name)
714
+ prob: Optional[float] = None
715
  if model is not None:
716
  try:
717
  prob = float(model.predict_proba(x_scaled)[0, 1])
718
  except Exception as e:
719
  logger.warning(f"Inference failed for {name}: {e}")
720
+
721
+ if prob is None:
722
+ votes.append(ModelVote(name=name, probability=0.5, vote="human", available=False))
723
+ continue
 
 
 
 
 
 
 
 
724
 
725
  threshold = self.threshold if name == best_name else 0.5
726
  votes.append(ModelVote(
 
729
  vote="ai" if prob >= threshold else "human",
730
  ))
731
 
732
+ # Loaded models first, highest probability first; placeholders last.
733
+ return sorted(votes, key=lambda v: (v.available, v.probability), reverse=True)
734
 
735
  def to_dict(self, result: XAIResult) -> Dict[str, Any]:
736
  """Serialize XAIResult for JSON response."""
 
742
  "tier": result.confidence_band.tier,
743
  "labelTr": result.confidence_band.label_tr,
744
  "labelEn": result.confidence_band.label_en,
745
+ "margin": result.confidence_band.margin,
 
746
  },
747
  "baseProbability": round(result.base_probability, 4),
748
  "modelVotes": [
 
750
  "name": v.name,
751
  "probability": round(v.probability, 4),
752
  "vote": v.vote,
753
+ "available": v.available,
754
  }
755
  for v in result.model_votes
756
  ],
app/services/youtube_downloader.py CHANGED
@@ -13,7 +13,7 @@ import re
13
  import tempfile
14
  from dataclasses import dataclass, field
15
  from pathlib import Path
16
- from typing import List, Optional
17
 
18
  import yt_dlp
19
 
@@ -170,9 +170,18 @@ class _YtDlpLogCollector:
170
 
171
 
172
  class YouTubeDownloader:
173
- def __init__(self, output_dir: Path) -> None:
 
 
 
 
 
 
 
174
  self.output_dir = output_dir
175
  self.output_dir.mkdir(parents=True, exist_ok=True)
 
 
176
 
177
  def download(self, url: str, video_id: str) -> DownloadResult:
178
  if not url or not url.strip():
@@ -257,7 +266,13 @@ class YouTubeDownloader:
257
  """Try downloading with different player client combinations."""
258
  failures: List[DownloadAttemptFailure] = []
259
 
 
 
 
260
  if with_postprocessor:
 
 
 
261
  options["format"] = "bestaudio/best"
262
  options["postprocessors"] = [
263
  {
 
13
  import tempfile
14
  from dataclasses import dataclass, field
15
  from pathlib import Path
16
+ from typing import List, Optional, Tuple
17
 
18
  import yt_dlp
19
 
 
170
 
171
 
172
  class YouTubeDownloader:
173
+ def __init__(
174
+ self,
175
+ output_dir: Path,
176
+ clip: Optional[Tuple[float, float]] = None,
177
+ max_filesize: Optional[int] = None,
178
+ ) -> None:
179
+ """clip=(start, end) seconds downloads only that section (needs ffmpeg);
180
+ max_filesize caps the fallback path that downloads without ffmpeg."""
181
  self.output_dir = output_dir
182
  self.output_dir.mkdir(parents=True, exist_ok=True)
183
+ self.clip = clip
184
+ self.max_filesize = max_filesize
185
 
186
  def download(self, url: str, video_id: str) -> DownloadResult:
187
  if not url or not url.strip():
 
266
  """Try downloading with different player client combinations."""
267
  failures: List[DownloadAttemptFailure] = []
268
 
269
+ if self.max_filesize:
270
+ options["max_filesize"] = self.max_filesize
271
+
272
  if with_postprocessor:
273
+ if self.clip:
274
+ options["download_ranges"] = yt_dlp.utils.download_range_func(None, [self.clip])
275
+ options["force_keyframes_at_cuts"] = True
276
  options["format"] = "bestaudio/best"
277
  options["postprocessors"] = [
278
  {