Spaces:
Sleeping
Sleeping
| """FastAPI routes for the captcha solver API.""" | |
| from __future__ import annotations | |
| import time | |
| from threading import Lock | |
| from fastapi import APIRouter, HTTPException | |
| from captcha_solver.config import get_settings | |
| from captcha_solver.models import ( | |
| HealthResponse, | |
| ModelsResponse, | |
| SolveRequest, | |
| SolveResponse, | |
| StatsResponse, | |
| ) | |
| from captcha_solver.solvers.router import SolverRouter | |
| from captcha_solver.utils.cache import SolveCache | |
| from captcha_solver.utils.image import audio_size_hash, perceptual_hash | |
| router = APIRouter() | |
| _state: dict = { | |
| "router": None, | |
| "cache": None, | |
| "stats": { | |
| "total": 0, | |
| "by_type": {}, | |
| "by_solver": {}, | |
| "success": 0, | |
| "elapsed_sum_ms": 0, | |
| "cache_hits": 0, | |
| "started_at": time.time(), | |
| }, | |
| "_stats_lock": Lock(), | |
| } | |
| def get_state() -> dict: | |
| if _state["router"] is None: | |
| s = get_settings() | |
| r = SolverRouter() | |
| r.init() | |
| _state["router"] = r | |
| _state["cache"] = SolveCache( | |
| ttl_seconds=s.cache_ttl_seconds, | |
| max_entries=s.cache_max_entries, | |
| ) | |
| return _state | |
| def solve(req: SolveRequest) -> SolveResponse: | |
| s = get_settings() | |
| state = get_state() | |
| t0 = time.time() | |
| captcha_type = req.type or "auto" | |
| if not req.image_base64 and not req.audio_base64 and not req.hint: | |
| raise HTTPException(status_code=400, detail="must provide image_base64, audio_base64, or hint") | |
| cache_key = None | |
| if req.use_cache and s.cache_enabled: | |
| if req.image_base64: | |
| try: | |
| from captcha_solver.utils.image import decode_base64_image | |
| cache_key = "img:" + perceptual_hash(decode_base64_image(req.image_base64)) | |
| except Exception: | |
| cache_key = None | |
| elif req.audio_base64: | |
| try: | |
| from captcha_solver.utils.image import decode_base64_audio | |
| cache_key = "aud:" + audio_size_hash(decode_base64_audio(req.audio_base64)) | |
| except Exception: | |
| cache_key = None | |
| if cache_key: | |
| hit = state["cache"].get(cache_key) | |
| if hit is not None: | |
| with state["_stats_lock"]: | |
| state["stats"]["total"] += 1 | |
| state["stats"]["by_type"][captcha_type] = state["stats"]["by_type"].get(captcha_type, 0) + 1 | |
| state["stats"]["by_solver"][hit.solver] = state["stats"]["by_solver"].get(hit.solver, 0) + 1 | |
| state["stats"]["success"] += 1 | |
| state["stats"]["cache_hits"] += 1 | |
| return SolveResponse( | |
| success=True, | |
| answer=hit.answer, | |
| confidence=hit.confidence, | |
| solver=hit.solver, | |
| elapsed_ms=int((time.time() - t0) * 1000), | |
| cache_hit=True, | |
| ) | |
| router_obj: SolverRouter = state["router"] | |
| attempt = router_obj.solve( | |
| captcha_type=captcha_type, | |
| image_b64=req.image_base64, | |
| audio_b64=req.audio_base64, | |
| hint=req.hint, | |
| ) | |
| elapsed_ms = int((time.time() - t0) * 1000) | |
| success = bool(attempt.answer) and attempt.confidence > 0 | |
| with state["_stats_lock"]: | |
| st = state["stats"] | |
| st["total"] += 1 | |
| st["by_type"][captcha_type] = st["by_type"].get(captcha_type, 0) + 1 | |
| st["by_solver"][attempt.solver_name] = st["by_solver"].get(attempt.solver_name, 0) + 1 | |
| if success: | |
| st["success"] += 1 | |
| st["elapsed_sum_ms"] += elapsed_ms | |
| if success and cache_key and s.cache_enabled: | |
| state["cache"].set(cache_key, attempt.answer, attempt.solver_name, attempt.confidence) | |
| return SolveResponse( | |
| success=success, | |
| answer=attempt.answer or None, | |
| confidence=attempt.confidence, | |
| solver=attempt.solver_name, | |
| elapsed_ms=elapsed_ms, | |
| cache_hit=False, | |
| attempts=len(attempt.metadata) + 1, | |
| error=attempt.error, | |
| ) | |
| def health() -> HealthResponse: | |
| state = get_state() | |
| engines = state["router"].engine_status() if state["router"] else {} | |
| status = "ok" | |
| if any(v == "not_loaded" for v in engines.values()): | |
| status = "degraded" | |
| if all(v == "not_loaded" for v in engines.values()): | |
| status = "down" | |
| return HealthResponse( | |
| status=status, | |
| version="0.1.0", | |
| engines=engines, | |
| uptime_s=time.time() - state["stats"]["started_at"], | |
| ) | |
| def stats() -> StatsResponse: | |
| state = get_state() | |
| st = state["stats"] | |
| total = st["total"] | |
| return StatsResponse( | |
| total_requests=total, | |
| by_type=dict(st["by_type"]), | |
| by_solver=dict(st["by_solver"]), | |
| success_rate=(st["success"] / total) if total else 0.0, | |
| avg_elapsed_ms=(st["elapsed_sum_ms"] / total) if total else 0.0, | |
| cache_hits=st["cache_hits"], | |
| ) | |
| def models() -> ModelsResponse: | |
| state = get_state() | |
| engines = state["router"].engine_status() if state["router"] else {} | |
| loaded = [k for k, v in engines.items() if v == "loaded"] | |
| available = [ | |
| {"name": "faster-whisper (tiny)", "size_mb": 75, "engine": "whisper", "purpose": "audio captcha"}, | |
| {"name": "faster-whisper (base)", "size_mb": 150, "engine": "whisper", "purpose": "audio captcha (better)"}, | |
| {"name": "Florence-2-base", "size_mb": 1200, "engine": "florence2", "purpose": "OCR + detection"}, | |
| {"name": "Moondream2", "size_mb": 1700, "engine": "moondream2", "purpose": "image grid VQA"}, | |
| {"name": "Qwen2.5-1.5B-Instruct", "size_mb": 1500, "engine": "qwen", "purpose": "math / text reasoning"}, | |
| {"name": "Qwen2-VL 7B (via ollama)", "size_mb": 4500, "engine": "ollama", "purpose": "best vision (optional)"}, | |
| ] | |
| return ModelsResponse( | |
| loaded=loaded, | |
| available=available, | |
| ollama_enabled=engines.get("ollama") == "loaded", | |
| ) | |
| def clear_cache() -> dict: | |
| state = get_state() | |
| if state["cache"]: | |
| state["cache"].clear() | |
| return {"cleared": True} | |
| def visual_qa(req: dict) -> dict: | |
| """Visual Question Answering with Moondream2. | |
| Request body: | |
| image_base64: str - base64-encoded image | |
| question: str - question about the image | |
| Response: | |
| answer: str - model's answer | |
| confidence: float | |
| solver: str - which model answered | |
| """ | |
| image_b64 = req.get("image_base64", "") | |
| question = req.get("question", "") | |
| if not image_b64: | |
| raise HTTPException(status_code=400, detail="image_base64 is required") | |
| if not question: | |
| raise HTTPException(status_code=400, detail="question is required") | |
| state = get_state() | |
| router_obj = state["router"] | |
| md = router_obj._engines.get("moondream2") | |
| if not md: | |
| raise HTTPException(status_code=503, detail="Moondream2 not loaded") | |
| from captcha_solver.utils.image import decode_base64_image, image_to_pil | |
| try: | |
| img = image_to_pil(decode_base64_image(image_b64)) | |
| except Exception as e: | |
| raise HTTPException(status_code=400, detail=f"bad image: {e}") | |
| try: | |
| answer = md.query(img, question) | |
| return {"answer": answer, "confidence": 0.6, "solver": "moondream2.vqa"} | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # --- hCaptcha tile classification --- | |
| def classify_tile(req: dict) -> dict: | |
| """Classify a single hCaptcha tile against an instruction. | |
| Request body: | |
| image_base64: str - base64-encoded tile image | |
| instruction: str - hCaptcha instruction (e.g. "Find all items made by people") | |
| Response: | |
| match: bool - whether the tile matches the instruction | |
| confidence: float - classification confidence | |
| caption: str - Florence-2 caption of the tile | |
| solver: str - which solver was used | |
| """ | |
| from captcha_solver.solvers.hcaptcha_solver import classify_tile as _classify | |
| image_b64 = req.get("image_base64", "") | |
| instruction = req.get("instruction", "") | |
| if not image_b64: | |
| raise HTTPException(status_code=400, detail="image_base64 is required") | |
| if not instruction: | |
| raise HTTPException(status_code=400, detail="instruction is required") | |
| state = get_state() | |
| router_obj = state["router"] | |
| # Build SolveContext from router's engines | |
| from captcha_solver.solvers.base import SolveContext | |
| ctx = SolveContext( | |
| whisper=router_obj._engines.get("whisper"), | |
| florence=router_obj._engines.get("florence2"), | |
| moondream=router_obj._engines.get("moondream2"), | |
| qwen=router_obj._engines.get("qwen"), | |
| ollama=router_obj._engines.get("ollama"), | |
| ) | |
| return _classify(image_b64, instruction, ctx) | |
| # --- Learning stats --- | |
| try: | |
| from captcha_solver.learning.db import LearningDB | |
| _learning_db = LearningDB() | |
| except ImportError: | |
| _learning_db = None | |
| def get_learning_stats() -> dict: | |
| if not _learning_db: | |
| return {"enabled": False, "error": "learning module not available"} | |
| summary = _learning_db.summary() | |
| rankings = _learning_db.get_solver_ranking() | |
| return { | |
| "enabled": True, | |
| "summary": summary, | |
| "rankings": rankings, | |
| "recent_failures": _learning_db.get_recent_failures(5), | |
| } | |