aliSaac510 commited on
Commit
dd271a6
Β·
1 Parent(s): 1ae9dc5

feat: implement asynchronous polling system with background tasks

Browse files
Files changed (3) hide show
  1. app/main.py +158 -52
  2. app/schemas.py +25 -1
  3. tests/test_api.py +40 -55
app/main.py CHANGED
@@ -6,11 +6,15 @@ Main entry point: routes, startup, error handlers.
6
  from __future__ import annotations
7
 
8
  import logging
 
 
9
  import time
 
10
  from contextlib import asynccontextmanager
 
11
  from typing import Optional
12
 
13
- from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile, status
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.responses import JSONResponse
16
 
@@ -26,13 +30,15 @@ from app.schemas import (
26
  AnalysisResponse,
27
  ErrorResponse,
28
  HealthResponse,
 
 
 
29
  MediaType,
30
  )
31
  from app.utils import (
32
  AUDIO_EXTENSIONS,
33
  VIDEO_EXTENSIONS,
34
  detect_extension,
35
- save_upload_to_tempfile,
36
  validate_extension,
37
  )
38
 
@@ -46,6 +52,13 @@ logging.basicConfig(
46
  logger = logging.getLogger("lecturelens.main")
47
 
48
 
 
 
 
 
 
 
 
49
  # ── Lifespan (startup/shutdown) ───────────────────────────────────────────────
50
 
51
  @asynccontextmanager
@@ -79,7 +92,7 @@ app = FastAPI(
79
  "- Detailed KPI metrics (loudness, SNR, sharpness, brightness, …)\n"
80
  "- Structured alerts with severity levels and actionable fixes\n"
81
  "- A composite quality score (0–1)\n\n"
82
- "Authentication: `X-API-Key` header."
83
  ),
84
  version="1.0.0",
85
  docs_url="/docs",
@@ -117,7 +130,76 @@ async def generic_exception_handler(request, exc: Exception):
117
  )
118
 
119
 
120
- # ── Routes ─────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
  @app.get(
123
  "/health",
@@ -126,23 +208,23 @@ async def generic_exception_handler(request, exc: Exception):
126
  tags=["System"],
127
  )
128
  async def health():
129
- """Returns service status and version. No authentication required."""
130
  return HealthResponse()
131
 
132
 
133
  @app.post(
134
  "/analyze",
135
- response_model=AnalysisResponse,
136
- summary="Analyze audio or video file",
137
  tags=["Analysis"],
138
  responses={
139
  400: {"model": ErrorResponse, "description": "Unsupported file type"},
140
  413: {"model": ErrorResponse, "description": "File too large"},
141
- 422: {"model": ErrorResponse, "description": "Corrupted or unreadable file"},
142
- 500: {"model": ErrorResponse, "description": "Internal processing failure"},
143
  },
144
  )
