Spaces:
Sleeping
Sleeping
Commit Β·
dd271a6
1
Parent(s): 1ae9dc5
feat: implement asynchronous polling system with background tasks
Browse files- app/main.py +158 -52
- app/schemas.py +25 -1
- 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 |
-
"
|
| 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 |
-
# ββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 130 |
return HealthResponse()
|
| 131 |
|
| 132 |
|
| 133 |
@app.post(
|
| 134 |
"/analyze",
|
| 135 |
-
response_model=
|
| 136 |
-
summary="
|
| 137 |
tags=["Analysis"],
|
| 138 |
responses={
|
| 139 |
400: {"model": ErrorResponse, "description": "Unsupported file type"},
|
| 140 |
413: {"model": ErrorResponse, "description": "File too large"},
|
| 141 |
-
|
| 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** β
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
| 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.
|
| 195 |
try:
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
except Exception as exc:
|
| 210 |
-
logger.exception("
|
| 211 |
raise HTTPException(
|
| 212 |
-
status_code=status.
|
| 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 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
# ββ
|
| 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 |
-
#
|
| 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 |
-
|
| 90 |
-
assert
|
| 91 |
-
assert
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|