145
  async def analyze(
 
146
  file: UploadFile = File(..., description="Audio (M4A/WAV/MP3) or video (MP4/MOV) file"),
147
  media_type: MediaType = Form(..., description="'audio' or 'video'"),
148
  participant_label: Optional[str] = Form(
@@ -152,15 +234,11 @@ async def analyze(
152
  settings: Settings = Depends(get_settings),
153
  ):
154
  """
155
- **POST /analyze** β€” the single analysis endpoint.
156
 
157
- - Send `media_type=audio` for per-speaker audio files (M4A, WAV, MP3…).
158
- - Send `media_type=video` for the full recording video (MP4, MOV…).
159
- - Use `participant_label` to tag audio files with the speaker's name;
160
- the value is echoed back in the response so n8n can correlate results.
161
  """
162
- t_start = time.perf_counter()
163
-
164
  # ── 1. File size guard ────────────────────────────────────────────────────
165
  content = await file.read()
166
  if len(content) > settings.max_file_size_bytes:
@@ -191,47 +269,75 @@ async def analyze(
191
  },
192
  )
193
 
194
- # ── 3. Run analysis inside a temp file ────────────────────────────────────
195
  try:
196
- async with save_upload_to_tempfile(content, suffix=ext) as tmp_path:
197
- if media_type == MediaType.audio:
198
- metrics = await analyze_audio(tmp_path, settings.dnsmos_model_path)
199
- alerts = generate_alerts(metrics, "audio", settings.thresholds_config_path)
200
- audio_score = compute_audio_score(metrics)
201
- video_score = None
202
- else:
203
- from app.analyzers.video_analyzer import analyze_video
204
- metrics = await analyze_video(tmp_path, settings.frame_sample_rate_seconds)
205
- alerts = generate_alerts(metrics, "video", settings.thresholds_config_path)
206
- video_score = compute_video_score(metrics)
207
- audio_score = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
 
209
  except Exception as exc:
210
- logger.exception("Analysis failed for file '%s': %s", filename, exc)
211
  raise HTTPException(
212
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
213
- detail={
214
- "error_code": "CORRUPTED_FILE",
215
- "detail": f"Could not process the uploaded file: {exc}",
216
- },
217
  )
218
 
219
- processing_time = round(time.perf_counter() - t_start, 3)
220
- logger.info(
221
- "βœ… %s analysis complete β€” %s | %.1fs | %d alert(s)",
222
- media_type.value.upper(),
223
- participant_label or filename,
224
- processing_time,
225
- len(alerts),
226
- )
227
 
228
- return AnalysisResponse(
229
- status="completed",
230
- media_type=media_type,
231
- participant_label=participant_label,
232
- processing_time_seconds=processing_time,
233
- metrics=metrics,
234
- alerts=alerts,
235
- overall_audio_score=audio_score,
236
- overall_video_score=video_score,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  )
 
6
  from __future__ import annotations
7
 
8
  import logging
9
+ import os
10
+ import tempfile
11
  import time
12
+ import uuid
13
  from contextlib import asynccontextmanager
14
+ from pathlib import Path
15
  from typing import Optional
16
 
17
+ from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, UploadFile, status
18
  from fastapi.middleware.cors import CORSMiddleware
19
  from fastapi.responses import JSONResponse
20
 
 
30
  AnalysisResponse,
31
  ErrorResponse,
32
  HealthResponse,
33
+ JobStatus,
34
+ JobSubmitResponse,
35
+ JobResultResponse,
36
  MediaType,
37
  )
38
  from app.utils import (
39
  AUDIO_EXTENSIONS,
40
  VIDEO_EXTENSIONS,
41
  detect_extension,
 
42
  validate_extension,
43
  )
44
 
 
52
  logger = logging.getLogger("lecturelens.main")
53
 
54
 
55
+ # ── Global Jobs DB (In-Memory) ────────────────────────────────────────────────
56
+
57
+ # Since HF Spaces free tier sleeps and loses memory, this is ephemeral.
58
+ # Keys are job_ids (UUIDs).
59
+ jobs_db: dict[str, dict] = {}
60
+
61
+
62
  # ── Lifespan (startup/shutdown) ───────────────────────────────────────────────
63
 
64
  @asynccontextmanager
 
92
  "- Detailed KPI metrics (loudness, SNR, sharpness, brightness, …)\n"
93
  "- Structured alerts with severity levels and actionable fixes\n"
94
  "- A composite quality score (0–1)\n\n"
95
+ "*(Note: API Key authentication is currently disabled for testing)*"
96
  ),
97
  version="1.0.0",
98
  docs_url="/docs",
 
130
  )
131
 
132
 
133
+ # ── Background Task ───────────────────────────────────────────────────────────
134
+
135
+ async def process_job_background(
136
+ job_id: str,
137
+ tmp_path: Path,
138
+ media_type: MediaType,
139
+ participant_label: Optional[str],
140
+ settings: Settings,
141
+ filename: str
142
+ ):
143
+ """
144
+ Executes the analysis in the background, updates jobs_db, and cleans up the temp file.
145
+ Includes extensive logging for debugging.
146
+ """
147
+ logger.info(f"[{job_id}] βš™οΈ Started background processing for file: {filename}")
148
+ t_start = time.perf_counter()
149
+
150
+ try:
151
+ if media_type == MediaType.audio:
152
+ logger.info(f"[{job_id}] 🎧 Step 1/3: Starting audio analysis...")
153
+ metrics = await analyze_audio(tmp_path, settings.dnsmos_model_path)
154
+
155
+ logger.info(f"[{job_id}] 🎧 Step 2/3: Generating alerts against thresholds...")
156
+ alerts = generate_alerts(metrics, "audio", settings.thresholds_config_path)
157
+
158
+ logger.info(f"[{job_id}] 🎧 Step 3/3: Computing composite audio score...")
159
+ audio_score = compute_audio_score(metrics)
160
+ video_score = None
161
+ else:
162
+ logger.info(f"[{job_id}] 🎬 Step 1/3: Starting video analysis...")
163
+ from app.analyzers.video_analyzer import analyze_video
164
+ metrics = await analyze_video(tmp_path, settings.frame_sample_rate_seconds)
165
+
166
+ logger.info(f"[{job_id}] 🎬 Step 2/3: Generating alerts against thresholds...")
167
+ alerts = generate_alerts(metrics, "video", settings.thresholds_config_path)
168
+
169
+ logger.info(f"[{job_id}] 🎬 Step 3/3: Computing composite video score...")
170
+ video_score = compute_video_score(metrics)
171
+ audio_score = None
172
+
173
+ processing_time = round(time.perf_counter() - t_start, 3)
174
+ logger.info(f"[{job_id}] βœ… Analysis completed successfully in {processing_time}s with {len(alerts)} alert(s).")
175
+
176
+ result = AnalysisResponse(
177
+ media_type=media_type,
178
+ participant_label=participant_label,
179
+ processing_time_seconds=processing_time,
180
+ metrics=metrics,
181
+ alerts=alerts,
182
+ overall_audio_score=audio_score,
183
+ overall_video_score=video_score,
184
+ )
185
+
186
+ jobs_db[job_id]["status"] = JobStatus.completed
187
+ jobs_db[job_id]["result"] = result
188
+
189
+ except Exception as exc:
190
+ logger.exception(f"[{job_id}] ❌ Error processing file {filename}: {exc}")
191
+ jobs_db[job_id]["status"] = JobStatus.failed
192
+ jobs_db[job_id]["error_detail"] = str(exc)
193
+
194
+ finally:
195
+ logger.info(f"[{job_id}] 🧹 Cleaning up temporary file: {tmp_path}")
196
+ try:
197
+ os.unlink(tmp_path)
198
+ except Exception as e:
199
+ logger.warning(f"[{job_id}] ⚠️ Failed to delete temp file {tmp_path}: {e}")
200
+
201
+
202
+ # ── Routes ────────────────────────────────────────────────────────────────────
203
 
204
  @app.get(
205
  "/health",
 
208
  tags=["System"],
209
  )
210
  async def health():
211
+ """Returns service status and version."""
212
  return HealthResponse()
213
 
214
 
215
  @app.post(
216
  "/analyze",
217
+ response_model=JobSubmitResponse,
218
+ summary="Submit an audio or video file for analysis",
219
  tags=["Analysis"],
220
  responses={
221
  400: {"model": ErrorResponse, "description": "Unsupported file type"},
222
  413: {"model": ErrorResponse, "description": "File too large"},
223
+ 500: {"model": ErrorResponse, "description": "Internal queuing failure"},
 
224
  },
225
  )
226
  async def analyze(
227
+ background_tasks: BackgroundTasks,
228
  file: UploadFile = File(..., description="Audio (M4A/WAV/MP3) or video (MP4/MOV) file"),
229
  media_type: MediaType = Form(..., description="'audio' or 'video'"),
230
  participant_label: Optional[str] = Form(
 
234
  settings: Settings = Depends(get_settings),
235
  ):
236
  """
237
+ **POST /analyze** β€” Submit a file and receive a `job_id`.
238
 
239
+ The file is processed asynchronously in the background. Use `GET /analyze/{job_id}`
240
+ to poll for the results to avoid HTTP timeouts.
 
 
241
  """
 
 
242
  # ── 1. File size guard ────────────────────────────────────────────────────
243
  content = await file.read()
244
  if len(content) > settings.max_file_size_bytes:
 
269
  },
270
  )
271
 
272
+ # ── 3. Save to temp file and dispatch background task ─────────────────────
273
  try:
274
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
275
+ tmp.write(content)
276
+ tmp.flush()
277
+ tmp.close()
278
+ tmp_path = Path(tmp.name)
279
+
280
+ job_id = str(uuid.uuid4())
281
+ jobs_db[job_id] = {
282
+ "status": JobStatus.processing,
283
+ "filename": filename,
284
+ "result": None,
285
+ "error_detail": None
286
+ }
287
+
288
+ logger.info(f"[{job_id}] πŸ“₯ Job created for file: {filename} ({len(content) / 1e6:.2f} MB)")
289
+
290
+ background_tasks.add_task(
291
+ process_job_background,
292
+ job_id=job_id,
293
+ tmp_path=tmp_path,
294
+ media_type=media_type,
295
+ participant_label=participant_label,
296
+ settings=settings,
297
+ filename=filename
298
+ )
299
+
300
+ return JobSubmitResponse(
301
+ job_id=job_id,
302
+ status=JobStatus.processing,
303
+ message="File successfully queued for background analysis."
304
+ )
305
 
306
  except Exception as exc:
307
+ logger.exception("Failed to queue file '%s': %s", filename, exc)
308
  raise HTTPException(
309
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
310
+ detail={"error_code": "QUEUE_FAILED", "detail": f"Could not queue file: {exc}"}
 
 
 
311
  )
312
 
 
 
 
 
 
 
 
 
313
 
314
+ @app.get(
315
+ "/analyze/{job_id}",
316
+ response_model=JobResultResponse,
317
+ summary="Get job status and results",
318
+ tags=["Analysis"],
319
+ responses={
320
+ 404: {"model": ErrorResponse, "description": "Job not found"},
321
+ },
322
+ )
323
+ async def get_job_status(job_id: str):
324
+ """
325
+ **GET /analyze/{job_id}** β€” check the status of a background analysis job.
326
+
327
+ - If `status` is **processing**, wait and poll again.
328
+ - If `status` is **completed**, the `result` field will contain the full metrics and alerts.
329
+ - If `status` is **failed**, the `error_detail` field will explain why.
330
+ """
331
+ job = jobs_db.get(job_id)
332
+ if not job:
333
+ raise HTTPException(
334
+ status_code=404,
335
+ detail={"error_code": "NOT_FOUND", "detail": f"Job ID {job_id} not found."}
336
+ )
337
+
338
+ return JobResultResponse(
339
+ job_id=job_id,
340
+ status=job["status"],
341
+ result=job["result"],
342
+ error_detail=job["error_detail"]
343
  )
app/schemas.py CHANGED
@@ -29,6 +29,12 @@ class AlertCategory(str, Enum):
29
  video = "video"
30
 
31
 
 
 
 
 
 
 
32
  # ── Alert ─────────────────────────────────────────────────────────────────────
33
 
34
  class Alert(BaseModel):
@@ -109,7 +115,6 @@ class VideoMetrics(BaseModel):
109
  # ── Analysis Response ─────────────────────────────────────────────────────────
110
 
111
  class AnalysisResponse(BaseModel):
112
- status: str = "completed"
113
  media_type: MediaType
114
  participant_label: Optional[str] = None
115
  processing_time_seconds: float
@@ -126,6 +131,25 @@ class AnalysisResponse(BaseModel):
126
  )
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  # ── Health ────────────────────────────────────────────────────────────────────
130
 
131
  class HealthResponse(BaseModel):
 
29
  video = "video"
30
 
31
 
32
+ class JobStatus(str, Enum):
33
+ processing = "processing"
34
+ completed = "completed"
35
+ failed = "failed"
36
+
37
+
38
  # ── Alert ─────────────────────────────────────────────────────────────────────
39
 
40
  class Alert(BaseModel):
 
115
  # ── Analysis Response ─────────────────────────────────────────────────────────
116
 
117
  class AnalysisResponse(BaseModel):
 
118
  media_type: MediaType
119
  participant_label: Optional[str] = None
120
  processing_time_seconds: float
 
131
  )
132
 
133
 
134
+ # ── Job Polling Responses ─────────────────────────────────────────────────────
135
+
136
+ class JobSubmitResponse(BaseModel):
137
+ job_id: str
138
+ status: JobStatus = JobStatus.processing
139
+ message: str
140
+
141
+
142
+ class JobResultResponse(BaseModel):
143
+ job_id: str
144
+ status: JobStatus
145
+ result: Optional[AnalysisResponse] = Field(
146
+ None, description="Populated only if status is 'completed'"
147
+ )
148
+ error_detail: Optional[str] = Field(
149
+ None, description="Populated only if status is 'failed'"
150
+ )
151
+
152
+
153
  # ── Health ────────────────────────────────────────────────────────────────────
154
 
155
  class HealthResponse(BaseModel):
tests/test_api.py CHANGED
@@ -7,26 +7,21 @@ Requires all dependencies installed (no Docker).
7
  from __future__ import annotations
8
 
9
  import io
10
- import struct
11
  import wave
12
- from pathlib import Path
13
 
14
  import numpy as np
15
  import pytest
16
  from fastapi.testclient import TestClient
17
 
18
  from app.main import app
19
- from app.config import get_settings
20
 
21
 
22
  # ── Fixtures ───────────────────────────────────────────────────────────────────
23
 
24
  @pytest.fixture(scope="module")
25
  def client():
26
- """TestClient with valid API key injected."""
27
- settings = get_settings()
28
- headers = {"X-API-Key": settings.lecturelens_api_key}
29
- with TestClient(app, headers=headers) as c:
30
  yield c
31
 
32
 
@@ -54,58 +49,43 @@ def test_health(client):
54
  assert r.json()["status"] == "ok"
55
 
56
 
57
- # ── Authentication ─────────────────────────────────────────────────────────────
58
-
59
- def test_missing_api_key_returns_401(wav_bytes):
60
- with TestClient(app) as no_auth_client:
61
- r = no_auth_client.post(
62
- "/analyze",
63
- data={"media_type": "audio"},
64
- files={"file": ("test.wav", wav_bytes, "audio/wav")},
65
- )
66
- assert r.status_code == 401
67
- assert r.json()["error_code"] == "UNAUTHORIZED"
68
-
69
-
70
- def test_wrong_api_key_returns_401(wav_bytes):
71
- with TestClient(app, headers={"X-API-Key": "wrong-key"}) as bad_client:
72
- r = bad_client.post(
73
- "/analyze",
74
- data={"media_type": "audio"},
75
- files={"file": ("test.wav", wav_bytes, "audio/wav")},
76
- )
77
- assert r.status_code == 401
78
 
79
-
80
- # ── Audio analysis ─────────────────────────────────────────────────────────────
81
-
82
- def test_audio_analysis_returns_200(client, wav_bytes):
83
  r = client.post(
84
  "/analyze",
85
  data={"media_type": "audio", "participant_label": "test_speaker"},
86
  files={"file": ("test.wav", wav_bytes, "audio/wav")},
87
  )
88
  assert r.status_code == 200, r.text
89
- body = r.json()
90
- assert body["status"] == "completed"
91
- assert body["media_type"] == "audio"
92
- assert body["participant_label"] == "test_speaker"
93
- assert "metrics" in body
94
- assert "alerts" in body
95
- assert body["overall_audio_score"] is not None
96
-
97
-
98
- def test_audio_response_schema(client, wav_bytes):
99
- r = client.post(
100
- "/analyze",
101
- data={"media_type": "audio"},
102
- files={"file": ("test.wav", wav_bytes, "audio/wav")},
103
- )
104
- assert r.status_code == 200
105
- m = r.json()["metrics"]
106
- # All expected fields present
107
- for field in ["clipped_samples_count", "silence_segments"]:
108
- assert field in m, f"Missing field: {field}"
 
 
 
 
 
 
 
109
 
110
 
111
  # ── Validation ─────────────────────────────────────────────────────────────────
@@ -120,7 +100,12 @@ def test_wrong_extension_returns_400(client, wav_bytes):
120
  assert r.json()["error_code"] == "INVALID_MEDIA_TYPE"
121
 
122
 
123
- def test_docs_accessible():
124
- with TestClient(app) as c:
125
- r = c.get("/docs")
 
 
 
 
 
126
  assert r.status_code == 200
 
7
  from __future__ import annotations
8
 
9
  import io
10
+ import time
11
  import wave
 
12
 
13
  import numpy as np
14
  import pytest
15
  from fastapi.testclient import TestClient
16
 
17
  from app.main import app
 
18
 
19
 
20
  # ── Fixtures ───────────────────────────────────────────────────────────────────
21
 
22
  @pytest.fixture(scope="module")
23
  def client():
24
+ with TestClient(app) as c:
 
 
 
25
  yield c
26
 
27
 
 
49
  assert r.json()["status"] == "ok"
50
 
51
 
52
+ # ── Polling Analysis ───────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
+ def test_audio_analysis_polling(client, wav_bytes):
55
+ # 1. Submit the job
 
 
56
  r = client.post(
57
  "/analyze",
58
  data={"media_type": "audio", "participant_label": "test_speaker"},
59
  files={"file": ("test.wav", wav_bytes, "audio/wav")},
60
  )
61
  assert r.status_code == 200, r.text
62
+ submit_body = r.json()
63
+ assert submit_body["status"] == "processing"
64
+ assert "job_id" in submit_body
65
+ job_id = submit_body["job_id"]
66
+
67
+ # 2. Poll for the result
68
+ for _ in range(10):
69
+ r_poll = client.get(f"/analyze/{job_id}")
70
+ assert r_poll.status_code == 200
71
+ poll_body = r_poll.json()
72
+
73
+ # TestClient actually runs BackgroundTasks synchronously at the end of the request.
74
+ # So by the time we poll, it's usually already 'completed'.
75
+ if poll_body["status"] == "completed":
76
+ res = poll_body["result"]
77
+ assert res["media_type"] == "audio"
78
+ assert res["participant_label"] == "test_speaker"
79
+ assert "metrics" in res
80
+ assert "alerts" in res
81
+ assert res["overall_audio_score"] is not None
82
+ break
83
+ elif poll_body["status"] == "failed":
84
+ pytest.fail(f"Job failed: {poll_body['error_detail']}")
85
+
86
+ time.sleep(0.5)
87
+ else:
88
+ pytest.fail("Job did not complete within timeout")
89
 
90
 
91
  # ── Validation ─────────────────────────────────────────────────────────────────
 
100
  assert r.json()["error_code"] == "INVALID_MEDIA_TYPE"
101
 
102
 
103
+ def test_job_not_found(client):
104
+ r = client.get("/analyze/non-existent-job-id")
105
+ assert r.status_code == 404
106
+ assert r.json()["error_code"] == "NOT_FOUND"
107
+
108
+
109
+ def test_docs_accessible(client):
110
+ r = client.get("/docs")
111
  assert r.status_code == 200