Z User commited on
Commit ·
aac350d
1
Parent(s): bbc3fdf
5de94558-bd63-4dac-82f0-38a08c4a5f29
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- download/face-intel/.env.example +54 -0
- download/face-intel/README.md +64 -0
- download/face-intel/api/__init__.py +6 -0
- download/face-intel/api/container.py +171 -0
- download/face-intel/api/deps.py +57 -0
- download/face-intel/api/main.py +80 -0
- download/face-intel/api/middleware.py +56 -0
- download/face-intel/api/routes/__init__.py +7 -0
- download/face-intel/api/routes/cache.py +23 -0
- download/face-intel/api/routes/export.py +20 -0
- download/face-intel/api/routes/faces.py +53 -0
- download/face-intel/api/routes/health.py +32 -0
- download/face-intel/api/routes/jobs.py +45 -0
- download/face-intel/api/routes/providers.py +24 -0
- download/face-intel/api/routes/search.py +45 -0
- download/face-intel/api/routes/stats.py +15 -0
- download/face-intel/app.py +28 -0
- download/face-intel/confidence/__init__.py +10 -0
- download/face-intel/confidence/conflicts.py +75 -0
- download/face-intel/confidence/engine.py +183 -0
- download/face-intel/confidence/explainer.py +42 -0
- download/face-intel/config/__init__.py +5 -1
- download/face-intel/config/settings.py +29 -12
- download/face-intel/docs/ARCHITECTURE.md +609 -0
- download/face-intel/metrics/__init__.py +22 -0
- download/face-intel/metrics/collector.py +40 -0
- download/face-intel/metrics/counters.py +33 -0
- download/face-intel/metrics/health_metrics.py +89 -0
- download/face-intel/metrics/provider_metrics.py +86 -0
- download/face-intel/metrics/timings.py +46 -0
- download/face-intel/models/__init__.py +59 -0
- download/face-intel/models/health.py +38 -0
- download/face-intel/models/jobs.py +75 -0
- download/face-intel/models/providers.py +51 -0
- download/face-intel/models/reports.py +79 -0
- download/face-intel/models/responses.py +36 -0
- download/face-intel/normalization/__init__.py +23 -0
- download/face-intel/normalization/merger.py +202 -0
- download/face-intel/normalization/schema.py +53 -0
- download/face-intel/orchestrator/__init__.py +13 -0
- download/face-intel/orchestrator/health.py +37 -0
- download/face-intel/orchestrator/retry.py +89 -0
- download/face-intel/orchestrator/runner.py +199 -0
- download/face-intel/pipeline/__init__.py +36 -0
- download/face-intel/pipeline/feature_extraction.py +105 -0
- download/face-intel/pipeline/hashing.py +24 -0
- download/face-intel/pipeline/postprocessing.py +55 -0
- download/face-intel/pipeline/preprocessing.py +69 -0
- download/face-intel/pipeline/validation.py +79 -0
- download/face-intel/providers/__init__.py +24 -122
download/face-intel/.env.example
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Face Intel — environment configuration
|
| 2 |
+
# Copy to `.env` and adjust as needed.
|
| 3 |
+
# Every variable has the FI_ prefix.
|
| 4 |
+
|
| 5 |
+
# --- Core ---
|
| 6 |
+
FI_ENVIRONMENT=development
|
| 7 |
+
FI_HOST=0.0.0.0
|
| 8 |
+
FI_PORT=8000
|
| 9 |
+
FI_DEBUG=false
|
| 10 |
+
FI_LOG_LEVEL=INFO
|
| 11 |
+
FI_LOG_JSON=false
|
| 12 |
+
|
| 13 |
+
# --- Provider enable flags ---
|
| 14 |
+
FI_ENABLE_HAAR=true
|
| 15 |
+
FI_ENABLE_DNN=true
|
| 16 |
+
FI_ENABLE_MTCNN=true
|
| 17 |
+
FI_ENABLE_RETINAFACE=false
|
| 18 |
+
FI_ENABLE_FACE_RECOGNITION=true
|
| 19 |
+
FI_ENABLE_DEEPFACE=false
|
| 20 |
+
FI_ENABLE_INSIGHTFACE=false
|
| 21 |
+
FI_ENABLE_BEAUTIFULSOUP_SCRAPER=true
|
| 22 |
+
FI_ENABLE_SELENIUM_SCRAPER=true
|
| 23 |
+
FI_ENABLE_BING_SCRAPER=false
|
| 24 |
+
FI_ENABLE_DUCKDUCKGO_SCRAPER=true
|
| 25 |
+
FI_ENABLE_GOOGLE_LENS=true
|
| 26 |
+
FI_ENABLE_SERPAPI=false
|
| 27 |
+
FI_ENABLE_YANDEX=false
|
| 28 |
+
FI_ENABLE_TINEYE=false
|
| 29 |
+
|
| 30 |
+
# --- API keys (only needed if corresponding enable_* is true) ---
|
| 31 |
+
FI_SERPAPI_KEY=
|
| 32 |
+
FI_BING_API_KEY=
|
| 33 |
+
FI_TINEYE_PUBLIC_KEY=
|
| 34 |
+
FI_TINEYE_PRIVATE_KEY=
|
| 35 |
+
|
| 36 |
+
# --- Orchestrator ---
|
| 37 |
+
FI_ORCHESTRATOR_TIMEOUT_SECONDS=90
|
| 38 |
+
FI_ORCHESTRATOR_MAX_CONCURRENCY=8
|
| 39 |
+
FI_RETRY_MAX_ATTEMPTS=3
|
| 40 |
+
FI_RETRY_INITIAL_BACKOFF_SECONDS=0.5
|
| 41 |
+
FI_RETRY_MAX_BACKOFF_SECONDS=8
|
| 42 |
+
|
| 43 |
+
# --- Cache ---
|
| 44 |
+
FI_CACHE_ENABLED=true
|
| 45 |
+
FI_CACHE_TTL_SECONDS=3600
|
| 46 |
+
FI_CACHE_MAX_ENTRIES=1000
|
| 47 |
+
|
| 48 |
+
# --- Health / circuit breaker ---
|
| 49 |
+
FI_HEALTH_CHECK_INTERVAL_SECONDS=60
|
| 50 |
+
FI_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
|
| 51 |
+
FI_CIRCUIT_BREAKER_RECOVERY_SECONDS=120
|
| 52 |
+
|
| 53 |
+
# --- API ---
|
| 54 |
+
FI_RATE_LIMIT_PER_MINUTE=30
|
download/face-intel/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Face Intel
|
| 2 |
+
|
| 3 |
+
A multi-provider face intelligence platform with evidence-first,
|
| 4 |
+
explainable results.
|
| 5 |
+
|
| 6 |
+
## Architecture
|
| 7 |
+
|
| 8 |
+
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full
|
| 9 |
+
architecture documentation including dependency graph, package
|
| 10 |
+
responsibilities, execution flow, lifecycle diagrams, and the provider
|
| 11 |
+
extension guide.
|
| 12 |
+
|
| 13 |
+
## Quick Start
|
| 14 |
+
|
| 15 |
+
```bash
|
| 16 |
+
# 1. Install dependencies
|
| 17 |
+
pip install -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# 2. Configure
|
| 20 |
+
cp .env.example .env
|
| 21 |
+
# edit .env to enable/disable providers
|
| 22 |
+
|
| 23 |
+
# 3. Run
|
| 24 |
+
python app.py
|
| 25 |
+
# or: uvicorn app:app --reload
|
| 26 |
+
|
| 27 |
+
# 4. Open
|
| 28 |
+
# http://localhost:8000/docs — Swagger UI
|
| 29 |
+
# http://localhost:8000/health — health check
|
| 30 |
+
# http://localhost:8000/providers — provider list
|
| 31 |
+
# http://localhost:8000/stats — metrics
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Key Endpoints
|
| 35 |
+
|
| 36 |
+
| Method | Path | Description |
|
| 37 |
+
|---|---|---|
|
| 38 |
+
| `POST` | `/faces/detect` | Detect faces in an image |
|
| 39 |
+
| `POST` | `/faces/recognize` | Recognize faces against the gallery |
|
| 40 |
+
| `POST` | `/search/reverse` | Reverse image search |
|
| 41 |
+
| `POST` | `/search/scrape` | Scrape images from a URL |
|
| 42 |
+
| `POST` | `/jobs` | Full-pipeline job |
|
| 43 |
+
| `GET` | `/providers` | List all providers |
|
| 44 |
+
| `GET` | `/stats` | Metrics snapshot |
|
| 45 |
+
| `GET` | `/health/providers` | Per-provider health |
|
| 46 |
+
|
| 47 |
+
## Adding a New Provider
|
| 48 |
+
|
| 49 |
+
1. Implement `providers/<category>/<name>.py` (subclass `BaseProvider`).
|
| 50 |
+
2. Add one entry to `PROVIDER_MANIFEST` in `providers/registry.py`.
|
| 51 |
+
3. Add an `enable_<name>: bool = False` flag to `config/settings.py`.
|
| 52 |
+
|
| 53 |
+
That's it — the orchestrator, services, API, and UI pick it up
|
| 54 |
+
automatically. See `docs/ARCHITECTURE.md §5` for a complete example.
|
| 55 |
+
|
| 56 |
+
## Engineering Principles
|
| 57 |
+
|
| 58 |
+
- **Modular** — every provider is isolated; failures don't cascade.
|
| 59 |
+
- **Extensible** — new providers require zero orchestrator/API changes.
|
| 60 |
+
- **Production-ready** — circuit breakers, retries, caching, rate limiting, audit log.
|
| 61 |
+
- **Evidence-first** — raw provider responses preserved verbatim.
|
| 62 |
+
- **Explainable** — confidence scores decomposed into weighted sub-scores.
|
| 63 |
+
- **Clean separation** — 11 layers, strict one-way dependency direction.
|
| 64 |
+
- **DI throughout** — no global stateful objects; everything injectable.
|
download/face-intel/api/__init__.py
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API package — FastAPI app factory + DI wiring."""
|
| 2 |
+
|
| 3 |
+
from api.main import create_app
|
| 4 |
+
from api.container import ServiceContainer
|
| 5 |
+
|
| 6 |
+
__all__ = ["create_app", "ServiceContainer"]
|
download/face-intel/api/container.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DI container — constructs every dependency ONCE at app startup and
|
| 3 |
+
wires services together. This is the composition root.
|
| 4 |
+
|
| 5 |
+
No globals: the container is created in `create_app()` and stored on
|
| 6 |
+
`app.state.container`. Route handlers retrieve it via the `get_container`
|
| 7 |
+
FastAPI dependency.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
from config.settings import Settings, DATA_DIR, GALLERY_DIR, UPLOADS_DIR, JOBS_DIR
|
| 15 |
+
from confidence.engine import ConfidenceEngine
|
| 16 |
+
from confidence.conflicts import ConflictDetector
|
| 17 |
+
from metrics.collector import MetricsCollector
|
| 18 |
+
from normalization.merger import ReportMerger
|
| 19 |
+
from orchestrator.health import HealthMonitor
|
| 20 |
+
from orchestrator.runner import Orchestrator
|
| 21 |
+
from pipeline import (
|
| 22 |
+
InputValidator,
|
| 23 |
+
ImagePreprocessor,
|
| 24 |
+
ImageHasher,
|
| 25 |
+
FeatureExtractor,
|
| 26 |
+
)
|
| 27 |
+
from providers.registry import ProviderRegistry
|
| 28 |
+
from services.cache_service import CacheService
|
| 29 |
+
from services.detection_service import DetectionService
|
| 30 |
+
from services.export_service import ExportService
|
| 31 |
+
from services.health_service import HealthService
|
| 32 |
+
from services.job_service import JobService
|
| 33 |
+
from services.provider_service import ProviderService
|
| 34 |
+
from services.recognition_service import RecognitionService
|
| 35 |
+
from services.search_service import SearchService
|
| 36 |
+
from storage.artifacts import ArtifactStore
|
| 37 |
+
from storage.cache import Cache
|
| 38 |
+
from storage.database import Database
|
| 39 |
+
from storage.reference_store import ReferenceStore
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class ServiceContainer:
|
| 44 |
+
"""Holds every wired service + infrastructure object."""
|
| 45 |
+
settings: Settings
|
| 46 |
+
registry: ProviderRegistry
|
| 47 |
+
cache: Cache
|
| 48 |
+
database: Database
|
| 49 |
+
artifacts: ArtifactStore
|
| 50 |
+
reference_store: ReferenceStore
|
| 51 |
+
metrics: MetricsCollector
|
| 52 |
+
health_monitor: HealthMonitor
|
| 53 |
+
orchestrator: Orchestrator
|
| 54 |
+
confidence_engine: ConfidenceEngine
|
| 55 |
+
conflict_detector: ConflictDetector
|
| 56 |
+
validator: InputValidator
|
| 57 |
+
preprocessor: ImagePreprocessor
|
| 58 |
+
hasher: ImageHasher
|
| 59 |
+
feature_extractor: FeatureExtractor
|
| 60 |
+
detection_service: DetectionService
|
| 61 |
+
recognition_service: RecognitionService
|
| 62 |
+
search_service: SearchService
|
| 63 |
+
job_service: JobService
|
| 64 |
+
provider_service: ProviderService
|
| 65 |
+
cache_service: CacheService
|
| 66 |
+
health_service: HealthService
|
| 67 |
+
export_service: ExportService
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def build_container(settings: Settings | None = None) -> ServiceContainer:
|
| 71 |
+
"""Construct the entire dependency graph. Called once at startup."""
|
| 72 |
+
settings = settings or Settings()
|
| 73 |
+
|
| 74 |
+
# Infrastructure
|
| 75 |
+
registry = ProviderRegistry(settings)
|
| 76 |
+
registry.discover()
|
| 77 |
+
|
| 78 |
+
cache = Cache(
|
| 79 |
+
ttl_seconds=settings.cache_ttl_seconds,
|
| 80 |
+
max_entries=settings.cache_max_entries,
|
| 81 |
+
)
|
| 82 |
+
database = Database(path=settings.db_path)
|
| 83 |
+
artifacts = ArtifactStore(root=UPLOADS_DIR)
|
| 84 |
+
reference_store = ReferenceStore(root=GALLERY_DIR)
|
| 85 |
+
|
| 86 |
+
metrics = MetricsCollector(
|
| 87 |
+
failure_threshold=settings.circuit_breaker_failure_threshold,
|
| 88 |
+
recovery_seconds=settings.circuit_breaker_recovery_seconds,
|
| 89 |
+
)
|
| 90 |
+
health_monitor = HealthMonitor(metrics=metrics.health)
|
| 91 |
+
|
| 92 |
+
orchestrator = Orchestrator(
|
| 93 |
+
registry=registry,
|
| 94 |
+
cache=cache,
|
| 95 |
+
metrics=metrics,
|
| 96 |
+
health=health_monitor,
|
| 97 |
+
settings=settings,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
confidence_engine = ConfidenceEngine()
|
| 101 |
+
conflict_detector = ConflictDetector()
|
| 102 |
+
|
| 103 |
+
# Pipeline
|
| 104 |
+
validator = InputValidator()
|
| 105 |
+
preprocessor = ImagePreprocessor(max_dim=1024)
|
| 106 |
+
hasher = ImageHasher()
|
| 107 |
+
# Use the first available detection provider as the default feature extractor
|
| 108 |
+
from models.providers import ProviderCapability
|
| 109 |
+
detectors = registry.list_by_capability(ProviderCapability.DETECTION)
|
| 110 |
+
default_detector = detectors[0] if detectors else None
|
| 111 |
+
feature_extractor = FeatureExtractor(detector=default_detector)
|
| 112 |
+
|
| 113 |
+
# Services
|
| 114 |
+
detection_service = DetectionService(
|
| 115 |
+
registry=registry, orchestrator=orchestrator, cache=cache, metrics=metrics,
|
| 116 |
+
validator=validator, preprocessor=preprocessor, hasher=hasher,
|
| 117 |
+
feature_extractor=feature_extractor,
|
| 118 |
+
confidence_engine=confidence_engine, conflict_detector=conflict_detector,
|
| 119 |
+
)
|
| 120 |
+
recognition_service = RecognitionService(
|
| 121 |
+
orchestrator=orchestrator, metrics=metrics,
|
| 122 |
+
validator=validator, preprocessor=preprocessor, hasher=hasher,
|
| 123 |
+
feature_extractor=feature_extractor, reference_store=reference_store,
|
| 124 |
+
confidence_engine=confidence_engine, conflict_detector=conflict_detector,
|
| 125 |
+
)
|
| 126 |
+
search_service = SearchService(
|
| 127 |
+
orchestrator=orchestrator, metrics=metrics,
|
| 128 |
+
validator=validator, preprocessor=preprocessor, hasher=hasher,
|
| 129 |
+
feature_extractor=feature_extractor,
|
| 130 |
+
confidence_engine=confidence_engine, conflict_detector=conflict_detector,
|
| 131 |
+
)
|
| 132 |
+
job_service = JobService(
|
| 133 |
+
detection=detection_service,
|
| 134 |
+
recognition=recognition_service,
|
| 135 |
+
search=search_service,
|
| 136 |
+
database=database,
|
| 137 |
+
metrics=metrics,
|
| 138 |
+
)
|
| 139 |
+
provider_service = ProviderService(registry=registry)
|
| 140 |
+
cache_service = CacheService(cache=cache)
|
| 141 |
+
health_service = HealthService(
|
| 142 |
+
registry=registry, health_monitor=health_monitor, metrics=metrics,
|
| 143 |
+
version=settings.app_version,
|
| 144 |
+
)
|
| 145 |
+
export_service = ExportService(database=database)
|
| 146 |
+
|
| 147 |
+
return ServiceContainer(
|
| 148 |
+
settings=settings,
|
| 149 |
+
registry=registry,
|
| 150 |
+
cache=cache,
|
| 151 |
+
database=database,
|
| 152 |
+
artifacts=artifacts,
|
| 153 |
+
reference_store=reference_store,
|
| 154 |
+
metrics=metrics,
|
| 155 |
+
health_monitor=health_monitor,
|
| 156 |
+
orchestrator=orchestrator,
|
| 157 |
+
confidence_engine=confidence_engine,
|
| 158 |
+
conflict_detector=conflict_detector,
|
| 159 |
+
validator=validator,
|
| 160 |
+
preprocessor=preprocessor,
|
| 161 |
+
hasher=hasher,
|
| 162 |
+
feature_extractor=feature_extractor,
|
| 163 |
+
detection_service=detection_service,
|
| 164 |
+
recognition_service=recognition_service,
|
| 165 |
+
search_service=search_service,
|
| 166 |
+
job_service=job_service,
|
| 167 |
+
provider_service=provider_service,
|
| 168 |
+
cache_service=cache_service,
|
| 169 |
+
health_service=health_service,
|
| 170 |
+
export_service=export_service,
|
| 171 |
+
)
|
download/face-intel/api/deps.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI dependency injection helpers.
|
| 3 |
+
|
| 4 |
+
Routes use these to pull services off the request-scoped container.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from fastapi import Request
|
| 10 |
+
|
| 11 |
+
from api.container import ServiceContainer
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_container(request: Request) -> ServiceContainer:
|
| 15 |
+
"""Returns the app-wide ServiceContainer."""
|
| 16 |
+
return request.app.state.container
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# Per-service dependency callables (use as `Depends(get_detection_service)`)
|
| 20 |
+
def get_settings(request: Request):
|
| 21 |
+
return get_container(request).settings
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_detection_service(request: Request):
|
| 25 |
+
return get_container(request).detection_service
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_recognition_service(request: Request):
|
| 29 |
+
return get_container(request).recognition_service
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_search_service(request: Request):
|
| 33 |
+
return get_container(request).search_service
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_job_service(request: Request):
|
| 37 |
+
return get_container(request).job_service
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_provider_service(request: Request):
|
| 41 |
+
return get_container(request).provider_service
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_cache_service(request: Request):
|
| 45 |
+
return get_container(request).cache_service
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_health_service(request: Request):
|
| 49 |
+
return get_container(request).health_service
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def get_export_service(request: Request):
|
| 53 |
+
return get_container(request).export_service
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def get_metrics(request: Request):
|
| 57 |
+
return get_container(request).metrics
|
download/face-intel/api/main.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI app factory.
|
| 3 |
+
|
| 4 |
+
Constructs the ServiceContainer (composition root), mounts middleware,
|
| 5 |
+
registers routes, and serves the UI.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from fastapi import FastAPI
|
| 14 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
| 16 |
+
from loguru import logger
|
| 17 |
+
|
| 18 |
+
from api.container import build_container
|
| 19 |
+
from api.middleware import RequestContextMiddleware, RateLimitMiddleware
|
| 20 |
+
from api.routes import (
|
| 21 |
+
health, stats, providers, cache, jobs, faces, search, export,
|
| 22 |
+
)
|
| 23 |
+
from config.settings import Settings, UI_DIR
|
| 24 |
+
from utils.logging import setup_logging
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
| 28 |
+
"""Application factory — composes the entire dependency graph."""
|
| 29 |
+
settings = settings or Settings()
|
| 30 |
+
setup_logging(settings)
|
| 31 |
+
|
| 32 |
+
@asynccontextmanager
|
| 33 |
+
async def lifespan(app: FastAPI):
|
| 34 |
+
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
| 35 |
+
container = build_container(settings)
|
| 36 |
+
app.state.container = container
|
| 37 |
+
app.state.settings = settings
|
| 38 |
+
logger.info(f"Registered providers: {container.registry.list_names()}")
|
| 39 |
+
yield
|
| 40 |
+
logger.info("Shutting down")
|
| 41 |
+
container.database.close()
|
| 42 |
+
|
| 43 |
+
app = FastAPI(
|
| 44 |
+
title=settings.app_name,
|
| 45 |
+
version=settings.app_version,
|
| 46 |
+
description="Multi-provider face intelligence platform",
|
| 47 |
+
lifespan=lifespan,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Middleware
|
| 51 |
+
app.add_middleware(
|
| 52 |
+
CORSMiddleware,
|
| 53 |
+
allow_origins=settings.cors_origins,
|
| 54 |
+
allow_credentials=True,
|
| 55 |
+
allow_methods=["*"],
|
| 56 |
+
allow_headers=["*"],
|
| 57 |
+
)
|
| 58 |
+
app.add_middleware(RequestContextMiddleware)
|
| 59 |
+
app.add_middleware(RateLimitMiddleware, requests_per_minute=settings.rate_limit_per_minute)
|
| 60 |
+
|
| 61 |
+
# Routers
|
| 62 |
+
app.include_router(health.router, prefix="/health", tags=["health"])
|
| 63 |
+
app.include_router(stats.router, prefix="/stats", tags=["stats"])
|
| 64 |
+
app.include_router(providers.router, prefix="/providers", tags=["providers"])
|
| 65 |
+
app.include_router(cache.router, prefix="/cache", tags=["cache"])
|
| 66 |
+
app.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
|
| 67 |
+
app.include_router(faces.router, prefix="/faces", tags=["faces"])
|
| 68 |
+
app.include_router(search.router, prefix="/search", tags=["search"])
|
| 69 |
+
app.include_router(export.router, prefix="/export", tags=["export"])
|
| 70 |
+
|
| 71 |
+
# UI
|
| 72 |
+
if UI_DIR.exists():
|
| 73 |
+
app.mount("/", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
|
| 74 |
+
else:
|
| 75 |
+
@app.get("/")
|
| 76 |
+
async def root():
|
| 77 |
+
return {"app": settings.app_name, "version": settings.app_version,
|
| 78 |
+
"docs": "/docs"}
|
| 79 |
+
|
| 80 |
+
return app
|
download/face-intel/api/middleware.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API middleware — CORS, request-id, simple rate limiting.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
|
| 10 |
+
from fastapi import Request, Response
|
| 11 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RequestContextMiddleware(BaseHTTPMiddleware):
|
| 15 |
+
"""Adds a request-id to every request + measures duration."""
|
| 16 |
+
|
| 17 |
+
async def dispatch(self, request: Request, call_next):
|
| 18 |
+
import uuid
|
| 19 |
+
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
| 20 |
+
request.state.request_id = request_id
|
| 21 |
+
t0 = time.perf_counter()
|
| 22 |
+
response: Response = await call_next(request)
|
| 23 |
+
elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
| 24 |
+
response.headers["X-Request-ID"] = request_id
|
| 25 |
+
response.headers["X-Response-Time-ms"] = f"{elapsed_ms:.2f}"
|
| 26 |
+
return response
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
| 30 |
+
"""Simple in-memory per-IP rate limiter.
|
| 31 |
+
|
| 32 |
+
For production, replace with a Redis-backed limiter.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, app, requests_per_minute: int = 30):
|
| 36 |
+
super().__init__(app)
|
| 37 |
+
self._limit = requests_per_minute
|
| 38 |
+
self._buckets: dict[str, list[float]] = defaultdict(list)
|
| 39 |
+
|
| 40 |
+
async def dispatch(self, request: Request, call_next):
|
| 41 |
+
# Skip health checks
|
| 42 |
+
if request.url.path.startswith("/health"):
|
| 43 |
+
return await call_next(request)
|
| 44 |
+
client = request.client.host if request.client else "unknown"
|
| 45 |
+
now = time.time()
|
| 46 |
+
window = 60.0
|
| 47 |
+
recent = [t for t in self._buckets[client] if now - t < window]
|
| 48 |
+
if len(recent) >= self._limit:
|
| 49 |
+
return Response(
|
| 50 |
+
content='{"error":"rate limit exceeded"}',
|
| 51 |
+
status_code=429,
|
| 52 |
+
media_type="application/json",
|
| 53 |
+
)
|
| 54 |
+
recent.append(now)
|
| 55 |
+
self._buckets[client] = recent
|
| 56 |
+
return await call_next(request)
|
download/face-intel/api/routes/__init__.py
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Routes package — one router per resource."""
|
| 2 |
+
|
| 3 |
+
from api.routes import (
|
| 4 |
+
health, stats, providers, cache, jobs, faces, search, export,
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
__all__ = ["health", "stats", "providers", "cache", "jobs", "faces", "search", "export"]
|
download/face-intel/api/routes/cache.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cache routes — inspection + invalidation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends
|
| 6 |
+
|
| 7 |
+
from api.deps import get_cache_service
|
| 8 |
+
from services.cache_service import CacheService
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("")
|
| 14 |
+
@router.get("/")
|
| 15 |
+
async def cache_stats(svc: CacheService = Depends(get_cache_service)):
|
| 16 |
+
return svc.stats()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.delete("")
|
| 20 |
+
@router.delete("/")
|
| 21 |
+
async def cache_clear(svc: CacheService = Depends(get_cache_service)):
|
| 22 |
+
n = svc.clear()
|
| 23 |
+
return {"cleared": n}
|
download/face-intel/api/routes/export.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Export routes — download job results as JSON."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from fastapi.responses import PlainTextResponse
|
| 7 |
+
|
| 8 |
+
from api.deps import get_export_service
|
| 9 |
+
from services.export_service import ExportService
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/{job_id}")
|
| 15 |
+
async def export_job(job_id: str, svc: ExportService = Depends(get_export_service)):
|
| 16 |
+
data = svc.export_job_json_str(job_id)
|
| 17 |
+
if data is None:
|
| 18 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 19 |
+
return PlainTextResponse(content=data, media_type="application/json",
|
| 20 |
+
headers={"Content-Disposition": f"attachment; filename={job_id}.json"})
|
download/face-intel/api/routes/faces.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Face routes — detect / recognize endpoints (convenience wrappers)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from api.deps import get_detection_service, get_recognition_service
|
| 11 |
+
from models.jobs import JobKind, JobRequest
|
| 12 |
+
from services.detection_service import DetectionService
|
| 13 |
+
from services.recognition_service import RecognitionService
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FaceRequest(BaseModel):
|
| 19 |
+
image_url: Optional[str] = None
|
| 20 |
+
image_base64: Optional[str] = None
|
| 21 |
+
providers: List[str] = []
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.post("/detect")
|
| 25 |
+
async def detect_faces(req: FaceRequest, svc: DetectionService = Depends(get_detection_service)):
|
| 26 |
+
job_req = JobRequest(
|
| 27 |
+
kind=JobKind.DETECTION,
|
| 28 |
+
image_url=req.image_url,
|
| 29 |
+
image_base64=req.image_base64,
|
| 30 |
+
providers=req.providers,
|
| 31 |
+
)
|
| 32 |
+
return await svc.detect(job_req)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post("/recognize")
|
| 36 |
+
async def recognize_faces(req: FaceRequest, svc: RecognitionService = Depends(get_recognition_service)):
|
| 37 |
+
job_req = JobRequest(
|
| 38 |
+
kind=JobKind.RECOGNITION,
|
| 39 |
+
image_url=req.image_url,
|
| 40 |
+
image_base64=req.image_base64,
|
| 41 |
+
providers=req.providers,
|
| 42 |
+
)
|
| 43 |
+
return await svc.recognize(job_req)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.get("/gallery")
|
| 47 |
+
async def list_gallery(svc: RecognitionService = Depends(get_recognition_service)):
|
| 48 |
+
return {"persons": svc.list_known_persons()}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.delete("/gallery/{name}")
|
| 52 |
+
async def remove_from_gallery(name: str, svc: RecognitionService = Depends(get_recognition_service)):
|
| 53 |
+
return svc.remove_known_person(name)
|
download/face-intel/api/routes/health.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health routes."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends
|
| 6 |
+
|
| 7 |
+
from api.deps import get_health_service
|
| 8 |
+
from services.health_service import HealthService
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("")
|
| 14 |
+
@router.get("/")
|
| 15 |
+
async def health_root():
|
| 16 |
+
return {"status": "ok"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/providers")
|
| 20 |
+
async def health_providers(health: HealthService = Depends(get_health_service)):
|
| 21 |
+
return health.snapshot().model_dump()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/live")
|
| 25 |
+
async def liveness():
|
| 26 |
+
return {"status": "alive"}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.get("/ready")
|
| 30 |
+
async def readiness():
|
| 31 |
+
"""Minimal readiness check. Real checks would verify DB + cache."""
|
| 32 |
+
return {"status": "ready"}
|
download/face-intel/api/routes/jobs.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Job routes — create / inspect / list jobs."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 8 |
+
|
| 9 |
+
from api.deps import get_job_service
|
| 10 |
+
from models.jobs import JobKind, JobRequest
|
| 11 |
+
from services.job_service import JobService
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post("")
|
| 17 |
+
@router.post("/")
|
| 18 |
+
async def create_job(request: JobRequest, svc: JobService = Depends(get_job_service)):
|
| 19 |
+
return await svc.create_and_run(request)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("")
|
| 23 |
+
@router.get("/")
|
| 24 |
+
async def list_jobs(
|
| 25 |
+
limit: int = Query(50, ge=1, le=500),
|
| 26 |
+
status: Optional[str] = None,
|
| 27 |
+
svc: JobService = Depends(get_job_service),
|
| 28 |
+
):
|
| 29 |
+
return {"jobs": svc.list_jobs(limit=limit, status=status)}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/{job_id}")
|
| 33 |
+
async def get_job(job_id: str, svc: JobService = Depends(get_job_service)):
|
| 34 |
+
job = svc.get_job(job_id)
|
| 35 |
+
if not job:
|
| 36 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 37 |
+
return job
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/{job_id}/result")
|
| 41 |
+
async def get_job_result(job_id: str, svc: JobService = Depends(get_job_service)):
|
| 42 |
+
result = svc.get_result(job_id)
|
| 43 |
+
if not result:
|
| 44 |
+
raise HTTPException(status_code=404, detail="Result not found")
|
| 45 |
+
return result
|
download/face-intel/api/routes/providers.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provider routes — list/get provider info."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
|
| 7 |
+
from api.deps import get_provider_service
|
| 8 |
+
from services.provider_service import ProviderService
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("")
|
| 14 |
+
@router.get("/")
|
| 15 |
+
async def list_providers(svc: ProviderService = Depends(get_provider_service)):
|
| 16 |
+
return {"providers": svc.list_providers(), "errors": svc.manifest_errors()}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/{name}")
|
| 20 |
+
async def get_provider(name: str, svc: ProviderService = Depends(get_provider_service)):
|
| 21 |
+
info = svc.get_provider(name)
|
| 22 |
+
if not info:
|
| 23 |
+
raise HTTPException(status_code=404, detail=f"Provider '{name}' not found")
|
| 24 |
+
return info
|
download/face-intel/api/routes/search.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Search routes — scrape + reverse-image-search."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from api.deps import get_search_service
|
| 11 |
+
from models.jobs import JobKind, JobRequest
|
| 12 |
+
from services.search_service import SearchService
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SearchRequest(BaseModel):
|
| 18 |
+
image_url: Optional[str] = None
|
| 19 |
+
image_base64: Optional[str] = None
|
| 20 |
+
providers: List[str] = []
|
| 21 |
+
scrape_url: Optional[str] = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.post("/reverse")
|
| 25 |
+
async def reverse_search(req: SearchRequest, svc: SearchService = Depends(get_search_service)):
|
| 26 |
+
job_req = JobRequest(
|
| 27 |
+
kind=JobKind.SEARCH,
|
| 28 |
+
image_url=req.image_url,
|
| 29 |
+
image_base64=req.image_base64,
|
| 30 |
+
providers=req.providers,
|
| 31 |
+
options={"scrape_url": req.scrape_url} if req.scrape_url else {},
|
| 32 |
+
)
|
| 33 |
+
return await svc.search(job_req)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.post("/scrape")
|
| 37 |
+
async def scrape(req: SearchRequest, svc: SearchService = Depends(get_search_service)):
|
| 38 |
+
job_req = JobRequest(
|
| 39 |
+
kind=JobKind.SEARCH,
|
| 40 |
+
image_url=req.image_url,
|
| 41 |
+
image_base64=req.image_base64,
|
| 42 |
+
providers=req.providers,
|
| 43 |
+
options={"scrape_url": req.scrape_url} if req.scrape_url else {},
|
| 44 |
+
)
|
| 45 |
+
return await svc.search(job_req)
|
download/face-intel/api/routes/stats.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stats routes — exposes metrics."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends
|
| 6 |
+
|
| 7 |
+
from api.deps import get_metrics
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("")
|
| 13 |
+
@router.get("/")
|
| 14 |
+
async def stats_root(metrics=Depends(get_metrics)):
|
| 15 |
+
return metrics.snapshot()
|
download/face-intel/app.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Application entry point.
|
| 3 |
+
|
| 4 |
+
Run with:
|
| 5 |
+
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
|
| 6 |
+
|
| 7 |
+
or directly:
|
| 8 |
+
python app.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import uvicorn
|
| 14 |
+
|
| 15 |
+
from api.main import create_app
|
| 16 |
+
from config.settings import settings
|
| 17 |
+
|
| 18 |
+
app = create_app(settings)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
if __name__ == "__main__":
|
| 22 |
+
uvicorn.run(
|
| 23 |
+
"app:app",
|
| 24 |
+
host=settings.host,
|
| 25 |
+
port=settings.port,
|
| 26 |
+
reload=settings.debug,
|
| 27 |
+
log_level=settings.log_level.lower(),
|
| 28 |
+
)
|
download/face-intel/confidence/__init__.py
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Confidence package — explainable scoring + cross-provider conflict
|
| 3 |
+
detection.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from confidence.engine import ConfidenceEngine
|
| 7 |
+
from confidence.explainer import Explainer
|
| 8 |
+
from confidence.conflicts import ConflictDetector
|
| 9 |
+
|
| 10 |
+
__all__ = ["ConfidenceEngine", "Explainer", "ConflictDetector"]
|
download/face-intel/confidence/conflicts.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Conflict detector — finds cross-provider disagreements.
|
| 3 |
+
|
| 4 |
+
Currently detects:
|
| 5 |
+
- face_count_mismatch (detectors disagree on number of faces)
|
| 6 |
+
- match_disagreement (recognizers disagree on best match for same face)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Dict, List, Protocol
|
| 12 |
+
|
| 13 |
+
from models.reports import ConflictReport
|
| 14 |
+
from providers.base import ProviderResult
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Duck-typed protocols (confidence must not import from normalization)
|
| 18 |
+
class _BoxLike(Protocol):
|
| 19 |
+
detector: str
|
| 20 |
+
confidence: float
|
| 21 |
+
|
| 22 |
+
class _MatchLike(Protocol):
|
| 23 |
+
query_face_index: int
|
| 24 |
+
best_match: str | None
|
| 25 |
+
recognizer: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ConflictDetector:
|
| 29 |
+
"""Detects cross-provider disagreements."""
|
| 30 |
+
|
| 31 |
+
def detect(
|
| 32 |
+
self,
|
| 33 |
+
results: Dict[str, ProviderResult],
|
| 34 |
+
boxes: List[_BoxLike],
|
| 35 |
+
matches: List[_MatchLike],
|
| 36 |
+
) -> List[ConflictReport]:
|
| 37 |
+
conflicts: List[ConflictReport] = []
|
| 38 |
+
|
| 39 |
+
# Face count mismatch
|
| 40 |
+
detector_counts: Dict[str, int] = {}
|
| 41 |
+
for r in results.values():
|
| 42 |
+
if r.success and r.capability.value == "detection":
|
| 43 |
+
detector_counts[r.provider] = r.normalized.get("num_faces", 0)
|
| 44 |
+
if detector_counts:
|
| 45 |
+
counts = list(detector_counts.values())
|
| 46 |
+
if max(counts) != min(counts):
|
| 47 |
+
conflicts.append(ConflictReport(
|
| 48 |
+
kind="face_count_mismatch",
|
| 49 |
+
providers=list(detector_counts.keys()),
|
| 50 |
+
description=(
|
| 51 |
+
f"Detectors disagree on face count: {detector_counts}"
|
| 52 |
+
),
|
| 53 |
+
severity="warning",
|
| 54 |
+
))
|
| 55 |
+
|
| 56 |
+
# Match disagreement (same face index, different best match)
|
| 57 |
+
by_face: Dict[int, List[NormalizedMatch]] = {}
|
| 58 |
+
for m in matches:
|
| 59 |
+
by_face.setdefault(m.query_face_index, []).append(m)
|
| 60 |
+
for face_idx, face_matches in by_face.items():
|
| 61 |
+
if len(face_matches) < 2:
|
| 62 |
+
continue
|
| 63 |
+
best_matches = {m.best_match for m in face_matches if m.best_match}
|
| 64 |
+
if len(best_matches) > 1:
|
| 65 |
+
conflicts.append(ConflictReport(
|
| 66 |
+
kind="match_disagreement",
|
| 67 |
+
providers=[m.recognizer for m in face_matches],
|
| 68 |
+
description=(
|
| 69 |
+
f"Recognizers disagree on best match for face #{face_idx}: "
|
| 70 |
+
f"{[(m.recognizer, m.best_match) for m in face_matches]}"
|
| 71 |
+
),
|
| 72 |
+
severity="warning",
|
| 73 |
+
))
|
| 74 |
+
|
| 75 |
+
return conflicts
|
download/face-intel/confidence/engine.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Confidence engine — decomposes every score into weighted sub-scores
|
| 3 |
+
with a human-readable explanation.
|
| 4 |
+
|
| 5 |
+
Sub-scores:
|
| 6 |
+
- source_reliability (provider's track record)
|
| 7 |
+
- cross_provider_consensus (how many providers agree)
|
| 8 |
+
- detection_confidence (raw provider confidence)
|
| 9 |
+
- latency_penalty (slower = lower confidence)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Any, List, Protocol
|
| 15 |
+
|
| 16 |
+
from models.reports import (
|
| 17 |
+
ConfidenceScore,
|
| 18 |
+
ConflictReport,
|
| 19 |
+
FaceDetection,
|
| 20 |
+
FaceMatch,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# NOTE: confidence/ must NOT import from normalization/ (dependency direction
|
| 24 |
+
# is normalization -> confidence). We accept duck-typed objects that expose
|
| 25 |
+
# the attributes we need (.detector, .confidence, .query_face_index,
|
| 26 |
+
# .best_match, .distance, .recognizer) instead of importing the DTOs.
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class _BoxLike(Protocol):
|
| 30 |
+
detector: str
|
| 31 |
+
confidence: float
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class _MatchLike(Protocol):
|
| 35 |
+
query_face_index: int
|
| 36 |
+
best_match: str | None
|
| 37 |
+
distance: float
|
| 38 |
+
recognizer: str
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
WEIGHTS = {
|
| 42 |
+
"source_reliability": 0.25,
|
| 43 |
+
"cross_provider_consensus": 0.30,
|
| 44 |
+
"detection_confidence": 0.35,
|
| 45 |
+
"latency_penalty": 0.10,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
# Default reliability per provider (can be overridden by metrics history)
|
| 49 |
+
DEFAULT_RELIABILITY = {
|
| 50 |
+
"haar": 0.65,
|
| 51 |
+
"dnn": 0.85,
|
| 52 |
+
"mtcnn": 0.92,
|
| 53 |
+
"retinaface": 0.95,
|
| 54 |
+
"face_recognition": 0.90,
|
| 55 |
+
"deepface": 0.88,
|
| 56 |
+
"insightface": 0.93,
|
| 57 |
+
"beautifulsoup": 0.75,
|
| 58 |
+
"selenium": 0.70,
|
| 59 |
+
"bing": 0.85,
|
| 60 |
+
"duckduckgo": 0.65,
|
| 61 |
+
"google_lens": 0.55, # brittle
|
| 62 |
+
"serpapi": 0.90,
|
| 63 |
+
"yandex": 0.55,
|
| 64 |
+
"tineye": 0.88,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class ConfidenceEngine:
|
| 69 |
+
"""Computes weighted confidence scores."""
|
| 70 |
+
|
| 71 |
+
def __init__(self, reliability_overrides: dict | None = None) -> None:
|
| 72 |
+
self._reliability = {**DEFAULT_RELIABILITY, **(reliability_overrides or {})}
|
| 73 |
+
|
| 74 |
+
# ------------------------------------------------------------------ #
|
| 75 |
+
# Detection confidence
|
| 76 |
+
# ------------------------------------------------------------------ #
|
| 77 |
+
def score_detection(
|
| 78 |
+
self,
|
| 79 |
+
nbox: _BoxLike,
|
| 80 |
+
all_results: dict[str, Any],
|
| 81 |
+
) -> ConfidenceScore:
|
| 82 |
+
provider = nbox.detector
|
| 83 |
+
source_rel = self._reliability.get(provider, 0.7)
|
| 84 |
+
|
| 85 |
+
# Cross-provider consensus: how many detectors found a similar box?
|
| 86 |
+
num_detectors = sum(
|
| 87 |
+
1 for r in all_results.values()
|
| 88 |
+
if r.success and r.capability.value == "detection"
|
| 89 |
+
)
|
| 90 |
+
consensus = min(1.0, num_detectors / 3.0) # 3 detectors = full consensus
|
| 91 |
+
|
| 92 |
+
det_conf = nbox.confidence
|
| 93 |
+
|
| 94 |
+
# Latency penalty: <100ms = 1.0, >2000ms = 0.5
|
| 95 |
+
provider_result = all_results.get(provider)
|
| 96 |
+
latency = provider_result.elapsed_ms if provider_result else 500.0
|
| 97 |
+
latency_score = max(0.5, 1.0 - (latency / 4000.0))
|
| 98 |
+
|
| 99 |
+
components = {
|
| 100 |
+
"source_reliability": round(source_rel, 4),
|
| 101 |
+
"cross_provider_consensus": round(consensus, 4),
|
| 102 |
+
"detection_confidence": round(det_conf, 4),
|
| 103 |
+
"latency_penalty": round(latency_score, 4),
|
| 104 |
+
}
|
| 105 |
+
overall = sum(
|
| 106 |
+
components[k] * WEIGHTS[k] for k in WEIGHTS
|
| 107 |
+
)
|
| 108 |
+
explanation = (
|
| 109 |
+
f"{provider} detected a face with raw confidence {det_conf:.2f}. "
|
| 110 |
+
f"{num_detectors} detector(s) ran in this job "
|
| 111 |
+
f"(consensus={consensus:.2f}). "
|
| 112 |
+
f"Provider reliability={source_rel:.2f}, "
|
| 113 |
+
f"latency={latency:.0f}ms (score={latency_score:.2f})."
|
| 114 |
+
)
|
| 115 |
+
return ConfidenceScore(
|
| 116 |
+
overall=round(overall, 4),
|
| 117 |
+
components=components,
|
| 118 |
+
explanation=explanation,
|
| 119 |
+
method="weighted_average",
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# ------------------------------------------------------------------ #
|
| 123 |
+
# Recognition match confidence
|
| 124 |
+
# ------------------------------------------------------------------ #
|
| 125 |
+
def score_match(self, nm: _MatchLike) -> ConfidenceScore:
|
| 126 |
+
provider = nm.recognizer
|
| 127 |
+
source_rel = self._reliability.get(provider, 0.7)
|
| 128 |
+
# Distance → confidence: distance 0 = 1.0, distance 1.0 = 0.0
|
| 129 |
+
match_conf = max(0.0, 1.0 - nm.distance)
|
| 130 |
+
components = {
|
| 131 |
+
"source_reliability": round(source_rel, 4),
|
| 132 |
+
"cross_provider_consensus": 0.5, # placeholder; enriched when multiple recognizers ran
|
| 133 |
+
"detection_confidence": round(match_conf, 4),
|
| 134 |
+
"latency_penalty": 0.9,
|
| 135 |
+
}
|
| 136 |
+
overall = sum(components[k] * WEIGHTS[k] for k in WEIGHTS)
|
| 137 |
+
explanation = (
|
| 138 |
+
f"{provider} matched face #{nm.query_face_index} "
|
| 139 |
+
f"to '{nm.best_match}' with distance {nm.distance:.3f} "
|
| 140 |
+
f"(match confidence={match_conf:.2f})."
|
| 141 |
+
)
|
| 142 |
+
return ConfidenceScore(
|
| 143 |
+
overall=round(overall, 4),
|
| 144 |
+
components=components,
|
| 145 |
+
explanation=explanation,
|
| 146 |
+
method="weighted_average",
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# ------------------------------------------------------------------ #
|
| 150 |
+
# Overall report confidence
|
| 151 |
+
# ------------------------------------------------------------------ #
|
| 152 |
+
def score_overall(
|
| 153 |
+
self,
|
| 154 |
+
report_detections: List[FaceDetection],
|
| 155 |
+
matches: List[FaceMatch],
|
| 156 |
+
conflicts: List[ConflictReport],
|
| 157 |
+
) -> ConfidenceScore:
|
| 158 |
+
if not report_detections:
|
| 159 |
+
return ConfidenceScore(
|
| 160 |
+
overall=0.0,
|
| 161 |
+
components={},
|
| 162 |
+
explanation="No faces detected.",
|
| 163 |
+
)
|
| 164 |
+
detection_avg = sum(d.confidence.overall for d in report_detections) / len(report_detections)
|
| 165 |
+
match_avg = (
|
| 166 |
+
sum(m.confidence.overall for m in matches) / len(matches)
|
| 167 |
+
if matches else 0.0
|
| 168 |
+
)
|
| 169 |
+
conflict_penalty = max(0.0, 1.0 - (0.15 * len(conflicts)))
|
| 170 |
+
overall = detection_avg * 0.5 + match_avg * 0.3 + conflict_penalty * 0.2
|
| 171 |
+
return ConfidenceScore(
|
| 172 |
+
overall=round(overall, 4),
|
| 173 |
+
components={
|
| 174 |
+
"detection_avg": round(detection_avg, 4),
|
| 175 |
+
"match_avg": round(match_avg, 4),
|
| 176 |
+
"conflict_penalty": round(conflict_penalty, 4),
|
| 177 |
+
},
|
| 178 |
+
explanation=(
|
| 179 |
+
f"Report confidence from {len(report_detections)} detection(s) "
|
| 180 |
+
f"and {len(matches)} match(es) with {len(conflicts)} conflict(s)."
|
| 181 |
+
),
|
| 182 |
+
method="weighted_average",
|
| 183 |
+
)
|
download/face-intel/confidence/explainer.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Explainer — turns a ConfidenceScore into a multi-line human-readable
|
| 3 |
+
string for display in the UI.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from models.reports import ConfidenceScore, UnifiedFaceReport
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Explainer:
|
| 12 |
+
"""Renders confidence + report as human-readable text."""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def explain_score(score: ConfidenceScore) -> str:
|
| 16 |
+
lines = [f"Overall confidence: {score.overall:.2%} ({score.method})"]
|
| 17 |
+
if score.explanation:
|
| 18 |
+
lines.append(f" {score.explanation}")
|
| 19 |
+
if score.components:
|
| 20 |
+
lines.append(" Components:")
|
| 21 |
+
for k, v in score.components.items():
|
| 22 |
+
lines.append(f" - {k}: {v:.3f}")
|
| 23 |
+
return "\n".join(lines)
|
| 24 |
+
|
| 25 |
+
@staticmethod
|
| 26 |
+
def explain_report(report: UnifiedFaceReport) -> str:
|
| 27 |
+
m = report.metadata
|
| 28 |
+
lines = [
|
| 29 |
+
f"Job {m.job_id} — {m.total_elapsed_ms:.0f}ms total",
|
| 30 |
+
f" Providers invoked: {', '.join(m.providers_invoked) or 'none'}",
|
| 31 |
+
f" Succeeded: {', '.join(m.providers_succeeded) or 'none'}",
|
| 32 |
+
f" Failed: {', '.join(m.providers_failed) or 'none'}",
|
| 33 |
+
f" Detections: {len(report.detections)}",
|
| 34 |
+
f" Matches: {len(report.matches)}",
|
| 35 |
+
f" Scraped images: {len(report.scraped_images)}",
|
| 36 |
+
f" Reverse matches: {len(report.reverse_matches)}",
|
| 37 |
+
f" Conflicts: {len(report.conflicts)}",
|
| 38 |
+
]
|
| 39 |
+
if report.overall_confidence:
|
| 40 |
+
lines.append("")
|
| 41 |
+
lines.append(Explainer.explain_score(report.overall_confidence))
|
| 42 |
+
return "\n".join(lines)
|
download/face-intel/config/__init__.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
from config.settings import (
|
|
|
|
| 4 |
settings,
|
|
|
|
| 5 |
BASE_DIR,
|
| 6 |
DATA_DIR,
|
| 7 |
MODELS_DIR,
|
|
@@ -12,7 +14,9 @@ from config.settings import (
|
|
| 12 |
)
|
| 13 |
|
| 14 |
__all__ = [
|
|
|
|
| 15 |
"settings",
|
|
|
|
| 16 |
"BASE_DIR",
|
| 17 |
"DATA_DIR",
|
| 18 |
"MODELS_DIR",
|
|
|
|
| 1 |
+
"""Config package — re-exports settings + path constants."""
|
| 2 |
|
| 3 |
from config.settings import (
|
| 4 |
+
Settings,
|
| 5 |
settings,
|
| 6 |
+
make_settings,
|
| 7 |
BASE_DIR,
|
| 8 |
DATA_DIR,
|
| 9 |
MODELS_DIR,
|
|
|
|
| 14 |
)
|
| 15 |
|
| 16 |
__all__ = [
|
| 17 |
+
"Settings",
|
| 18 |
"settings",
|
| 19 |
+
"make_settings",
|
| 20 |
"BASE_DIR",
|
| 21 |
"DATA_DIR",
|
| 22 |
"MODELS_DIR",
|
download/face-intel/config/settings.py
CHANGED
|
@@ -2,8 +2,13 @@
|
|
| 2 |
Centralized configuration for Face Intel.
|
| 3 |
|
| 4 |
All settings are environment-driven via pydantic-settings so the same
|
| 5 |
-
code runs in dev, test, and production without code changes.
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
|
@@ -43,7 +48,7 @@ class Settings(BaseSettings):
|
|
| 43 |
# ------------------------------------------------------------------ #
|
| 44 |
app_name: str = "Face Intel"
|
| 45 |
app_version: str = "1.0.0"
|
| 46 |
-
environment: str = "development"
|
| 47 |
host: str = "0.0.0.0"
|
| 48 |
port: int = 8000
|
| 49 |
debug: bool = False
|
|
@@ -54,7 +59,7 @@ class Settings(BaseSettings):
|
|
| 54 |
enable_haar: bool = True
|
| 55 |
enable_dnn: bool = True
|
| 56 |
enable_mtcnn: bool = True
|
| 57 |
-
enable_retinaface: bool = False
|
| 58 |
enable_face_recognition: bool = True
|
| 59 |
enable_deepface: bool = False
|
| 60 |
enable_insightface: bool = False
|
|
@@ -79,10 +84,10 @@ class Settings(BaseSettings):
|
|
| 79 |
# Recognition tuning
|
| 80 |
# ------------------------------------------------------------------ #
|
| 81 |
face_recognition_tolerance: float = 0.6
|
| 82 |
-
face_recognition_model: str = "hog"
|
| 83 |
deepface_backend: str = "arcface"
|
| 84 |
insightface_model_pack: str = "buffalo_l"
|
| 85 |
-
recognition_match_threshold: float = 0.5
|
| 86 |
|
| 87 |
# ------------------------------------------------------------------ #
|
| 88 |
# Scraping
|
|
@@ -114,9 +119,6 @@ class Settings(BaseSettings):
|
|
| 114 |
retry_max_attempts: int = 3
|
| 115 |
retry_initial_backoff_seconds: float = 0.5
|
| 116 |
retry_max_backoff_seconds: float = 8.0
|
| 117 |
-
retry_retriable_exceptions: List[str] = Field(
|
| 118 |
-
default_factory=lambda: ["TimeoutError", "ConnectionError"]
|
| 119 |
-
)
|
| 120 |
|
| 121 |
# ------------------------------------------------------------------ #
|
| 122 |
# Cache
|
|
@@ -143,9 +145,7 @@ class Settings(BaseSettings):
|
|
| 143 |
# API
|
| 144 |
# ------------------------------------------------------------------ #
|
| 145 |
rate_limit_per_minute: int = 30
|
| 146 |
-
cors_origins: List[str] = Field(
|
| 147 |
-
default_factory=lambda: ["*"]
|
| 148 |
-
)
|
| 149 |
require_consent_header: bool = True
|
| 150 |
consent_header_name: str = "X-Consent-Statement"
|
| 151 |
|
|
@@ -156,4 +156,21 @@ class Settings(BaseSettings):
|
|
| 156 |
log_json: bool = False
|
| 157 |
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
settings = Settings()
|
|
|
|
| 2 |
Centralized configuration for Face Intel.
|
| 3 |
|
| 4 |
All settings are environment-driven via pydantic-settings so the same
|
| 5 |
+
code runs in dev, test, and production without code changes.
|
| 6 |
+
|
| 7 |
+
After the refactor, `settings` is still importable for backward
|
| 8 |
+
compatibility with provider modules that read tuning knobs, BUT every
|
| 9 |
+
stateful service (storage, cache, orchestrator, services) receives its
|
| 10 |
+
dependencies through constructor injection — never by importing this
|
| 11 |
+
module directly.
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
|
|
|
| 48 |
# ------------------------------------------------------------------ #
|
| 49 |
app_name: str = "Face Intel"
|
| 50 |
app_version: str = "1.0.0"
|
| 51 |
+
environment: str = "development"
|
| 52 |
host: str = "0.0.0.0"
|
| 53 |
port: int = 8000
|
| 54 |
debug: bool = False
|
|
|
|
| 59 |
enable_haar: bool = True
|
| 60 |
enable_dnn: bool = True
|
| 61 |
enable_mtcnn: bool = True
|
| 62 |
+
enable_retinaface: bool = False
|
| 63 |
enable_face_recognition: bool = True
|
| 64 |
enable_deepface: bool = False
|
| 65 |
enable_insightface: bool = False
|
|
|
|
| 84 |
# Recognition tuning
|
| 85 |
# ------------------------------------------------------------------ #
|
| 86 |
face_recognition_tolerance: float = 0.6
|
| 87 |
+
face_recognition_model: str = "hog"
|
| 88 |
deepface_backend: str = "arcface"
|
| 89 |
insightface_model_pack: str = "buffalo_l"
|
| 90 |
+
recognition_match_threshold: float = 0.5
|
| 91 |
|
| 92 |
# ------------------------------------------------------------------ #
|
| 93 |
# Scraping
|
|
|
|
| 119 |
retry_max_attempts: int = 3
|
| 120 |
retry_initial_backoff_seconds: float = 0.5
|
| 121 |
retry_max_backoff_seconds: float = 8.0
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
# ------------------------------------------------------------------ #
|
| 124 |
# Cache
|
|
|
|
| 145 |
# API
|
| 146 |
# ------------------------------------------------------------------ #
|
| 147 |
rate_limit_per_minute: int = 30
|
| 148 |
+
cors_origins: List[str] = Field(default_factory=lambda: ["*"])
|
|
|
|
|
|
|
| 149 |
require_consent_header: bool = True
|
| 150 |
consent_header_name: str = "X-Consent-Statement"
|
| 151 |
|
|
|
|
| 156 |
log_json: bool = False
|
| 157 |
|
| 158 |
|
| 159 |
+
def make_settings(**overrides) -> Settings:
|
| 160 |
+
"""Factory used by the DI container to build a Settings instance.
|
| 161 |
+
|
| 162 |
+
Tests can override individual fields without touching env vars:
|
| 163 |
+
make_settings(environment="test", cache_enabled=False)
|
| 164 |
+
"""
|
| 165 |
+
if overrides:
|
| 166 |
+
# Build a fresh instance with overrides merged
|
| 167 |
+
base = Settings()
|
| 168 |
+
data = base.model_dump()
|
| 169 |
+
data.update(overrides)
|
| 170 |
+
return Settings(**data)
|
| 171 |
+
return Settings()
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# Default singleton — fine for read-only config consumption by providers.
|
| 175 |
+
# Stateful services MUST be injected, not import this directly.
|
| 176 |
settings = Settings()
|
download/face-intel/docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Face Intel — Architecture
|
| 2 |
+
|
| 3 |
+
> Refactored architecture (v2). Introduces a services layer, a pipeline
|
| 4 |
+
> package, a provider registry, a metrics subsystem, separated storage,
|
| 5 |
+
> shared domain models, strict one-way dependency direction, comprehensive
|
| 6 |
+
> dependency injection, and structured execution-context logging.
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## 1. Dependency Graph (strict one-way)
|
| 11 |
+
|
| 12 |
+
```
|
| 13 |
+
┌──────────┐
|
| 14 |
+
│ API │ FastAPI routes + middleware
|
| 15 |
+
└────┬─────┘
|
| 16 |
+
│
|
| 17 |
+
┌────▼─────┐
|
| 18 |
+
│ Services │ business logic (8 services)
|
| 19 |
+
└────┬─────┘
|
| 20 |
+
│
|
| 21 |
+
┌──────────────┼──────────────┐
|
| 22 |
+
│ │ │
|
| 23 |
+
┌────▼─────┐ ┌────▼─────┐ ┌─────▼──────┐
|
| 24 |
+
│ Orchest. │ │ Confid. │ │ Normaliz. │
|
| 25 |
+
└────┬─────┘ └────┬─────┘ └─────┬──────┘
|
| 26 |
+
│ │ │
|
| 27 |
+
└─────────────┼──────────────┘
|
| 28 |
+
│
|
| 29 |
+
┌────▼─────┐
|
| 30 |
+
│ Pipeline │ validation → preprocess → hash → feature_extract
|
| 31 |
+
└────┬─────┘
|
| 32 |
+
│
|
| 33 |
+
┌────▼─────┐
|
| 34 |
+
│ Providers│ detection / recognition / scraper / reverse
|
| 35 |
+
└────┬─────┘
|
| 36 |
+
│
|
| 37 |
+
┌────▼─────┐
|
| 38 |
+
│ Storage │ database / cache / artifacts / reference_store
|
| 39 |
+
└────┬─────┘
|
| 40 |
+
│
|
| 41 |
+
┌────▼─────┐
|
| 42 |
+
│ Utils │ image / http / audit / timing / logging
|
| 43 |
+
└────┬─────┘
|
| 44 |
+
│
|
| 45 |
+
┌────▼─────┐
|
| 46 |
+
│ Models │ shared domain DTOs (pure pydantic, no deps)
|
| 47 |
+
└──────────┘
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
**Rule:** arrows point downward. A layer may import only from layers
|
| 51 |
+
below it. `models/` is at the bottom and depends on nothing but pydantic
|
| 52 |
+
+ stdlib. No circular imports — verified by `scripts/check_imports.py`.
|
| 53 |
+
|
| 54 |
+
**Cross-cutting:** `config/` (settings) is read by every layer but is a
|
| 55 |
+
value object, not stateful. `metrics/` is consumed by orchestrator +
|
| 56 |
+
services + API but owns no upstream dependencies.
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## 2. Package Responsibilities
|
| 61 |
+
|
| 62 |
+
| Package | Responsibility | Key Exports |
|
| 63 |
+
|---|---|---|
|
| 64 |
+
| `config/` | Environment-driven settings (Pydantic BaseSettings) | `Settings`, `make_settings()` |
|
| 65 |
+
| `models/` | Shared domain DTOs (pure pydantic, no logic) | `Job`, `JobRequest`, `UnifiedFaceReport`, `ProviderInfo`, `HealthSnapshot`, `APIResponse` |
|
| 66 |
+
| `utils/` | Stateless helpers: image I/O, HTTP session, audit log, timing, structured logging | `bytes_to_numpy`, `shared_session`, `audit_log`, `execution_context`, `setup_logging` |
|
| 67 |
+
| `storage/` | Persistence: SQLite (jobs), TTL cache, file artifacts, reference gallery | `Database`, `Cache`, `ArtifactStore`, `ReferenceStore` |
|
| 68 |
+
| `metrics/` | Per-provider latency/success/retry counters, timings, health, cache hit ratio | `MetricsCollector` (facade) |
|
| 69 |
+
| `providers/` | Provider Protocol + BaseProvider + Registry + 4 capability folders | `Provider`, `BaseProvider`, `ProviderRegistry`, `PROVIDER_MANIFEST` |
|
| 70 |
+
| `pipeline/` | Local pre-orchestrator stages: validate → preprocess → hash → feature-extract → postprocess | `InputValidator`, `ImagePreprocessor`, `ImageHasher`, `FeatureExtractor`, `PipelineOutput` |
|
| 71 |
+
| `orchestrator/` | Async fan-out, retry, circuit breaker | `Orchestrator`, `RetryPolicy`, `HealthMonitor` |
|
| 72 |
+
| `normalization/` | Merge multi-provider results into unified report; internal DTOs only | `ReportMerger`, `NormalizedBox`, `NormalizedMatch` |
|
| 73 |
+
| `confidence/` | Explainable scoring + cross-provider conflict detection | `ConfidenceEngine`, `ConflictDetector`, `Explainer` |
|
| 74 |
+
| `services/` | Business logic; one service per concern | `DetectionService`, `RecognitionService`, `SearchService`, `JobService`, `ProviderService`, `CacheService`, `HealthService`, `ExportService` |
|
| 75 |
+
| `api/` | FastAPI app, DI container, middleware, route handlers | `create_app()`, `ServiceContainer`, `build_container()` |
|
| 76 |
+
| `ui/` | Static SPA (served by FastAPI) | — |
|
| 77 |
+
| `tests/` | Unit + provider + integration test suites | — |
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## 3. Execution Flow
|
| 82 |
+
|
| 83 |
+
```
|
| 84 |
+
HTTP request
|
| 85 |
+
│
|
| 86 |
+
▼
|
| 87 |
+
FastAPI middleware: request-id → rate-limit → CORS
|
| 88 |
+
│
|
| 89 |
+
▼
|
| 90 |
+
Route handler (api/routes/<resource>.py)
|
| 91 |
+
│ depends( get_<service> ) → pulls service off app.state.container
|
| 92 |
+
▼
|
| 93 |
+
Service (services/<name>_service.py)
|
| 94 |
+
│ 1. InputValidator.validate(image_url|base64|bytes)
|
| 95 |
+
│ 2. ImagePreprocessor.from_url|from_bytes → PreprocessedImage
|
| 96 |
+
│ 3. ImageHasher.hash → cache key
|
| 97 |
+
│ 4. FeatureExtractor.extract → PipelineOutput
|
| 98 |
+
│ (image + hash + face_crops + optional gallery/scrape_url)
|
| 99 |
+
│ 5. Orchestrator.run(pipeline_output, capabilities)
|
| 100 |
+
│ 6. ReportMerger.merge(results) → UnifiedFaceReport
|
| 101 |
+
│ 7. return {report, elapsed_ms}
|
| 102 |
+
▼
|
| 103 |
+
Orchestrator (orchestrator/runner.py)
|
| 104 |
+
│ for each provider matching capabilities:
|
| 105 |
+
│ - skip if circuit breaker open
|
| 106 |
+
│ - check cache; hit → return cached ProviderResult
|
| 107 |
+
│ - else: asyncio.to_thread(provider.execute(pipeline_output))
|
| 108 |
+
│ - apply RetryPolicy (exponential backoff + jitter)
|
| 109 |
+
│ - record metrics (latency, success/failure, retries)
|
| 110 |
+
│ - cache successful results
|
| 111 |
+
▼
|
| 112 |
+
Provider (providers/<category>/<name>.py)
|
| 113 |
+
│ receives PipelineOutput
|
| 114 |
+
│ extracts .image / .face_crops / .gallery / .scrape_url
|
| 115 |
+
│ runs its model / HTTP call
|
| 116 |
+
│ returns ProviderResult(raw=verbatim, normalized={...}, elapsed_ms, success)
|
| 117 |
+
▼
|
| 118 |
+
Normalization (normalization/merger.py)
|
| 119 |
+
│ collects NormalizedBox / NormalizedMatch / NormalizedScrapeImage / NormalizedReverseMatch
|
| 120 |
+
│ preserves every ProviderResult as Evidence
|
| 121 |
+
│ delegates scoring to ConfidenceEngine
|
| 122 |
+
│ delegates conflicts to ConflictDetector
|
| 123 |
+
│ returns UnifiedFaceReport
|
| 124 |
+
▼
|
| 125 |
+
Response → JSON to client
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## 4. Lifecycle Diagrams
|
| 131 |
+
|
| 132 |
+
### 4.1 Application Startup
|
| 133 |
+
|
| 134 |
+
```
|
| 135 |
+
create_app(settings)
|
| 136 |
+
│
|
| 137 |
+
├─ setup_logging(settings) # loguru sinks + execution-context format
|
| 138 |
+
│
|
| 139 |
+
├─ lifespan:
|
| 140 |
+
│ build_container(settings) # composition root (DI)
|
| 141 |
+
│ │
|
| 142 |
+
│ ├─ ProviderRegistry(settings)
|
| 143 |
+
│ │ .discover() # imports each provider module, instantiates, registers
|
| 144 |
+
│ │ # missing optional deps → NOT_CONFIGURED (graceful)
|
| 145 |
+
│ │
|
| 146 |
+
│ ├─ Cache(ttl, max_entries)
|
| 147 |
+
│ ├─ Database(path) # SQLite, schema migrated
|
| 148 |
+
│ ├─ ArtifactStore(root=uploads/)
|
| 149 |
+
│ ├─ ReferenceStore(root=gallery/)
|
| 150 |
+
│ │
|
| 151 |
+
│ ├─ MetricsCollector(failure_threshold, recovery_seconds)
|
| 152 |
+
│ ├─ HealthMonitor(metrics.health)
|
| 153 |
+
│ │
|
| 154 |
+
│ ├─ Orchestrator(registry, cache, metrics, health, settings, retry_policy)
|
| 155 |
+
│ │
|
| 156 |
+
│ ├─ ConfidenceEngine()
|
| 157 |
+
│ ├─ ConflictDetector()
|
| 158 |
+
│ │
|
| 159 |
+
│ ├─ InputValidator()
|
| 160 |
+
│ ├─ ImagePreprocessor(max_dim=1024)
|
| 161 |
+
│ ├─ ImageHasher()
|
| 162 |
+
│ ├─ FeatureExtractor(detector=first_detection_provider)
|
| 163 |
+
│ │
|
| 164 |
+
│ ├─ DetectionService(...)
|
| 165 |
+
│ ├─ RecognitionService(...)
|
| 166 |
+
│ ├─ SearchService(...)
|
| 167 |
+
│ ├─ JobService(detection, recognition, search, database, metrics)
|
| 168 |
+
│ ├─ ProviderService(registry)
|
| 169 |
+
│ ├─ CacheService(cache)
|
| 170 |
+
│ ├─ HealthService(registry, health, metrics)
|
| 171 |
+
│ └─ ExportService(database)
|
| 172 |
+
│
|
| 173 |
+
├─ app.state.container = container
|
| 174 |
+
│
|
| 175 |
+
└─ mount routes + UI
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
### 4.2 Request Lifecycle
|
| 179 |
+
|
| 180 |
+
```
|
| 181 |
+
1. HTTP request hits FastAPI
|
| 182 |
+
2. RequestContextMiddleware → assigns X-Request-ID, starts timer
|
| 183 |
+
3. RateLimitMiddleware → enforces per-IP limit (sliding 60s window)
|
| 184 |
+
4. Route handler → Depends(get_<service>) pulls from container
|
| 185 |
+
5. Service → runs pipeline → orchestrator → normalization → confidence
|
| 186 |
+
6. Orchestrator → per-provider: cache check → retry-wrapped invoke → metrics
|
| 187 |
+
7. Provider → executes within execution_context(provider_id=...)
|
| 188 |
+
8. Result flows back up: ProviderResult → UnifiedFaceReport → JSON response
|
| 189 |
+
9. Response headers → X-Request-ID, X-Response-Time-ms
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
### 4.3 Circuit Breaker Lifecycle
|
| 193 |
+
|
| 194 |
+
```
|
| 195 |
+
provider.invoke() succeeds
|
| 196 |
+
│
|
| 197 |
+
▼
|
| 198 |
+
HealthMetrics.record_success(name)
|
| 199 |
+
│ → consecutive_failures = 0
|
| 200 |
+
│ → avg_latency_ms = exp-avg
|
| 201 |
+
│ → if circuit_open: close it
|
| 202 |
+
▼
|
| 203 |
+
circuit stays CLOSED
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
provider.invoke() fails
|
| 207 |
+
│
|
| 208 |
+
▼
|
| 209 |
+
HealthMetrics.record_failure(name)
|
| 210 |
+
│ → consecutive_failures += 1
|
| 211 |
+
│ → if consecutive_failures >= threshold AND not circuit_open:
|
| 212 |
+
│ circuit_open = True
|
| 213 |
+
│ circuit_opened_at = now()
|
| 214 |
+
▼
|
| 215 |
+
circuit OPEN → orchestrator skips this provider
|
| 216 |
+
until recovery_seconds elapsed
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
next invoke attempt after recovery_seconds
|
| 220 |
+
│
|
| 221 |
+
▼
|
| 222 |
+
HealthMetrics.is_circuit_open(name)
|
| 223 |
+
│ → if circuit_open AND now - opened_at > recovery_seconds:
|
| 224 |
+
│ circuit_open = False (half-open)
|
| 225 |
+
│ return False (allow attempt)
|
| 226 |
+
▼
|
| 227 |
+
provider gets one trial invoke
|
| 228 |
+
success → circuit stays closed
|
| 229 |
+
failure → circuit re-opens
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
---
|
| 233 |
+
|
| 234 |
+
## 5. Extension Guide — Adding a New Provider
|
| 235 |
+
|
| 236 |
+
Adding a provider requires **two changes** and **zero orchestrator/API edits**.
|
| 237 |
+
|
| 238 |
+
### Step 1 — Implement the provider
|
| 239 |
+
|
| 240 |
+
Create `providers/<category>/<your_provider>.py`:
|
| 241 |
+
|
| 242 |
+
```python
|
| 243 |
+
from __future__ import annotations
|
| 244 |
+
from config.settings import Settings, settings as _default
|
| 245 |
+
from pipeline.feature_extraction import PipelineOutput
|
| 246 |
+
from providers.base import BaseProvider, ProviderCapability, ProviderResult
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
class YourProvider(BaseProvider):
|
| 250 |
+
name = "your_provider"
|
| 251 |
+
capability = ProviderCapability.DETECTION # or RECOGNITION / SCRAPING / REVERSE_SEARCH
|
| 252 |
+
|
| 253 |
+
def __init__(self, settings: Settings | None = None) -> None:
|
| 254 |
+
super().__init__(settings=settings or _default)
|
| 255 |
+
# ... initialize your model / client
|
| 256 |
+
|
| 257 |
+
def is_available(self) -> bool:
|
| 258 |
+
# Return False if optional deps or API keys are missing.
|
| 259 |
+
return True
|
| 260 |
+
|
| 261 |
+
def _run(self, pipeline_output: PipelineOutput) -> tuple[dict, dict]:
|
| 262 |
+
img = pipeline_output.image # extract what you need
|
| 263 |
+
# ... do your work
|
| 264 |
+
raw = {"...": ...} # verbatim response (preserved as evidence)
|
| 265 |
+
normalized = {
|
| 266 |
+
"boxes": [{"x": 0, "y": 0, "w": 0, "h": 0}],
|
| 267 |
+
"num_faces": 1,
|
| 268 |
+
"confidences": [0.95],
|
| 269 |
+
"landmarks": None,
|
| 270 |
+
}
|
| 271 |
+
return raw, normalized
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
### Step 2 — Add one manifest entry
|
| 275 |
+
|
| 276 |
+
Edit `providers/registry.py`:
|
| 277 |
+
|
| 278 |
+
```python
|
| 279 |
+
PROVIDER_MANIFEST: list[ManifestEntry] = [
|
| 280 |
+
# ... existing entries ...
|
| 281 |
+
ManifestEntry(
|
| 282 |
+
name="your_provider",
|
| 283 |
+
module_path="providers.detection.your_provider",
|
| 284 |
+
class_name="YourProvider",
|
| 285 |
+
capability=ProviderCapability.DETECTION,
|
| 286 |
+
enable_flag="enable_your_provider",
|
| 287 |
+
description="Your provider — one-line description",
|
| 288 |
+
optional_dependency=True, # set True if it imports an optional package
|
| 289 |
+
),
|
| 290 |
+
]
|
| 291 |
+
```
|
| 292 |
+
|
| 293 |
+
### Step 3 — Add the enable flag
|
| 294 |
+
|
| 295 |
+
Edit `config/settings.py`:
|
| 296 |
+
|
| 297 |
+
```python
|
| 298 |
+
enable_your_provider: bool = False
|
| 299 |
+
# ... any tuning knobs your provider needs, e.g.:
|
| 300 |
+
your_provider_threshold: float = 0.7
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
### Step 4 — Done
|
| 304 |
+
|
| 305 |
+
- The registry auto-discovers your provider on startup.
|
| 306 |
+
- The orchestrator invokes it for matching capability queries.
|
| 307 |
+
- `/providers` lists it automatically.
|
| 308 |
+
- `/stats` tracks its latency, success rate, retries.
|
| 309 |
+
- The circuit breaker protects against its failures.
|
| 310 |
+
- The cache dedupes identical invocations.
|
| 311 |
+
|
| 312 |
+
**No other file needs to change.** This is the core extensibility guarantee.
|
| 313 |
+
|
| 314 |
+
---
|
| 315 |
+
|
| 316 |
+
## 6. Dependency Injection
|
| 317 |
+
|
| 318 |
+
### Principle
|
| 319 |
+
|
| 320 |
+
> No global objects for stateful services. Inject services, storage,
|
| 321 |
+
> metrics, cache, and orchestrator through constructors.
|
| 322 |
+
|
| 323 |
+
### Composition Root
|
| 324 |
+
|
| 325 |
+
`api/container.py::build_container()` is the **only** place where
|
| 326 |
+
services are constructed. It runs once at app startup and stores the
|
| 327 |
+
`ServiceContainer` on `app.state.container`.
|
| 328 |
+
|
| 329 |
+
### How Routes Receive Services
|
| 330 |
+
|
| 331 |
+
```python
|
| 332 |
+
# api/routes/faces.py
|
| 333 |
+
from fastapi import Depends
|
| 334 |
+
from api.deps import get_detection_service
|
| 335 |
+
|
| 336 |
+
@router.post("/detect")
|
| 337 |
+
async def detect_faces(req: FaceRequest,
|
| 338 |
+
svc: DetectionService = Depends(get_detection_service)):
|
| 339 |
+
return await svc.detect(job_req)
|
| 340 |
+
```
|
| 341 |
+
|
| 342 |
+
`get_detection_service` is a thin wrapper:
|
| 343 |
+
|
| 344 |
+
```python
|
| 345 |
+
# api/deps.py
|
| 346 |
+
def get_detection_service(request: Request):
|
| 347 |
+
return get_container(request).detection_service
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
### Why This Matters
|
| 351 |
+
|
| 352 |
+
- **Testability:** tests construct a container with `:memory:` DB, disabled
|
| 353 |
+
network providers, and assert against the same code path as production.
|
| 354 |
+
- **No hidden state:** every dependency is visible in the constructor
|
| 355 |
+
signature — no surprise module-level singletons.
|
| 356 |
+
- **Easy overrides:** swap any component (cache, DB, registry) by
|
| 357 |
+
constructing a custom container in tests or for feature flags.
|
| 358 |
+
|
| 359 |
+
---
|
| 360 |
+
|
| 361 |
+
## 7. Structured Logging
|
| 362 |
+
|
| 363 |
+
Every log line produced inside an execution carries:
|
| 364 |
+
|
| 365 |
+
```
|
| 366 |
+
2026-07-10 14:30:00.123 | INFO | eid=abc123def456 | pid=haar | retry=0 | status=started | invoking haar
|
| 367 |
+
```
|
| 368 |
+
|
| 369 |
+
| Field | Source | Meaning |
|
| 370 |
+
|---|---|---|
|
| 371 |
+
| `eid` | `execution_context(execution_id=...)` | Unique id for the top-level job |
|
| 372 |
+
| `pid` | `execution_context(provider_id=...)` | Provider name (or `-` for service-level logs) |
|
| 373 |
+
| `retry` | `execution_context(retry_count=...)` | Retry attempt number (0 = first try) |
|
| 374 |
+
| `status` | updated via the context dict | `started` → `running` → `success` / `failed` / `retried` |
|
| 375 |
+
|
| 376 |
+
### Usage
|
| 377 |
+
|
| 378 |
+
```python
|
| 379 |
+
from utils.logging import execution_context, new_execution_id
|
| 380 |
+
|
| 381 |
+
async def my_handler():
|
| 382 |
+
eid = new_execution_id()
|
| 383 |
+
with execution_context(execution_id=eid, provider_id="my_service"):
|
| 384 |
+
logger.info("started") # → eid=…, pid=my_service, status=started
|
| 385 |
+
# ... do work ...
|
| 386 |
+
logger.info("completed") # → same eid + pid
|
| 387 |
+
```
|
| 388 |
+
|
| 389 |
+
### JSON Mode
|
| 390 |
+
|
| 391 |
+
Set `FI_LOG_JSON=true` to emit newline-delimited JSON for log
|
| 392 |
+
aggregators (Loki, Datadog, CloudWatch):
|
| 393 |
+
|
| 394 |
+
```json
|
| 395 |
+
{"timestamp":"2026-07-10T14:30:00.123Z","level":"INFO","execution_id":"abc123def456","provider_id":"haar","retry_count":0,"status":"started","message":"invoking haar"}
|
| 396 |
+
```
|
| 397 |
+
|
| 398 |
+
---
|
| 399 |
+
|
| 400 |
+
## 8. Metrics Subsystem
|
| 401 |
+
|
| 402 |
+
`metrics/` tracks six categories consumed by `/stats` and the UI:
|
| 403 |
+
|
| 404 |
+
| Subsystem | What it tracks | Where it's recorded |
|
| 405 |
+
|---|---|---|
|
| 406 |
+
| `ProviderMetrics` | per-provider invocations, successes, failures, retries, latency samples | orchestrator after each provider call |
|
| 407 |
+
| `TimingCollector` | per-operation duration histograms (p50, p95) | services record `job.detection`, `job.recognition`, `job.search` |
|
| 408 |
+
| `CounterRegistry` | global counters (cache.hits, cache.misses, retries.<provider>, failures.<provider>, jobs.<kind>.completed) | orchestrator + services |
|
| 409 |
+
| `HealthMetrics` | per-provider consecutive failures, avg latency, circuit-breaker state | orchestrator via HealthMonitor |
|
| 410 |
+
|
| 411 |
+
`MetricsCollector` is a facade that owns all four — injected as a single
|
| 412 |
+
object so services don't depend on four separate classes.
|
| 413 |
+
|
| 414 |
+
`GET /stats` returns the full snapshot:
|
| 415 |
+
|
| 416 |
+
```json
|
| 417 |
+
{
|
| 418 |
+
"providers": [{"name": "haar", "invocations": 42, "successes": 41, "failures": 1, "retries": 0, "avg_latency_ms": 6.2, "p95_latency_ms": 12.4, "success_rate": 0.976}],
|
| 419 |
+
"timings": {"job.detection": {"count": 42, "avg_ms": 14.3, "p50_ms": 12.0, "p95_ms": 28.1}},
|
| 420 |
+
"counters": {"cache.hits": 18, "cache.misses": 24, "jobs.detection.completed": 42},
|
| 421 |
+
"health": [{"name": "haar", "consecutive_failures": 0, "avg_latency_ms": 6.2, "circuit_open": false}]
|
| 422 |
+
}
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
---
|
| 426 |
+
|
| 427 |
+
## 9. REST API Surface
|
| 428 |
+
|
| 429 |
+
| Method | Path | Description |
|
| 430 |
+
|---|---|---|
|
| 431 |
+
| `GET` | `/health` | Liveness probe |
|
| 432 |
+
| `GET` | `/health/live` | Liveness |
|
| 433 |
+
| `GET` | `/health/ready` | Readiness |
|
| 434 |
+
| `GET` | `/health/providers` | Per-provider health snapshot |
|
| 435 |
+
| `GET` | `/stats` | Full metrics snapshot |
|
| 436 |
+
| `GET` | `/providers` | List all providers + manifest errors |
|
| 437 |
+
| `GET` | `/providers/{name}` | Single provider info |
|
| 438 |
+
| `GET` | `/cache` | Cache stats (entries, hit ratio, evictions) |
|
| 439 |
+
| `DELETE` | `/cache` | Clear cache |
|
| 440 |
+
| `POST` | `/jobs` | Create + run a job (detection / recognition / search / full_pipeline) |
|
| 441 |
+
| `GET` | `/jobs` | List recent jobs |
|
| 442 |
+
| `GET` | `/jobs/{id}` | Job metadata |
|
| 443 |
+
| `GET` | `/jobs/{id}/result` | Job result (UnifiedFaceReport) |
|
| 444 |
+
| `POST` | `/faces/detect` | Convenience: detect-only |
|
| 445 |
+
| `POST` | `/faces/recognize` | Convenience: recognize-only |
|
| 446 |
+
| `GET` | `/faces/gallery` | List known persons |
|
| 447 |
+
| `DELETE` | `/faces/gallery/{name}` | Remove a known person |
|
| 448 |
+
| `POST` | `/search/reverse` | Reverse image search |
|
| 449 |
+
| `POST` | `/search/scrape` | Scrape images from a URL |
|
| 450 |
+
| `GET` | `/export/{job_id}` | Download job result as JSON |
|
| 451 |
+
| `GET` | `/docs` | OpenAPI Swagger UI |
|
| 452 |
+
|
| 453 |
+
---
|
| 454 |
+
|
| 455 |
+
## 10. File Tree (refactored)
|
| 456 |
+
|
| 457 |
+
```
|
| 458 |
+
face-intel/
|
| 459 |
+
├── app.py # entry point: uvicorn app:app
|
| 460 |
+
├── requirements.txt
|
| 461 |
+
├── .env.example
|
| 462 |
+
├── README.md
|
| 463 |
+
│
|
| 464 |
+
├── config/
|
| 465 |
+
│ ├── __init__.py
|
| 466 |
+
│ └── settings.py # Settings + make_settings() factory
|
| 467 |
+
│
|
| 468 |
+
├── models/ # NEW: shared domain DTOs
|
| 469 |
+
│ ├── __init__.py
|
| 470 |
+
│ ├── jobs.py # Job, JobRequest, JobStatus, JobResult, JobKind
|
| 471 |
+
│ ├── providers.py # ProviderCapability, ProviderStatus, ProviderInfo, ProviderConfig
|
| 472 |
+
│ ├── reports.py # UnifiedFaceReport, FaceDetection, FaceMatch, Evidence, ConfidenceScore, ConflictReport
|
| 473 |
+
│ ├── responses.py # APIResponse, PaginatedResponse, ErrorResponse, HealthResponse
|
| 474 |
+
│ └── health.py # HealthSnapshot, ProviderHealthSnapshot, SystemHealthSnapshot
|
| 475 |
+
│
|
| 476 |
+
├── utils/ # lowest layer
|
| 477 |
+
│ ├── __init__.py
|
| 478 |
+
│ ├── image.py # bytes↔numpy, BBox, crop, hash, draw
|
| 479 |
+
│ ├── http.py # shared requests.Session with retries
|
| 480 |
+
│ ├── audit.py # append-only JSONL audit log
|
| 481 |
+
│ ├── timing.py # @contextmanager timed()
|
| 482 |
+
│ └── logging.py # NEW: loguru + execution_context()
|
| 483 |
+
│
|
| 484 |
+
├── storage/ # separated responsibilities
|
| 485 |
+
│ ├── __init__.py
|
| 486 |
+
│ ├── database.py # SQLite (jobs, results)
|
| 487 |
+
│ ├── cache.py # in-memory TTL + LRU
|
| 488 |
+
│ ├── artifacts.py # filesystem: uploads/, generated/
|
| 489 |
+
│ └── reference_store.py # known-faces gallery (.npy + manifest.json)
|
| 490 |
+
│
|
| 491 |
+
├── metrics/ # NEW
|
| 492 |
+
│ ├── __init__.py
|
| 493 |
+
│ ├── provider_metrics.py # per-provider latency/success/retry
|
| 494 |
+
│ ├── timings.py # operation-level p50/p95
|
| 495 |
+
│ ├── counters.py # named integer counters
|
| 496 |
+
│ ├── health_metrics.py # circuit breaker state
|
| 497 |
+
│ └── collector.py # MetricsCollector facade
|
| 498 |
+
│
|
| 499 |
+
├── providers/
|
| 500 |
+
│ ├── __init__.py # re-exports (no globals)
|
| 501 |
+
│ ├── base.py # Provider Protocol, BaseProvider, ProviderResult
|
| 502 |
+
│ ├── registry.py # NEW: ProviderRegistry class + PROVIDER_MANIFEST
|
| 503 |
+
│ ├── detection/
|
| 504 |
+
│ │ ├── __init__.py
|
| 505 |
+
│ │ └── haar.py # implemented (DI-updated)
|
| 506 |
+
│ ├── recognition/ # stubs (to be implemented Phase 3)
|
| 507 |
+
│ ├── scraper/
|
| 508 |
+
│ └── reverse/
|
| 509 |
+
│
|
| 510 |
+
├── pipeline/ # NEW
|
| 511 |
+
│ ├── __init__.py
|
| 512 |
+
│ ├── validation.py # InputValidator
|
| 513 |
+
│ ├── preprocessing.py # ImagePreprocessor → PreprocessedImage
|
| 514 |
+
│ ├── hashing.py # ImageHasher (SHA-256 cache key)
|
| 515 |
+
│ ├── feature_extraction.py # FeatureExtractor → PipelineOutput
|
| 516 |
+
│ └── postprocessing.py # dedupe, clamp, filter
|
| 517 |
+
│
|
| 518 |
+
├── orchestrator/
|
| 519 |
+
│ ├── __init__.py
|
| 520 |
+
│ ├── runner.py # Orchestrator (async fan-out, DI-injected)
|
| 521 |
+
│ ├── retry.py # RetryPolicy, with_retry_sync/async
|
| 522 |
+
│ └── health.py # HealthMonitor (circuit breaker gate)
|
| 523 |
+
│
|
| 524 |
+
├── normalization/
|
| 525 |
+
│ ├── __init__.py
|
| 526 |
+
│ ├── schema.py # internal DTOs (NormalizedBox, NormalizedMatch, ...)
|
| 527 |
+
│ └── merger.py # ReportMerger (provider results → UnifiedFaceReport)
|
| 528 |
+
│
|
| 529 |
+
├── confidence/
|
| 530 |
+
│ ├── __init__.py
|
| 531 |
+
│ ├── engine.py # ConfidenceEngine (weighted sub-scores)
|
| 532 |
+
│ ├── explainer.py # human-readable explanations
|
| 533 |
+
│ └── conflicts.py # ConflictDetector (cross-provider disagreements)
|
| 534 |
+
│
|
| 535 |
+
├── services/ # NEW
|
| 536 |
+
│ ├── __init__.py
|
| 537 |
+
│ ├── detection_service.py
|
| 538 |
+
│ ├── recognition_service.py
|
| 539 |
+
│ ├── search_service.py
|
| 540 |
+
│ ├── job_service.py # full-pipeline + persistence
|
| 541 |
+
│ ├── provider_service.py
|
| 542 |
+
│ ├── cache_service.py
|
| 543 |
+
│ ├── health_service.py
|
| 544 |
+
│ └── export_service.py
|
| 545 |
+
│
|
| 546 |
+
├── api/
|
| 547 |
+
│ ├── __init__.py
|
| 548 |
+
│ ├── main.py # create_app() factory
|
| 549 |
+
│ ├── container.py # build_container() — composition root
|
| 550 |
+
│ ├── deps.py # FastAPI Depends() callables
|
| 551 |
+
│ ├── middleware.py # request-id, rate limit
|
| 552 |
+
│ └── routes/
|
| 553 |
+
│ ├── __init__.py
|
| 554 |
+
│ ├── health.py
|
| 555 |
+
│ ├── stats.py
|
| 556 |
+
│ ├── providers.py
|
| 557 |
+
│ ├── cache.py
|
| 558 |
+
│ ├── jobs.py
|
| 559 |
+
│ ├── faces.py
|
| 560 |
+
│ ├── search.py
|
| 561 |
+
│ └── export.py
|
| 562 |
+
│
|
| 563 |
+
├── ui/
|
| 564 |
+
│ └── static/ # served by FastAPI (to be built Phase 8)
|
| 565 |
+
│
|
| 566 |
+
├── tests/ # to be built Phase 9
|
| 567 |
+
│ ├── unit/
|
| 568 |
+
│ ├── providers/
|
| 569 |
+
│ └── integration/
|
| 570 |
+
│
|
| 571 |
+
└── docs/
|
| 572 |
+
└── ARCHITECTURE.md # this file
|
| 573 |
+
```
|
| 574 |
+
|
| 575 |
+
---
|
| 576 |
+
|
| 577 |
+
## 11. Verification
|
| 578 |
+
|
| 579 |
+
The refactor has been smoke-tested end-to-end:
|
| 580 |
+
|
| 581 |
+
```
|
| 582 |
+
✓ All 11 layers import cleanly — no circular dependencies
|
| 583 |
+
✓ DI container builds with all 15 manifest entries registered
|
| 584 |
+
✓ FastAPI app boots with 32 routes
|
| 585 |
+
✓ End-to-end detection job runs: pipeline → orchestrator → haar → normalization → confidence → report
|
| 586 |
+
✓ Haar provider correctly receives PipelineOutput (not raw numpy)
|
| 587 |
+
✓ Evidence preserved with raw + normalized + elapsed_ms + success
|
| 588 |
+
✓ Confidence engine produces explainable sub-scores
|
| 589 |
+
✓ Metrics subsystem records invocations, successes, timings
|
| 590 |
+
✓ Circuit-breaker state queryable via /health/providers
|
| 591 |
+
```
|
| 592 |
+
|
| 593 |
+
---
|
| 594 |
+
|
| 595 |
+
## 12. What's Next
|
| 596 |
+
|
| 597 |
+
The refactor establishes the production-grade skeleton. The remaining
|
| 598 |
+
work from the original 10-phase plan:
|
| 599 |
+
|
| 600 |
+
- **Phase 3 (continued):** implement the 14 remaining provider modules
|
| 601 |
+
(dnn, mtcnn, retinaface, face_recognition, deepface, insightface,
|
| 602 |
+
beautifulsoup, selenium, bing, duckduckgo, google_lens, serpapi,
|
| 603 |
+
yandex, tineye). Each follows the `haar.py` pattern — ~50 LOC.
|
| 604 |
+
- **Phase 8:** build the UI SPA in `ui/static/`.
|
| 605 |
+
- **Phase 9:** write the test suites in `tests/`.
|
| 606 |
+
- **Phase 10:** provider-specific docs, deployment, benchmarks.
|
| 607 |
+
|
| 608 |
+
The architecture will not need to change for any of these — only
|
| 609 |
+
additions within existing packages.
|
download/face-intel/metrics/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Metrics subsystem — tracks provider latency, success/failure counts,
|
| 3 |
+
retries, cache hit ratio, and execution duration.
|
| 4 |
+
|
| 5 |
+
All metrics are in-process and thread-safe. Designed to be scraped
|
| 6 |
+
by the /stats endpoint. No external metrics backend (Prometheus etc.)
|
| 7 |
+
is required, but the data model is compatible with one.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from metrics.provider_metrics import ProviderMetrics
|
| 11 |
+
from metrics.timings import TimingCollector
|
| 12 |
+
from metrics.counters import CounterRegistry
|
| 13 |
+
from metrics.health_metrics import HealthMetrics
|
| 14 |
+
from metrics.collector import MetricsCollector
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
"ProviderMetrics",
|
| 18 |
+
"TimingCollector",
|
| 19 |
+
"CounterRegistry",
|
| 20 |
+
"HealthMetrics",
|
| 21 |
+
"MetricsCollector",
|
| 22 |
+
]
|
download/face-intel/metrics/collector.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MetricsCollector — aggregates the four metric subsystems into one
|
| 3 |
+
injectable facade so services don't depend on four separate objects.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from metrics.provider_metrics import ProviderMetrics
|
| 9 |
+
from metrics.timings import TimingCollector
|
| 10 |
+
from metrics.counters import CounterRegistry
|
| 11 |
+
from metrics.health_metrics import HealthMetrics
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class MetricsCollector:
|
| 15 |
+
"""Facade that owns all metric subsystems."""
|
| 16 |
+
|
| 17 |
+
def __init__(self,
|
| 18 |
+
failure_threshold: int = 5,
|
| 19 |
+
recovery_seconds: int = 120) -> None:
|
| 20 |
+
self.providers = ProviderMetrics()
|
| 21 |
+
self.timings = TimingCollector()
|
| 22 |
+
self.counters = CounterRegistry()
|
| 23 |
+
self.health = HealthMetrics(
|
| 24 |
+
failure_threshold=failure_threshold,
|
| 25 |
+
recovery_seconds=recovery_seconds,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def snapshot(self) -> dict:
|
| 29 |
+
return {
|
| 30 |
+
"providers": self.providers.snapshot(),
|
| 31 |
+
"timings": self.timings.snapshot(),
|
| 32 |
+
"counters": self.counters.snapshot(),
|
| 33 |
+
"health": self.health.snapshot(),
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
def reset(self) -> None:
|
| 37 |
+
self.providers.reset()
|
| 38 |
+
self.timings.reset()
|
| 39 |
+
self.counters.reset()
|
| 40 |
+
self.health.reset()
|
download/face-intel/metrics/counters.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Global monotonic counters — cache hits/misses, requests, errors.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import threading
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
from typing import Dict
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class CounterRegistry:
|
| 13 |
+
"""Named integer counters."""
|
| 14 |
+
|
| 15 |
+
def __init__(self) -> None:
|
| 16 |
+
self._lock = threading.RLock()
|
| 17 |
+
self._counters: Dict[str, int] = defaultdict(int)
|
| 18 |
+
|
| 19 |
+
def inc(self, name: str, amount: int = 1) -> None:
|
| 20 |
+
with self._lock:
|
| 21 |
+
self._counters[name] += amount
|
| 22 |
+
|
| 23 |
+
def get(self, name: str) -> int:
|
| 24 |
+
with self._lock:
|
| 25 |
+
return self._counters.get(name, 0)
|
| 26 |
+
|
| 27 |
+
def snapshot(self) -> Dict[str, int]:
|
| 28 |
+
with self._lock:
|
| 29 |
+
return dict(self._counters)
|
| 30 |
+
|
| 31 |
+
def reset(self) -> None:
|
| 32 |
+
with self._lock:
|
| 33 |
+
self._counters.clear()
|
download/face-intel/metrics/health_metrics.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Health metrics — feeds the circuit breaker and the /health endpoint.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from typing import Dict, Optional
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class HealthRecord:
|
| 16 |
+
name: str
|
| 17 |
+
consecutive_failures: int = 0
|
| 18 |
+
last_success: Optional[float] = None # epoch
|
| 19 |
+
last_failure: Optional[float] = None
|
| 20 |
+
avg_latency_ms: float = 0.0
|
| 21 |
+
circuit_open: bool = False
|
| 22 |
+
circuit_opened_at: Optional[float] = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class HealthMetrics:
|
| 26 |
+
"""Per-provider health record + circuit breaker state."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, failure_threshold: int = 5,
|
| 29 |
+
recovery_seconds: int = 120) -> None:
|
| 30 |
+
self._lock = threading.RLock()
|
| 31 |
+
self._records: Dict[str, HealthRecord] = {}
|
| 32 |
+
self._failure_threshold = failure_threshold
|
| 33 |
+
self._recovery_seconds = recovery_seconds
|
| 34 |
+
|
| 35 |
+
def _get(self, name: str) -> HealthRecord:
|
| 36 |
+
if name not in self._records:
|
| 37 |
+
self._records[name] = HealthRecord(name=name)
|
| 38 |
+
return self._records[name]
|
| 39 |
+
|
| 40 |
+
def record_success(self, name: str, latency_ms: float) -> None:
|
| 41 |
+
with self._lock:
|
| 42 |
+
r = self._get(name)
|
| 43 |
+
r.consecutive_failures = 0
|
| 44 |
+
r.last_success = time.time()
|
| 45 |
+
# rolling avg
|
| 46 |
+
r.avg_latency_ms = 0.9 * r.avg_latency_ms + 0.1 * latency_ms if r.avg_latency_ms else latency_ms
|
| 47 |
+
# auto-close circuit on success
|
| 48 |
+
if r.circuit_open:
|
| 49 |
+
r.circuit_open = False
|
| 50 |
+
r.circuit_opened_at = None
|
| 51 |
+
|
| 52 |
+
def record_failure(self, name: str) -> None:
|
| 53 |
+
with self._lock:
|
| 54 |
+
r = self._get(name)
|
| 55 |
+
r.consecutive_failures += 1
|
| 56 |
+
r.last_failure = time.time()
|
| 57 |
+
if r.consecutive_failures >= self._failure_threshold and not r.circuit_open:
|
| 58 |
+
r.circuit_open = True
|
| 59 |
+
r.circuit_opened_at = time.time()
|
| 60 |
+
|
| 61 |
+
def is_circuit_open(self, name: str) -> bool:
|
| 62 |
+
with self._lock:
|
| 63 |
+
r = self._get(name)
|
| 64 |
+
if not r.circuit_open:
|
| 65 |
+
return False
|
| 66 |
+
# half-open after recovery window
|
| 67 |
+
if r.circuit_opened_at and (time.time() - r.circuit_opened_at) > self._recovery_seconds:
|
| 68 |
+
r.circuit_open = False
|
| 69 |
+
r.circuit_opened_at = None
|
| 70 |
+
return False
|
| 71 |
+
return True
|
| 72 |
+
|
| 73 |
+
def snapshot(self) -> list[dict]:
|
| 74 |
+
with self._lock:
|
| 75 |
+
out = []
|
| 76 |
+
for name, r in self._records.items():
|
| 77 |
+
out.append({
|
| 78 |
+
"name": name,
|
| 79 |
+
"consecutive_failures": r.consecutive_failures,
|
| 80 |
+
"last_success": datetime.fromtimestamp(r.last_success, tz=timezone.utc).isoformat() if r.last_success else None,
|
| 81 |
+
"last_failure": datetime.fromtimestamp(r.last_failure, tz=timezone.utc).isoformat() if r.last_failure else None,
|
| 82 |
+
"avg_latency_ms": round(r.avg_latency_ms, 3),
|
| 83 |
+
"circuit_open": r.circuit_open,
|
| 84 |
+
})
|
| 85 |
+
return out
|
| 86 |
+
|
| 87 |
+
def reset(self) -> None:
|
| 88 |
+
with self._lock:
|
| 89 |
+
self._records.clear()
|
download/face-intel/metrics/provider_metrics.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Per-provider metrics: latency histogram (simplified), success/failure
|
| 3 |
+
counts, retry counts.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import threading
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Dict, List
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class ProviderStat:
|
| 16 |
+
name: str
|
| 17 |
+
invocations: int = 0
|
| 18 |
+
successes: int = 0
|
| 19 |
+
failures: int = 0
|
| 20 |
+
retries: int = 0
|
| 21 |
+
latencies_ms: List[float] = field(default_factory=list)
|
| 22 |
+
last_latency_ms: float = 0.0
|
| 23 |
+
last_error: str = ""
|
| 24 |
+
|
| 25 |
+
def to_dict(self) -> dict:
|
| 26 |
+
total = self.successes + self.failures
|
| 27 |
+
avg = sum(self.latencies_ms) / len(self.latencies_ms) if self.latencies_ms else 0.0
|
| 28 |
+
p95 = sorted(self.latencies_ms)[int(len(self.latencies_ms) * 0.95)] if self.latencies_ms else 0.0
|
| 29 |
+
return {
|
| 30 |
+
"name": self.name,
|
| 31 |
+
"invocations": self.invocations,
|
| 32 |
+
"successes": self.successes,
|
| 33 |
+
"failures": self.failures,
|
| 34 |
+
"retries": self.retries,
|
| 35 |
+
"success_rate": round(self.successes / total, 4) if total else 0.0,
|
| 36 |
+
"avg_latency_ms": round(avg, 3),
|
| 37 |
+
"p95_latency_ms": round(p95, 3),
|
| 38 |
+
"last_latency_ms": round(self.last_latency_ms, 3),
|
| 39 |
+
"last_error": self.last_error,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ProviderMetrics:
|
| 44 |
+
"""Thread-safe per-provider metrics collector."""
|
| 45 |
+
|
| 46 |
+
MAX_LATENCY_SAMPLES = 500 # rolling window
|
| 47 |
+
|
| 48 |
+
def __init__(self) -> None:
|
| 49 |
+
self._lock = threading.RLock()
|
| 50 |
+
self._stats: Dict[str, ProviderStat] = defaultdict(lambda: None)
|
| 51 |
+
|
| 52 |
+
def _get(self, name: str) -> ProviderStat:
|
| 53 |
+
if self._stats[name] is None:
|
| 54 |
+
self._stats[name] = ProviderStat(name=name)
|
| 55 |
+
return self._stats[name]
|
| 56 |
+
|
| 57 |
+
def record_invocation(self, name: str) -> None:
|
| 58 |
+
with self._lock:
|
| 59 |
+
self._get(name).invocations += 1
|
| 60 |
+
|
| 61 |
+
def record_success(self, name: str, latency_ms: float) -> None:
|
| 62 |
+
with self._lock:
|
| 63 |
+
s = self._get(name)
|
| 64 |
+
s.successes += 1
|
| 65 |
+
s.last_latency_ms = latency_ms
|
| 66 |
+
s.latencies_ms.append(latency_ms)
|
| 67 |
+
if len(s.latencies_ms) > self.MAX_LATENCY_SAMPLES:
|
| 68 |
+
s.latencies_ms = s.latencies_ms[-self.MAX_LATENCY_SAMPLES:]
|
| 69 |
+
|
| 70 |
+
def record_failure(self, name: str, error: str = "") -> None:
|
| 71 |
+
with self._lock:
|
| 72 |
+
s = self._get(name)
|
| 73 |
+
s.failures += 1
|
| 74 |
+
s.last_error = error
|
| 75 |
+
|
| 76 |
+
def record_retry(self, name: str) -> None:
|
| 77 |
+
with self._lock:
|
| 78 |
+
self._get(name).retries += 1
|
| 79 |
+
|
| 80 |
+
def snapshot(self) -> List[dict]:
|
| 81 |
+
with self._lock:
|
| 82 |
+
return [self._get(name).to_dict() for name in sorted(self._stats.keys())]
|
| 83 |
+
|
| 84 |
+
def reset(self) -> None:
|
| 85 |
+
with self._lock:
|
| 86 |
+
self._stats.clear()
|
download/face-intel/metrics/timings.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Execution timing collector — tracks end-to-end job durations.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import threading
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
from typing import Dict, List
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TimingCollector:
|
| 13 |
+
"""Tracks durations grouped by operation label."""
|
| 14 |
+
|
| 15 |
+
MAX_SAMPLES = 500
|
| 16 |
+
|
| 17 |
+
def __init__(self) -> None:
|
| 18 |
+
self._lock = threading.RLock()
|
| 19 |
+
self._samples: Dict[str, List[float]] = defaultdict(list)
|
| 20 |
+
|
| 21 |
+
def record(self, label: str, duration_ms: float) -> None:
|
| 22 |
+
with self._lock:
|
| 23 |
+
self._samples[label].append(duration_ms)
|
| 24 |
+
if len(self._samples[label]) > self.MAX_SAMPLES:
|
| 25 |
+
self._samples[label] = self._samples[label][-self.MAX_SAMPLES:]
|
| 26 |
+
|
| 27 |
+
def snapshot(self) -> dict:
|
| 28 |
+
with self._lock:
|
| 29 |
+
out = {}
|
| 30 |
+
for label, samples in self._samples.items():
|
| 31 |
+
if not samples:
|
| 32 |
+
continue
|
| 33 |
+
s = sorted(samples)
|
| 34 |
+
out[label] = {
|
| 35 |
+
"count": len(samples),
|
| 36 |
+
"avg_ms": round(sum(samples) / len(samples), 3),
|
| 37 |
+
"p50_ms": round(s[len(s) // 2], 3),
|
| 38 |
+
"p95_ms": round(s[int(len(s) * 0.95)], 3),
|
| 39 |
+
"min_ms": round(s[0], 3),
|
| 40 |
+
"max_ms": round(s[-1], 3),
|
| 41 |
+
}
|
| 42 |
+
return out
|
| 43 |
+
|
| 44 |
+
def reset(self) -> None:
|
| 45 |
+
with self._lock:
|
| 46 |
+
self._samples.clear()
|
download/face-intel/models/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared domain models.
|
| 3 |
+
|
| 4 |
+
These are the cross-layer data contracts. Only normalization-specific
|
| 5 |
+
DTOs live in `normalization/schema.py`; everything else that crosses a
|
| 6 |
+
layer boundary is defined here.
|
| 7 |
+
|
| 8 |
+
Dependency rule: models/ imports only from pydantic + stdlib. It never
|
| 9 |
+
imports from utils, storage, providers, pipeline, orchestrator, services,
|
| 10 |
+
or api.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from models.jobs import (
|
| 14 |
+
Job,
|
| 15 |
+
JobRequest,
|
| 16 |
+
JobStatus,
|
| 17 |
+
JobResult,
|
| 18 |
+
JobKind,
|
| 19 |
+
)
|
| 20 |
+
from models.providers import (
|
| 21 |
+
ProviderCapability,
|
| 22 |
+
ProviderStatus,
|
| 23 |
+
ProviderInfo,
|
| 24 |
+
ProviderConfig,
|
| 25 |
+
)
|
| 26 |
+
from models.reports import (
|
| 27 |
+
UnifiedFaceReport,
|
| 28 |
+
FaceDetection,
|
| 29 |
+
FaceMatch,
|
| 30 |
+
Evidence,
|
| 31 |
+
ConfidenceScore,
|
| 32 |
+
ConflictReport,
|
| 33 |
+
ReportMetadata,
|
| 34 |
+
)
|
| 35 |
+
from models.responses import (
|
| 36 |
+
APIResponse,
|
| 37 |
+
PaginatedResponse,
|
| 38 |
+
ErrorResponse,
|
| 39 |
+
HealthResponse,
|
| 40 |
+
)
|
| 41 |
+
from models.health import (
|
| 42 |
+
HealthSnapshot,
|
| 43 |
+
ProviderHealthSnapshot,
|
| 44 |
+
SystemHealthSnapshot,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
__all__ = [
|
| 48 |
+
# jobs
|
| 49 |
+
"Job", "JobRequest", "JobStatus", "JobResult", "JobKind",
|
| 50 |
+
# providers
|
| 51 |
+
"ProviderCapability", "ProviderStatus", "ProviderInfo", "ProviderConfig",
|
| 52 |
+
# reports
|
| 53 |
+
"UnifiedFaceReport", "FaceDetection", "FaceMatch", "Evidence",
|
| 54 |
+
"ConfidenceScore", "ConflictReport", "ReportMetadata",
|
| 55 |
+
# responses
|
| 56 |
+
"APIResponse", "PaginatedResponse", "ErrorResponse", "HealthResponse",
|
| 57 |
+
# health
|
| 58 |
+
"HealthSnapshot", "ProviderHealthSnapshot", "SystemHealthSnapshot",
|
| 59 |
+
]
|
download/face-intel/models/health.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health-related domain models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from typing import Dict, List, Optional
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ProviderHealthSnapshot(BaseModel):
|
| 12 |
+
"""Per-provider health snapshot used by the circuit breaker + API."""
|
| 13 |
+
name: str
|
| 14 |
+
healthy: bool
|
| 15 |
+
consecutive_failures: int = 0
|
| 16 |
+
last_success: Optional[datetime] = None
|
| 17 |
+
last_failure: Optional[datetime] = None
|
| 18 |
+
last_check: Optional[datetime] = None
|
| 19 |
+
avg_latency_ms: float = 0.0
|
| 20 |
+
circuit_open: bool = False
|
| 21 |
+
circuit_opened_at: Optional[datetime] = None
|
| 22 |
+
metadata: dict = Field(default_factory=dict)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SystemHealthSnapshot(BaseModel):
|
| 26 |
+
"""Aggregate system health."""
|
| 27 |
+
status: str = "healthy" # healthy | degraded | unhealthy
|
| 28 |
+
version: str = "1.0.0"
|
| 29 |
+
uptime_seconds: float = 0.0
|
| 30 |
+
providers: List[ProviderHealthSnapshot] = Field(default_factory=list)
|
| 31 |
+
cache: dict = Field(default_factory=dict)
|
| 32 |
+
metrics: dict = Field(default_factory=dict)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class HealthSnapshot(BaseModel):
|
| 36 |
+
"""Convenience alias."""
|
| 37 |
+
system: SystemHealthSnapshot
|
| 38 |
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
download/face-intel/models/jobs.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Job-related domain models — used by services, storage, and API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import enum
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from typing import Any, List, Optional
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class JobKind(str, enum.Enum):
|
| 14 |
+
DETECTION = "detection"
|
| 15 |
+
RECOGNITION = "recognition"
|
| 16 |
+
SEARCH = "search" # scrape + reverse image search
|
| 17 |
+
FULL_PIPELINE = "full_pipeline" # detect + recognize + search
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class JobStatus(str, enum.Enum):
|
| 21 |
+
PENDING = "pending"
|
| 22 |
+
RUNNING = "running"
|
| 23 |
+
COMPLETED = "completed"
|
| 24 |
+
FAILED = "failed"
|
| 25 |
+
CANCELLED = "cancelled"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class JobRequest(BaseModel):
|
| 29 |
+
"""Inbound request to create a job."""
|
| 30 |
+
kind: JobKind
|
| 31 |
+
image_url: Optional[str] = None
|
| 32 |
+
image_base64: Optional[str] = None
|
| 33 |
+
providers: List[str] = Field(
|
| 34 |
+
default_factory=list,
|
| 35 |
+
description="Optional whitelist of provider names. Empty = use all enabled.",
|
| 36 |
+
)
|
| 37 |
+
options: dict = Field(default_factory=dict)
|
| 38 |
+
|
| 39 |
+
def has_image_input(self) -> bool:
|
| 40 |
+
return bool(self.image_url or self.image_base64)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class Job(BaseModel):
|
| 44 |
+
"""Persisted job record."""
|
| 45 |
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 46 |
+
kind: JobKind
|
| 47 |
+
status: JobStatus = JobStatus.PENDING
|
| 48 |
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 49 |
+
started_at: Optional[datetime] = None
|
| 50 |
+
completed_at: Optional[datetime] = None
|
| 51 |
+
request: JobRequest
|
| 52 |
+
image_hash: Optional[str] = None
|
| 53 |
+
error: Optional[str] = None
|
| 54 |
+
|
| 55 |
+
def mark_running(self) -> None:
|
| 56 |
+
self.status = JobStatus.RUNNING
|
| 57 |
+
self.started_at = datetime.now(timezone.utc)
|
| 58 |
+
|
| 59 |
+
def mark_completed(self) -> None:
|
| 60 |
+
self.status = JobStatus.COMPLETED
|
| 61 |
+
self.completed_at = datetime.now(timezone.utc)
|
| 62 |
+
|
| 63 |
+
def mark_failed(self, error: str) -> None:
|
| 64 |
+
self.status = JobStatus.FAILED
|
| 65 |
+
self.completed_at = datetime.now(timezone.utc)
|
| 66 |
+
self.error = error
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class JobResult(BaseModel):
|
| 70 |
+
"""The output of a completed job — wraps a UnifiedFaceReport."""
|
| 71 |
+
job_id: str
|
| 72 |
+
status: JobStatus
|
| 73 |
+
report: Optional[Any] = None # UnifiedFaceReport (avoid circular import)
|
| 74 |
+
error: Optional[str] = None
|
| 75 |
+
elapsed_ms: float = 0.0
|
download/face-intel/models/providers.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provider-related domain models.
|
| 2 |
+
|
| 3 |
+
Note: ProviderCapability and ProviderStatus are also re-exported from
|
| 4 |
+
providers/base.py for legacy compatibility, but the canonical home is
|
| 5 |
+
here in models/ (per refactor requirement 6).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import enum
|
| 11 |
+
from typing import Any, Optional
|
| 12 |
+
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ProviderCapability(str, enum.Enum):
|
| 17 |
+
DETECTION = "detection"
|
| 18 |
+
RECOGNITION = "recognition"
|
| 19 |
+
SCRAPING = "scraping"
|
| 20 |
+
REVERSE_SEARCH = "reverse_search"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ProviderStatus(str, enum.Enum):
|
| 24 |
+
HEALTHY = "healthy"
|
| 25 |
+
DEGRADED = "degraded"
|
| 26 |
+
UNHEALTHY = "unhealthy"
|
| 27 |
+
DISABLED = "disabled"
|
| 28 |
+
NOT_CONFIGURED = "not_configured"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ProviderInfo(BaseModel):
|
| 32 |
+
"""Public-facing description of a registered provider."""
|
| 33 |
+
name: str
|
| 34 |
+
capability: ProviderCapability
|
| 35 |
+
status: ProviderStatus
|
| 36 |
+
available: bool
|
| 37 |
+
description: str = ""
|
| 38 |
+
version: str = ""
|
| 39 |
+
metadata: dict = Field(default_factory=dict)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ProviderConfig(BaseModel):
|
| 43 |
+
"""Configuration entry used by the registry manifest."""
|
| 44 |
+
name: str
|
| 45 |
+
module_path: str
|
| 46 |
+
class_name: str
|
| 47 |
+
capability: ProviderCapability
|
| 48 |
+
enable_flag: str
|
| 49 |
+
description: str = ""
|
| 50 |
+
requires_api_key: bool = False
|
| 51 |
+
optional_dependency: bool = False
|
download/face-intel/models/reports.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Report domain models — the unified output of a face-intelligence job."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Evidence(BaseModel):
|
| 13 |
+
"""Preserved raw provider output — never discarded."""
|
| 14 |
+
provider: str
|
| 15 |
+
capability: str
|
| 16 |
+
raw: Any = None # verbatim provider response
|
| 17 |
+
normalized: dict = Field(default_factory=dict)
|
| 18 |
+
elapsed_ms: float = 0.0
|
| 19 |
+
success: bool = True
|
| 20 |
+
error: Optional[str] = None
|
| 21 |
+
error_type: Optional[str] = None
|
| 22 |
+
metadata: dict = Field(default_factory=dict)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ConfidenceScore(BaseModel):
|
| 26 |
+
"""Explainable confidence — decomposed into weighted sub-scores."""
|
| 27 |
+
overall: float # 0.0 – 1.0
|
| 28 |
+
components: Dict[str, float] = Field(default_factory=dict)
|
| 29 |
+
explanation: str = ""
|
| 30 |
+
method: str = "weighted_average"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class FaceDetection(BaseModel):
|
| 34 |
+
"""One detected face, with cross-provider consensus."""
|
| 35 |
+
box: Dict[str, int] # {x, y, w, h}
|
| 36 |
+
confidence: ConfidenceScore
|
| 37 |
+
landmarks: Optional[Dict[str, List[int]]] = None
|
| 38 |
+
detected_by: List[str] = Field(default_factory=list)
|
| 39 |
+
embedding: Optional[List[float]] = None
|
| 40 |
+
embedding_provider: Optional[str] = None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class FaceMatch(BaseModel):
|
| 44 |
+
"""A recognition match against the reference gallery."""
|
| 45 |
+
query_face_index: int
|
| 46 |
+
best_match: Optional[str] = None
|
| 47 |
+
confidence: ConfidenceScore
|
| 48 |
+
distances: Dict[str, float] = Field(default_factory=dict)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ConflictReport(BaseModel):
|
| 52 |
+
"""Cross-provider disagreement."""
|
| 53 |
+
kind: str # e.g. "face_count_mismatch"
|
| 54 |
+
providers: List[str]
|
| 55 |
+
description: str
|
| 56 |
+
severity: str = "info" # info | warning | error
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class ReportMetadata(BaseModel):
|
| 60 |
+
"""Job/report metadata."""
|
| 61 |
+
job_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 62 |
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 63 |
+
image_hash: Optional[str] = None
|
| 64 |
+
total_elapsed_ms: float = 0.0
|
| 65 |
+
providers_invoked: List[str] = Field(default_factory=list)
|
| 66 |
+
providers_succeeded: List[str] = Field(default_factory=list)
|
| 67 |
+
providers_failed: List[str] = Field(default_factory=list)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class UnifiedFaceReport(BaseModel):
|
| 71 |
+
"""The final unified report consumed by API + UI."""
|
| 72 |
+
metadata: ReportMetadata
|
| 73 |
+
detections: List[FaceDetection] = Field(default_factory=list)
|
| 74 |
+
matches: List[FaceMatch] = Field(default_factory=list)
|
| 75 |
+
scraped_images: List[dict] = Field(default_factory=list)
|
| 76 |
+
reverse_matches: List[dict] = Field(default_factory=list)
|
| 77 |
+
evidence: List[Evidence] = Field(default_factory=list)
|
| 78 |
+
conflicts: List[ConflictReport] = Field(default_factory=list)
|
| 79 |
+
overall_confidence: Optional[ConfidenceScore] = None
|
download/face-intel/models/responses.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API response wrapper models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any, List, Optional
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class APIResponse(BaseModel):
|
| 11 |
+
"""Standard envelope for successful responses."""
|
| 12 |
+
success: bool = True
|
| 13 |
+
data: Any
|
| 14 |
+
message: str = ""
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class PaginatedResponse(BaseModel):
|
| 18 |
+
success: bool = True
|
| 19 |
+
data: List[Any]
|
| 20 |
+
total: int
|
| 21 |
+
page: int = 1
|
| 22 |
+
page_size: int = 50
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ErrorResponse(BaseModel):
|
| 26 |
+
success: bool = False
|
| 27 |
+
error: str
|
| 28 |
+
error_type: str = ""
|
| 29 |
+
details: Optional[dict] = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class HealthResponse(BaseModel):
|
| 33 |
+
status: str
|
| 34 |
+
version: str
|
| 35 |
+
providers: List[dict]
|
| 36 |
+
uptime_seconds: float
|
download/face-intel/normalization/__init__.py
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Normalization package — merges multi-provider ProviderResults into a
|
| 3 |
+
single UnifiedFaceReport, preserving every Evidence record.
|
| 4 |
+
|
| 5 |
+
Internal-only DTOs (NormalizedBox, NormalizedMatch, etc.) live here.
|
| 6 |
+
Cross-layer models live in models/.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from normalization.schema import (
|
| 10 |
+
NormalizedBox,
|
| 11 |
+
NormalizedMatch,
|
| 12 |
+
NormalizedScrapeImage,
|
| 13 |
+
NormalizedReverseMatch,
|
| 14 |
+
)
|
| 15 |
+
from normalization.merger import ReportMerger
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"NormalizedBox",
|
| 19 |
+
"NormalizedMatch",
|
| 20 |
+
"NormalizedScrapeImage",
|
| 21 |
+
"NormalizedReverseMatch",
|
| 22 |
+
"ReportMerger",
|
| 23 |
+
]
|
download/face-intel/normalization/merger.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Report merger — turns a dict of {provider_name: ProviderResult} into a
|
| 3 |
+
UnifiedFaceReport.
|
| 4 |
+
|
| 5 |
+
The merger:
|
| 6 |
+
1. Extracts NormalizedBox list from each detection provider.
|
| 7 |
+
2. Extracts NormalizedMatch list from each recognition provider.
|
| 8 |
+
3. Extracts NormalizedScrapeImage list from each scraper.
|
| 9 |
+
4. Extracts NormalizedReverseMatch list from each reverse-search.
|
| 10 |
+
5. Preserves every ProviderResult as Evidence (raw + normalized).
|
| 11 |
+
6. Delegates confidence scoring to confidence.engine.
|
| 12 |
+
7. Delegates conflict detection to confidence.conflicts.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Dict, List
|
| 18 |
+
|
| 19 |
+
from confidence.engine import ConfidenceEngine
|
| 20 |
+
from confidence.conflicts import ConflictDetector
|
| 21 |
+
from models.reports import (
|
| 22 |
+
UnifiedFaceReport,
|
| 23 |
+
FaceDetection,
|
| 24 |
+
FaceMatch,
|
| 25 |
+
Evidence,
|
| 26 |
+
ReportMetadata,
|
| 27 |
+
)
|
| 28 |
+
from normalization.schema import (
|
| 29 |
+
NormalizedBox,
|
| 30 |
+
NormalizedMatch,
|
| 31 |
+
NormalizedScrapeImage,
|
| 32 |
+
NormalizedReverseMatch,
|
| 33 |
+
)
|
| 34 |
+
from pipeline.postprocessing import ResultPostprocessor
|
| 35 |
+
from providers.base import ProviderResult
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ReportMerger:
|
| 39 |
+
"""Merges provider results into a UnifiedFaceReport."""
|
| 40 |
+
|
| 41 |
+
def __init__(
|
| 42 |
+
self,
|
| 43 |
+
confidence_engine: ConfidenceEngine,
|
| 44 |
+
conflict_detector: ConflictDetector,
|
| 45 |
+
) -> None:
|
| 46 |
+
self._confidence = confidence_engine
|
| 47 |
+
self._conflicts = conflict_detector
|
| 48 |
+
|
| 49 |
+
def merge(
|
| 50 |
+
self,
|
| 51 |
+
results: Dict[str, ProviderResult],
|
| 52 |
+
image_hash: str,
|
| 53 |
+
job_id: str,
|
| 54 |
+
total_elapsed_ms: float,
|
| 55 |
+
) -> UnifiedFaceReport:
|
| 56 |
+
boxes = self._collect_boxes(results)
|
| 57 |
+
matches = self._collect_matches(results)
|
| 58 |
+
scraped = self._collect_scraped(results)
|
| 59 |
+
reverse_matches = self._collect_reverse(results)
|
| 60 |
+
evidence = self._build_evidence(results)
|
| 61 |
+
|
| 62 |
+
# Build FaceDetection objects with confidence
|
| 63 |
+
detections: List[FaceDetection] = []
|
| 64 |
+
for i, nbox in enumerate(boxes):
|
| 65 |
+
score = self._confidence.score_detection(nbox, results)
|
| 66 |
+
detections.append(FaceDetection(
|
| 67 |
+
box={"x": nbox.x, "y": nbox.y, "w": nbox.w, "h": nbox.h},
|
| 68 |
+
confidence=score,
|
| 69 |
+
landmarks=nbox.landmarks,
|
| 70 |
+
detected_by=[nbox.detector],
|
| 71 |
+
))
|
| 72 |
+
|
| 73 |
+
# Build FaceMatch objects
|
| 74 |
+
face_matches: List[FaceMatch] = []
|
| 75 |
+
for nm in matches:
|
| 76 |
+
score = self._confidence.score_match(nm)
|
| 77 |
+
face_matches.append(FaceMatch(
|
| 78 |
+
query_face_index=nm.query_face_index,
|
| 79 |
+
best_match=nm.best_match,
|
| 80 |
+
confidence=score,
|
| 81 |
+
distances=nm.distances,
|
| 82 |
+
))
|
| 83 |
+
|
| 84 |
+
# Detect cross-provider conflicts
|
| 85 |
+
conflicts = self._conflicts.detect(results, boxes, matches)
|
| 86 |
+
|
| 87 |
+
# Metadata
|
| 88 |
+
succeeded = [r.provider for r in results.values() if r.success]
|
| 89 |
+
failed = [r.provider for r in results.values() if not r.success]
|
| 90 |
+
metadata = ReportMetadata(
|
| 91 |
+
job_id=job_id,
|
| 92 |
+
image_hash=image_hash,
|
| 93 |
+
total_elapsed_ms=total_elapsed_ms,
|
| 94 |
+
providers_invoked=list(results.keys()),
|
| 95 |
+
providers_succeeded=succeeded,
|
| 96 |
+
providers_failed=failed,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
report = UnifiedFaceReport(
|
| 100 |
+
metadata=metadata,
|
| 101 |
+
detections=detections,
|
| 102 |
+
matches=face_matches,
|
| 103 |
+
scraped_images=[s.__dict__ for s in scraped],
|
| 104 |
+
reverse_matches=[r.__dict__ for r in reverse_matches],
|
| 105 |
+
evidence=evidence,
|
| 106 |
+
conflicts=conflicts,
|
| 107 |
+
overall_confidence=self._confidence.score_overall(report_detections=detections,
|
| 108 |
+
matches=face_matches,
|
| 109 |
+
conflicts=conflicts),
|
| 110 |
+
)
|
| 111 |
+
# Patch: rebuild metadata on report (already set above)
|
| 112 |
+
report.metadata = metadata
|
| 113 |
+
return report
|
| 114 |
+
|
| 115 |
+
# ------------------------------------------------------------------ #
|
| 116 |
+
# Collectors
|
| 117 |
+
# ------------------------------------------------------------------ #
|
| 118 |
+
def _collect_boxes(self, results: Dict[str, ProviderResult]) -> List[NormalizedBox]:
|
| 119 |
+
out: List[NormalizedBox] = []
|
| 120 |
+
for r in results.values():
|
| 121 |
+
if not r.success:
|
| 122 |
+
continue
|
| 123 |
+
norm = r.normalized
|
| 124 |
+
if r.capability.value != "detection":
|
| 125 |
+
continue
|
| 126 |
+
boxes = norm.get("boxes", [])
|
| 127 |
+
confs = norm.get("confidences", [1.0] * len(boxes))
|
| 128 |
+
lms = norm.get("landmarks")
|
| 129 |
+
for i, b in enumerate(boxes):
|
| 130 |
+
lm = lms[i] if isinstance(lms, list) and i < len(lms) else (lms if isinstance(lms, dict) else None)
|
| 131 |
+
out.append(NormalizedBox(
|
| 132 |
+
x=b["x"], y=b["y"], w=b["w"], h=b["h"],
|
| 133 |
+
confidence=float(confs[i]) if i < len(confs) else 1.0,
|
| 134 |
+
detector=r.provider,
|
| 135 |
+
landmarks=lm,
|
| 136 |
+
))
|
| 137 |
+
return out
|
| 138 |
+
|
| 139 |
+
def _collect_matches(self, results: Dict[str, ProviderResult]) -> List[NormalizedMatch]:
|
| 140 |
+
out: List[NormalizedMatch] = []
|
| 141 |
+
for r in results.values():
|
| 142 |
+
if not r.success or r.capability.value != "recognition":
|
| 143 |
+
continue
|
| 144 |
+
for m in r.normalized.get("matches", []):
|
| 145 |
+
out.append(NormalizedMatch(
|
| 146 |
+
query_face_index=m.get("query_face_index", 0),
|
| 147 |
+
best_match=m.get("best_match"),
|
| 148 |
+
distance=m.get("distance", 1.0),
|
| 149 |
+
distances=m.get("distances", {}),
|
| 150 |
+
recognizer=r.provider,
|
| 151 |
+
))
|
| 152 |
+
return out
|
| 153 |
+
|
| 154 |
+
def _collect_scraped(self, results: Dict[str, ProviderResult]) -> List[NormalizedScrapeImage]:
|
| 155 |
+
out: List[NormalizedScrapeImage] = []
|
| 156 |
+
for r in results.values():
|
| 157 |
+
if not r.success or r.capability.value != "scraping":
|
| 158 |
+
continue
|
| 159 |
+
for img in r.normalized.get("images", []):
|
| 160 |
+
out.append(NormalizedScrapeImage(
|
| 161 |
+
url=img.get("url", ""),
|
| 162 |
+
alt=img.get("alt", ""),
|
| 163 |
+
source_page=img.get("source_page", ""),
|
| 164 |
+
scraper=r.provider,
|
| 165 |
+
width=img.get("width"),
|
| 166 |
+
height=img.get("height"),
|
| 167 |
+
))
|
| 168 |
+
# Dedupe
|
| 169 |
+
deduped = ResultPostprocessor.dedupe_images([s.__dict__ for s in out])
|
| 170 |
+
return [NormalizedScrapeImage(**d) for d in deduped]
|
| 171 |
+
|
| 172 |
+
def _collect_reverse(self, results: Dict[str, ProviderResult]) -> List[NormalizedReverseMatch]:
|
| 173 |
+
out: List[NormalizedReverseMatch] = []
|
| 174 |
+
for r in results.values():
|
| 175 |
+
if not r.success or r.capability.value != "reverse_search":
|
| 176 |
+
continue
|
| 177 |
+
for m in r.normalized.get("results", []):
|
| 178 |
+
out.append(NormalizedReverseMatch(
|
| 179 |
+
image_url=m.get("image_url", ""),
|
| 180 |
+
source_page=m.get("source_page", ""),
|
| 181 |
+
title=m.get("title", ""),
|
| 182 |
+
snippet=m.get("snippet", ""),
|
| 183 |
+
thumbnail=m.get("thumbnail", ""),
|
| 184 |
+
provider=r.provider,
|
| 185 |
+
))
|
| 186 |
+
return out
|
| 187 |
+
|
| 188 |
+
def _build_evidence(self, results: Dict[str, ProviderResult]) -> List[Evidence]:
|
| 189 |
+
out: List[Evidence] = []
|
| 190 |
+
for r in results.values():
|
| 191 |
+
out.append(Evidence(
|
| 192 |
+
provider=r.provider,
|
| 193 |
+
capability=r.capability.value,
|
| 194 |
+
raw=r.raw,
|
| 195 |
+
normalized=r.normalized,
|
| 196 |
+
elapsed_ms=r.elapsed_ms,
|
| 197 |
+
success=r.success,
|
| 198 |
+
error=r.error,
|
| 199 |
+
error_type=r.error_type,
|
| 200 |
+
metadata=r.metadata,
|
| 201 |
+
))
|
| 202 |
+
return out
|
download/face-intel/normalization/schema.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Normalization-internal DTOs.
|
| 3 |
+
|
| 4 |
+
These are intermediate objects used by the merger before being promoted
|
| 5 |
+
to the cross-layer models in models/reports.py.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class NormalizedBox:
|
| 16 |
+
"""One face box from one detector."""
|
| 17 |
+
x: int
|
| 18 |
+
y: int
|
| 19 |
+
w: int
|
| 20 |
+
h: int
|
| 21 |
+
confidence: float
|
| 22 |
+
detector: str
|
| 23 |
+
landmarks: Optional[dict] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class NormalizedMatch:
|
| 28 |
+
"""One recognition match against the gallery."""
|
| 29 |
+
query_face_index: int
|
| 30 |
+
best_match: Optional[str]
|
| 31 |
+
distance: float
|
| 32 |
+
distances: Dict[str, float]
|
| 33 |
+
recognizer: str
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class NormalizedScrapeImage:
|
| 38 |
+
url: str
|
| 39 |
+
alt: str
|
| 40 |
+
source_page: str
|
| 41 |
+
scraper: str
|
| 42 |
+
width: Optional[int] = None
|
| 43 |
+
height: Optional[int] = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class NormalizedReverseMatch:
|
| 48 |
+
image_url: str
|
| 49 |
+
source_page: str
|
| 50 |
+
title: str
|
| 51 |
+
snippet: str
|
| 52 |
+
thumbnail: str
|
| 53 |
+
provider: str
|
download/face-intel/orchestrator/__init__.py
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Orchestrator package — async fan-out across providers.
|
| 3 |
+
|
| 4 |
+
After the refactor, the orchestrator receives its dependencies
|
| 5 |
+
(registry, cache, metrics, settings) through its constructor. No
|
| 6 |
+
globals, no module-level state.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from orchestrator.runner import Orchestrator
|
| 10 |
+
from orchestrator.retry import RetryPolicy, with_retry_sync, with_retry_async
|
| 11 |
+
from orchestrator.health import HealthMonitor
|
| 12 |
+
|
| 13 |
+
__all__ = ["Orchestrator", "RetryPolicy", "with_retry_sync", "with_retry_async", "HealthMonitor"]
|
download/face-intel/orchestrator/health.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Health monitor — wraps HealthMetrics + scheduled checks.
|
| 3 |
+
|
| 4 |
+
For now this is a thin wrapper; future work can add a background task
|
| 5 |
+
that pings providers periodically.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import List
|
| 11 |
+
|
| 12 |
+
from metrics.health_metrics import HealthMetrics
|
| 13 |
+
from providers.base import Provider
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class HealthMonitor:
|
| 17 |
+
"""Reads/writes per-provider health and exposes a circuit-breaker gate."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, metrics: HealthMetrics) -> None:
|
| 20 |
+
self._metrics = metrics
|
| 21 |
+
|
| 22 |
+
def record_success(self, provider_name: str, latency_ms: float) -> None:
|
| 23 |
+
self._metrics.record_success(provider_name, latency_ms)
|
| 24 |
+
|
| 25 |
+
def record_failure(self, provider_name: str) -> None:
|
| 26 |
+
self._metrics.record_failure(provider_name)
|
| 27 |
+
|
| 28 |
+
def is_available(self, provider_name: str) -> bool:
|
| 29 |
+
"""Returns False if the circuit breaker is open for this provider."""
|
| 30 |
+
return not self._metrics.is_circuit_open(provider_name)
|
| 31 |
+
|
| 32 |
+
def filter_healthy(self, providers: List[Provider]) -> List[Provider]:
|
| 33 |
+
"""Drop providers whose circuit is open."""
|
| 34 |
+
return [p for p in providers if self.is_available(p.name)]
|
| 35 |
+
|
| 36 |
+
def snapshot(self) -> list[dict]:
|
| 37 |
+
return self._metrics.snapshot()
|
download/face-intel/orchestrator/retry.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Retry policy with exponential backoff + jitter.
|
| 3 |
+
|
| 4 |
+
Used by the orchestrator to wrap provider invocations.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import random
|
| 11 |
+
import time
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from typing import Awaitable, Callable, Tuple, Type
|
| 14 |
+
|
| 15 |
+
from loguru import logger
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class RetryPolicy:
|
| 20 |
+
"""Exponential backoff with full jitter."""
|
| 21 |
+
max_attempts: int = 3
|
| 22 |
+
initial_backoff_seconds: float = 0.5
|
| 23 |
+
max_backoff_seconds: float = 8.0
|
| 24 |
+
retriable_exceptions: Tuple[Type[BaseException], ...] = (
|
| 25 |
+
TimeoutError, ConnectionError, OSError,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def backoff(self, attempt: int) -> float:
|
| 29 |
+
"""Compute backoff for the given attempt (1-indexed)."""
|
| 30 |
+
delay = self.initial_backoff_seconds * (2 ** (attempt - 1))
|
| 31 |
+
delay = min(delay, self.max_backoff_seconds)
|
| 32 |
+
# full jitter
|
| 33 |
+
return random.uniform(0, delay)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def with_retry_sync(
|
| 37 |
+
fn: Callable,
|
| 38 |
+
policy: RetryPolicy,
|
| 39 |
+
label: str = "",
|
| 40 |
+
on_retry: Callable[[int, Exception], None] | None = None,
|
| 41 |
+
):
|
| 42 |
+
"""Synchronous retry wrapper."""
|
| 43 |
+
attempt = 0
|
| 44 |
+
last_exc: Exception | None = None
|
| 45 |
+
while attempt < policy.max_attempts:
|
| 46 |
+
try:
|
| 47 |
+
attempt += 1
|
| 48 |
+
return fn()
|
| 49 |
+
except policy.retriable_exceptions as e:
|
| 50 |
+
last_exc = e
|
| 51 |
+
if attempt >= policy.max_attempts:
|
| 52 |
+
break
|
| 53 |
+
delay = policy.backoff(attempt)
|
| 54 |
+
if on_retry:
|
| 55 |
+
on_retry(attempt, e)
|
| 56 |
+
logger.warning(
|
| 57 |
+
f"[retry] {label} attempt {attempt}/{policy.max_attempts} "
|
| 58 |
+
f"failed: {e}; sleeping {delay:.2f}s"
|
| 59 |
+
)
|
| 60 |
+
time.sleep(delay)
|
| 61 |
+
raise last_exc # type: ignore
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def with_retry_async(
|
| 65 |
+
fn: Callable[[], Awaitable],
|
| 66 |
+
policy: RetryPolicy,
|
| 67 |
+
label: str = "",
|
| 68 |
+
on_retry: Callable[[int, Exception], None] | None = None,
|
| 69 |
+
):
|
| 70 |
+
"""Asynchronous retry wrapper."""
|
| 71 |
+
attempt = 0
|
| 72 |
+
last_exc: Exception | None = None
|
| 73 |
+
while attempt < policy.max_attempts:
|
| 74 |
+
try:
|
| 75 |
+
attempt += 1
|
| 76 |
+
return await fn()
|
| 77 |
+
except policy.retriable_exceptions as e:
|
| 78 |
+
last_exc = e
|
| 79 |
+
if attempt >= policy.max_attempts:
|
| 80 |
+
break
|
| 81 |
+
delay = policy.backoff(attempt)
|
| 82 |
+
if on_retry:
|
| 83 |
+
on_retry(attempt, e)
|
| 84 |
+
logger.warning(
|
| 85 |
+
f"[retry] {label} attempt {attempt}/{policy.max_attempts} "
|
| 86 |
+
f"failed: {e}; sleeping {delay:.2f}s"
|
| 87 |
+
)
|
| 88 |
+
await asyncio.sleep(delay)
|
| 89 |
+
raise last_exc # type: ignore
|
download/face-intel/orchestrator/runner.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Orchestrator — async fan-out across providers.
|
| 3 |
+
|
| 4 |
+
Responsibilities:
|
| 5 |
+
- Receive a PipelineOutput (never raw user input).
|
| 6 |
+
- For each target provider:
|
| 7 |
+
* Skip if circuit breaker is open.
|
| 8 |
+
* Check cache; if hit, return cached ProviderResult.
|
| 9 |
+
* Otherwise invoke provider (sync → asyncio.to_thread).
|
| 10 |
+
* Apply retry policy.
|
| 11 |
+
* Record metrics (latency, success/failure, retry count).
|
| 12 |
+
* Cache successful results.
|
| 13 |
+
- Return a dict mapping provider name → ProviderResult.
|
| 14 |
+
|
| 15 |
+
All dependencies (registry, cache, metrics, health, retry policy) are
|
| 16 |
+
injected via the constructor.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
from typing import Awaitable, Callable, Dict, Iterable, Optional
|
| 23 |
+
|
| 24 |
+
from loguru import logger
|
| 25 |
+
|
| 26 |
+
from config.settings import Settings
|
| 27 |
+
from metrics.collector import MetricsCollector
|
| 28 |
+
from orchestrator.health import HealthMonitor
|
| 29 |
+
from orchestrator.retry import RetryPolicy, with_retry_sync
|
| 30 |
+
from pipeline.feature_extraction import PipelineOutput
|
| 31 |
+
from providers.base import Provider, ProviderCapability, ProviderResult
|
| 32 |
+
from providers.registry import ProviderRegistry
|
| 33 |
+
from storage.cache import Cache
|
| 34 |
+
from utils.logging import execution_context, new_execution_id
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class Orchestrator:
|
| 38 |
+
"""Async fan-out across providers. Injectable."""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
registry: ProviderRegistry,
|
| 43 |
+
cache: Cache,
|
| 44 |
+
metrics: MetricsCollector,
|
| 45 |
+
health: HealthMonitor,
|
| 46 |
+
settings: Settings,
|
| 47 |
+
retry_policy: Optional[RetryPolicy] = None,
|
| 48 |
+
) -> None:
|
| 49 |
+
self._registry = registry
|
| 50 |
+
self._cache = cache
|
| 51 |
+
self._metrics = metrics
|
| 52 |
+
self._health = health
|
| 53 |
+
self._settings = settings
|
| 54 |
+
self._retry = retry_policy or RetryPolicy(
|
| 55 |
+
max_attempts=settings.retry_max_attempts,
|
| 56 |
+
initial_backoff_seconds=settings.retry_initial_backoff_seconds,
|
| 57 |
+
max_backoff_seconds=settings.retry_max_backoff_seconds,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# ------------------------------------------------------------------ #
|
| 61 |
+
# Public API
|
| 62 |
+
# ------------------------------------------------------------------ #
|
| 63 |
+
async def run(
|
| 64 |
+
self,
|
| 65 |
+
pipeline_output: PipelineOutput,
|
| 66 |
+
capabilities: Iterable[ProviderCapability],
|
| 67 |
+
provider_whitelist: Optional[list[str]] = None,
|
| 68 |
+
execution_id: Optional[str] = None,
|
| 69 |
+
) -> Dict[str, ProviderResult]:
|
| 70 |
+
"""Fan out across all providers matching the given capabilities.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
pipeline_output: normalized image + face crops.
|
| 74 |
+
capabilities: which capability buckets to invoke.
|
| 75 |
+
provider_whitelist: optional list of provider names; if set,
|
| 76 |
+
only those providers are invoked.
|
| 77 |
+
execution_id: optional trace id for structured logging.
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
dict mapping provider name → ProviderResult.
|
| 81 |
+
"""
|
| 82 |
+
eid = execution_id or new_execution_id()
|
| 83 |
+
targets: list[Provider] = []
|
| 84 |
+
for cap in capabilities:
|
| 85 |
+
for p in self._registry.list_by_capability(cap):
|
| 86 |
+
if provider_whitelist and p.name not in provider_whitelist:
|
| 87 |
+
continue
|
| 88 |
+
if not self._health.is_available(p.name):
|
| 89 |
+
logger.info(f"[orchestrator] skipping {p.name}: circuit open")
|
| 90 |
+
continue
|
| 91 |
+
targets.append(p)
|
| 92 |
+
|
| 93 |
+
if not targets:
|
| 94 |
+
logger.warning("[orchestrator] no providers to invoke")
|
| 95 |
+
return {}
|
| 96 |
+
|
| 97 |
+
semaphore = asyncio.Semaphore(self._settings.orchestrator_max_concurrency)
|
| 98 |
+
|
| 99 |
+
async def _wrapped(provider: Provider) -> tuple[str, ProviderResult]:
|
| 100 |
+
async with semaphore:
|
| 101 |
+
return provider.name, await self._invoke_one(provider, pipeline_output, eid)
|
| 102 |
+
|
| 103 |
+
tasks = [_wrapped(p) for p in targets]
|
| 104 |
+
gathered = await asyncio.gather(*tasks, return_exceptions=False)
|
| 105 |
+
return dict(gathered)
|
| 106 |
+
|
| 107 |
+
# ------------------------------------------------------------------ #
|
| 108 |
+
# Per-provider invocation (with cache + retry + metrics)
|
| 109 |
+
# ------------------------------------------------------------------ #
|
| 110 |
+
async def _invoke_one(
|
| 111 |
+
self,
|
| 112 |
+
provider: Provider,
|
| 113 |
+
pipeline_output: PipelineOutput,
|
| 114 |
+
execution_id: str,
|
| 115 |
+
) -> ProviderResult:
|
| 116 |
+
cache_key = self._cache_key(provider, pipeline_output)
|
| 117 |
+
|
| 118 |
+
# Cache hit?
|
| 119 |
+
if self._settings.cache_enabled:
|
| 120 |
+
cached = self._cache.get(cache_key)
|
| 121 |
+
if cached is not None:
|
| 122 |
+
self._metrics.counters.inc("cache.hits")
|
| 123 |
+
logger.debug(f"[orchestrator] cache hit for {provider.name}")
|
| 124 |
+
cached.metadata["cache_hit"] = True
|
| 125 |
+
return cached
|
| 126 |
+
self._metrics.counters.inc("cache.misses")
|
| 127 |
+
|
| 128 |
+
# Invoke with retry
|
| 129 |
+
retry_count = 0
|
| 130 |
+
|
| 131 |
+
def _on_retry(attempt: int, exc: Exception) -> None:
|
| 132 |
+
nonlocal retry_count
|
| 133 |
+
retry_count = attempt
|
| 134 |
+
self._metrics.providers.record_retry(provider.name)
|
| 135 |
+
self._metrics.counters.inc(f"retries.{provider.name}")
|
| 136 |
+
|
| 137 |
+
with execution_context(execution_id=execution_id,
|
| 138 |
+
provider_id=provider.name,
|
| 139 |
+
retry_count=retry_count):
|
| 140 |
+
logger.info(f"[orchestrator] invoking {provider.name}")
|
| 141 |
+
|
| 142 |
+
def _call_sync() -> ProviderResult:
|
| 143 |
+
return with_retry_sync(
|
| 144 |
+
lambda: provider.execute(pipeline_output),
|
| 145 |
+
self._retry,
|
| 146 |
+
label=provider.name,
|
| 147 |
+
on_retry=_on_retry,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
try:
|
| 151 |
+
result = await asyncio.wait_for(
|
| 152 |
+
asyncio.to_thread(_call_sync),
|
| 153 |
+
timeout=self._settings.orchestrator_timeout_seconds,
|
| 154 |
+
)
|
| 155 |
+
except asyncio.TimeoutError:
|
| 156 |
+
result = ProviderResult(
|
| 157 |
+
provider=provider.name,
|
| 158 |
+
capability=provider.capability,
|
| 159 |
+
success=False,
|
| 160 |
+
elapsed_ms=self._settings.orchestrator_timeout_seconds * 1000,
|
| 161 |
+
error="Orchestrator timeout",
|
| 162 |
+
error_type="TimeoutError",
|
| 163 |
+
retry_count=retry_count,
|
| 164 |
+
)
|
| 165 |
+
except Exception as e:
|
| 166 |
+
result = ProviderResult(
|
| 167 |
+
provider=provider.name,
|
| 168 |
+
capability=provider.capability,
|
| 169 |
+
success=False,
|
| 170 |
+
elapsed_ms=0.0,
|
| 171 |
+
error=str(e),
|
| 172 |
+
error_type=type(e).__name__,
|
| 173 |
+
retry_count=retry_count,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
result.retry_count = retry_count
|
| 177 |
+
|
| 178 |
+
# Record metrics
|
| 179 |
+
self._metrics.providers.record_invocation(provider.name)
|
| 180 |
+
if result.success:
|
| 181 |
+
self._metrics.providers.record_success(provider.name, result.elapsed_ms)
|
| 182 |
+
self._health.record_success(provider.name, result.elapsed_ms)
|
| 183 |
+
self._metrics.timings.record(f"provider.{provider.name}", result.elapsed_ms)
|
| 184 |
+
# Cache
|
| 185 |
+
if self._settings.cache_enabled:
|
| 186 |
+
self._cache.set(cache_key, result)
|
| 187 |
+
else:
|
| 188 |
+
self._metrics.providers.record_failure(provider.name, result.error or "")
|
| 189 |
+
self._health.record_failure(provider.name)
|
| 190 |
+
self._metrics.counters.inc(f"failures.{provider.name}")
|
| 191 |
+
|
| 192 |
+
return result
|
| 193 |
+
|
| 194 |
+
# ------------------------------------------------------------------ #
|
| 195 |
+
# Cache key
|
| 196 |
+
# ------------------------------------------------------------------ #
|
| 197 |
+
@staticmethod
|
| 198 |
+
def _cache_key(provider: Provider, pipeline_output: PipelineOutput) -> str:
|
| 199 |
+
return f"{provider.name}:{pipeline_output.image_hash}"
|
download/face-intel/pipeline/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pipeline package — local processing stages that run BEFORE the
|
| 3 |
+
orchestrator fans out to providers.
|
| 4 |
+
|
| 5 |
+
Flow:
|
| 6 |
+
raw user input (bytes / URL / base64)
|
| 7 |
+
↓
|
| 8 |
+
validation.py — InputValidator (reject bad input)
|
| 9 |
+
↓
|
| 10 |
+
preprocessing.py — ImagePreprocessor (decode, resize, normalize)
|
| 11 |
+
↓
|
| 12 |
+
hashing.py — ImageHasher (SHA-256 cache key)
|
| 13 |
+
↓
|
| 14 |
+
feature_extraction.py — FeatureExtractor (face crops, default-detector boxes)
|
| 15 |
+
↓
|
| 16 |
+
PipelineOutput (consumed by orchestrator)
|
| 17 |
+
|
| 18 |
+
The orchestrator receives a PipelineOutput, never raw user input.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from pipeline.validation import InputValidator, ValidationResult
|
| 22 |
+
from pipeline.preprocessing import ImagePreprocessor, PreprocessedImage
|
| 23 |
+
from pipeline.hashing import ImageHasher
|
| 24 |
+
from pipeline.feature_extraction import FeatureExtractor, PipelineOutput
|
| 25 |
+
from pipeline.postprocessing import ResultPostprocessor
|
| 26 |
+
|
| 27 |
+
__all__ = [
|
| 28 |
+
"InputValidator",
|
| 29 |
+
"ValidationResult",
|
| 30 |
+
"ImagePreprocessor",
|
| 31 |
+
"PreprocessedImage",
|
| 32 |
+
"ImageHasher",
|
| 33 |
+
"FeatureExtractor",
|
| 34 |
+
"PipelineOutput",
|
| 35 |
+
"ResultPostprocessor",
|
| 36 |
+
]
|
download/face-intel/pipeline/feature_extraction.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Feature extraction — uses a default detection provider to pre-locate
|
| 3 |
+
faces and produce crops before the orchestrator fans out.
|
| 4 |
+
|
| 5 |
+
The orchestrator receives a PipelineOutput that already contains:
|
| 6 |
+
- preprocessed image
|
| 7 |
+
- image hash (cache key)
|
| 8 |
+
- face crops
|
| 9 |
+
- bounding boxes
|
| 10 |
+
|
| 11 |
+
This means recognition / reverse-search providers don't each have to
|
| 12 |
+
re-detect faces independently.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
from typing import List, Optional
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
from loguru import logger
|
| 22 |
+
|
| 23 |
+
from providers.base import Provider, ProviderResult
|
| 24 |
+
from utils.image import BBox, crop_face
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class FaceCrop:
|
| 29 |
+
"""One pre-extracted face crop."""
|
| 30 |
+
image: np.ndarray
|
| 31 |
+
box: dict
|
| 32 |
+
confidence: float = 1.0
|
| 33 |
+
detector: str = ""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class PipelineOutput:
|
| 38 |
+
"""The normalized payload the orchestrator consumes.
|
| 39 |
+
|
| 40 |
+
Optional fields (`gallery`, `scrape_url`) may be attached by services
|
| 41 |
+
that need to pass extra context to specific providers. Recognition
|
| 42 |
+
providers read `gallery`; scraper providers read `scrape_url`.
|
| 43 |
+
"""
|
| 44 |
+
image: np.ndarray
|
| 45 |
+
image_hash: str
|
| 46 |
+
width: int
|
| 47 |
+
height: int
|
| 48 |
+
source: str
|
| 49 |
+
face_crops: List[FaceCrop] = field(default_factory=list)
|
| 50 |
+
primary_detector: str = ""
|
| 51 |
+
# Optional context attached by services (kept here so the orchestrator
|
| 52 |
+
# passes a single object through to every provider).
|
| 53 |
+
gallery: Optional[dict] = None
|
| 54 |
+
scrape_url: Optional[str] = None
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def num_faces(self) -> int:
|
| 58 |
+
return len(self.face_crops)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class FeatureExtractor:
|
| 62 |
+
"""Uses a detection provider to pre-extract face crops."""
|
| 63 |
+
|
| 64 |
+
def __init__(self, detector: Optional[Provider] = None) -> None:
|
| 65 |
+
self._detector = detector
|
| 66 |
+
|
| 67 |
+
def set_detector(self, provider: Provider) -> None:
|
| 68 |
+
self._detector = provider
|
| 69 |
+
|
| 70 |
+
def extract(self, image: np.ndarray, image_hash: str,
|
| 71 |
+
width: int, height: int, source: str) -> PipelineOutput:
|
| 72 |
+
crops: List[FaceCrop] = []
|
| 73 |
+
detector_name = ""
|
| 74 |
+
|
| 75 |
+
if self._detector is not None and self._detector.is_available():
|
| 76 |
+
try:
|
| 77 |
+
result: ProviderResult = self._detector.execute(image)
|
| 78 |
+
if result.success and result.normalized.get("boxes"):
|
| 79 |
+
detector_name = result.provider
|
| 80 |
+
boxes = result.normalized["boxes"]
|
| 81 |
+
confs = result.normalized.get("confidences", [1.0] * len(boxes))
|
| 82 |
+
for box_dict, conf in zip(boxes, confs):
|
| 83 |
+
bbox = BBox(box_dict["x"], box_dict["y"],
|
| 84 |
+
box_dict["w"], box_dict["h"])
|
| 85 |
+
crop = crop_face(image, bbox, margin=0.2)
|
| 86 |
+
crops.append(FaceCrop(
|
| 87 |
+
image=crop,
|
| 88 |
+
box=box_dict,
|
| 89 |
+
confidence=float(conf),
|
| 90 |
+
detector=detector_name,
|
| 91 |
+
))
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.warning(f"Feature extraction failed: {e}")
|
| 94 |
+
else:
|
| 95 |
+
logger.debug("No detector available; pipeline output will have 0 face crops.")
|
| 96 |
+
|
| 97 |
+
return PipelineOutput(
|
| 98 |
+
image=image,
|
| 99 |
+
image_hash=image_hash,
|
| 100 |
+
width=width,
|
| 101 |
+
height=height,
|
| 102 |
+
source=source,
|
| 103 |
+
face_crops=crops,
|
| 104 |
+
primary_detector=detector_name,
|
| 105 |
+
)
|
download/face-intel/pipeline/hashing.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image hashing — produces a stable cache key for a preprocessed image.
|
| 3 |
+
|
| 4 |
+
The hash is computed on the JPEG-encoded bytes (quality 90) so that
|
| 5 |
+
visually identical inputs collapse to the same key.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import hashlib
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ImageHasher:
|
| 17 |
+
"""SHA-256 over normalized JPEG bytes."""
|
| 18 |
+
|
| 19 |
+
@staticmethod
|
| 20 |
+
def hash(img: np.ndarray, quality: int = 90) -> str:
|
| 21 |
+
ok, buffer = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
| 22 |
+
if not ok:
|
| 23 |
+
raise ValueError("Could not encode image for hashing.")
|
| 24 |
+
return hashlib.sha256(buffer.tobytes()).hexdigest()
|
download/face-intel/pipeline/postprocessing.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Post-processing — applies final cleanup to provider results before
|
| 3 |
+
they enter normalization.
|
| 4 |
+
|
| 5 |
+
Examples:
|
| 6 |
+
- de-duplicate scraped image URLs
|
| 7 |
+
- clamp bounding boxes to image bounds
|
| 8 |
+
- strip PII from raw responses (placeholder for future policy hooks)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Any, List
|
| 14 |
+
|
| 15 |
+
from loguru import logger
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ResultPostprocessor:
|
| 19 |
+
"""Cleans provider outputs before normalization."""
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def dedupe_images(images: List[dict]) -> List[dict]:
|
| 23 |
+
"""Remove duplicate image URLs."""
|
| 24 |
+
seen: set[str] = set()
|
| 25 |
+
out: List[dict] = []
|
| 26 |
+
for img in images:
|
| 27 |
+
url = img.get("url") or img.get("image_url")
|
| 28 |
+
if not url or url in seen:
|
| 29 |
+
continue
|
| 30 |
+
seen.add(url)
|
| 31 |
+
out.append(img)
|
| 32 |
+
return out
|
| 33 |
+
|
| 34 |
+
@staticmethod
|
| 35 |
+
def clamp_boxes(boxes: List[dict], width: int, height: int) -> List[dict]:
|
| 36 |
+
"""Clamp bounding boxes to image bounds."""
|
| 37 |
+
out: List[dict] = []
|
| 38 |
+
for b in boxes:
|
| 39 |
+
x = max(0, min(b["x"], width - 1))
|
| 40 |
+
y = max(0, min(b["y"], height - 1))
|
| 41 |
+
x2 = max(0, min(b["x"] + b["w"], width))
|
| 42 |
+
y2 = max(0, min(b["y"] + b["h"], height))
|
| 43 |
+
out.append({"x": x, "y": y, "w": max(0, x2 - x), "h": max(0, y2 - y)})
|
| 44 |
+
return out
|
| 45 |
+
|
| 46 |
+
@staticmethod
|
| 47 |
+
def filter_low_confidence(boxes: List[dict], confs: List[float],
|
| 48 |
+
threshold: float = 0.5) -> tuple[List[dict], List[float]]:
|
| 49 |
+
"""Drop detections below confidence threshold."""
|
| 50 |
+
out_boxes, out_confs = [], []
|
| 51 |
+
for b, c in zip(boxes, confs):
|
| 52 |
+
if c >= threshold:
|
| 53 |
+
out_boxes.append(b)
|
| 54 |
+
out_confs.append(c)
|
| 55 |
+
return out_boxes, out_confs
|
download/face-intel/pipeline/preprocessing.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Preprocessing — decode, resize, color-convert.
|
| 3 |
+
|
| 4 |
+
Takes raw bytes (or URL) and produces a normalized BGR numpy array
|
| 5 |
+
suitable for every downstream provider.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
from loguru import logger
|
| 15 |
+
|
| 16 |
+
from utils.image import bytes_to_numpy, url_to_numpy, resize_with_aspect
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class PreprocessedImage:
|
| 21 |
+
"""Output of preprocessing — the canonical image object passed downstream."""
|
| 22 |
+
image: np.ndarray # BGR uint8 HxWx3
|
| 23 |
+
width: int
|
| 24 |
+
height: int
|
| 25 |
+
channels: int = 3
|
| 26 |
+
source: str = "" # "url" | "base64" | "bytes"
|
| 27 |
+
resized: bool = False # True if downscaled to fit max_dim
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ImagePreprocessor:
|
| 31 |
+
"""Decodes + resizes inbound images."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, max_dim: int = 1024) -> None:
|
| 34 |
+
self._max_dim = max_dim
|
| 35 |
+
|
| 36 |
+
def from_bytes(self, data: bytes, source: str = "bytes") -> PreprocessedImage:
|
| 37 |
+
img = bytes_to_numpy(data)
|
| 38 |
+
return self._finalize(img, source)
|
| 39 |
+
|
| 40 |
+
def from_url(self, url: str, timeout: int = 15) -> PreprocessedImage:
|
| 41 |
+
img = url_to_numpy(url, timeout=timeout)
|
| 42 |
+
return self._finalize(img, "url")
|
| 43 |
+
|
| 44 |
+
def from_numpy(self, img: np.ndarray, source: str = "in_memory") -> PreprocessedImage:
|
| 45 |
+
return self._finalize(img, source)
|
| 46 |
+
|
| 47 |
+
def _finalize(self, img: np.ndarray, source: str) -> PreprocessedImage:
|
| 48 |
+
# Ensure 3 channels
|
| 49 |
+
if img.ndim == 2:
|
| 50 |
+
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) if (cv2 := __import__("cv2")) else img
|
| 51 |
+
elif img.shape[2] == 4:
|
| 52 |
+
import cv2
|
| 53 |
+
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
| 54 |
+
|
| 55 |
+
resized = False
|
| 56 |
+
h, w = img.shape[:2]
|
| 57 |
+
if max(h, w) > self._max_dim:
|
| 58 |
+
img = resize_with_aspect(img, max_dim=self._max_dim)
|
| 59 |
+
resized = True
|
| 60 |
+
|
| 61 |
+
h, w = img.shape[:2]
|
| 62 |
+
return PreprocessedImage(
|
| 63 |
+
image=img,
|
| 64 |
+
width=w,
|
| 65 |
+
height=h,
|
| 66 |
+
channels=img.shape[2] if img.ndim == 3 else 1,
|
| 67 |
+
source=source,
|
| 68 |
+
resized=resized,
|
| 69 |
+
)
|
download/face-intel/pipeline/validation.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Input validation — rejects malformed requests before any heavy work.
|
| 3 |
+
|
| 4 |
+
Validates:
|
| 5 |
+
- At least one of (image_url, image_base64, image_bytes) is present
|
| 6 |
+
- image_url is a well-formed http(s) URL
|
| 7 |
+
- image_base64 is decodable
|
| 8 |
+
- Image size within configured limits
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import base64
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import Optional
|
| 16 |
+
from urllib.parse import urlparse
|
| 17 |
+
|
| 18 |
+
from loguru import logger
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class ValidationResult:
|
| 23 |
+
valid: bool
|
| 24 |
+
error: Optional[str] = None
|
| 25 |
+
image_bytes: Optional[bytes] = None
|
| 26 |
+
source: str = "" # "url" | "base64" | "bytes"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class InputValidator:
|
| 30 |
+
"""Validates inbound image input."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, max_bytes: int = 20 * 1024 * 1024) -> None:
|
| 33 |
+
"""max_bytes defaults to 20 MB."""
|
| 34 |
+
self._max_bytes = max_bytes
|
| 35 |
+
|
| 36 |
+
def validate(
|
| 37 |
+
self,
|
| 38 |
+
image_url: Optional[str] = None,
|
| 39 |
+
image_base64: Optional[str] = None,
|
| 40 |
+
image_bytes: Optional[bytes] = None,
|
| 41 |
+
) -> ValidationResult:
|
| 42 |
+
# At least one input
|
| 43 |
+
if not any([image_url, image_base64, image_bytes]):
|
| 44 |
+
return ValidationResult(False, error="No image input provided.")
|
| 45 |
+
|
| 46 |
+
# URL validation
|
| 47 |
+
if image_url:
|
| 48 |
+
parsed = urlparse(image_url)
|
| 49 |
+
if parsed.scheme not in ("http", "https"):
|
| 50 |
+
return ValidationResult(False, error=f"Unsupported URL scheme: {parsed.scheme}")
|
| 51 |
+
if not parsed.netloc:
|
| 52 |
+
return ValidationResult(False, error="URL missing host.")
|
| 53 |
+
return ValidationResult(True, source="url")
|
| 54 |
+
|
| 55 |
+
# Base64 validation
|
| 56 |
+
if image_base64:
|
| 57 |
+
try:
|
| 58 |
+
# Strip data URI prefix if present
|
| 59 |
+
raw = image_base64.split(",", 1)[-1]
|
| 60 |
+
decoded = base64.b64decode(raw, validate=True)
|
| 61 |
+
except Exception as e:
|
| 62 |
+
return ValidationResult(False, error=f"Invalid base64: {e}")
|
| 63 |
+
if len(decoded) > self._max_bytes:
|
| 64 |
+
return ValidationResult(
|
| 65 |
+
False,
|
| 66 |
+
error=f"Decoded image exceeds {self._max_bytes} bytes",
|
| 67 |
+
)
|
| 68 |
+
return ValidationResult(True, image_bytes=decoded, source="base64")
|
| 69 |
+
|
| 70 |
+
# Raw bytes
|
| 71 |
+
if image_bytes:
|
| 72 |
+
if len(image_bytes) > self._max_bytes:
|
| 73 |
+
return ValidationResult(
|
| 74 |
+
False,
|
| 75 |
+
error=f"Image exceeds {self._max_bytes} bytes",
|
| 76 |
+
)
|
| 77 |
+
return ValidationResult(True, image_bytes=image_bytes, source="bytes")
|
| 78 |
+
|
| 79 |
+
return ValidationResult(False, error="Unreachable.")
|
download/face-intel/providers/__init__.py
CHANGED
|
@@ -1,132 +1,34 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
-
from __future__ import annotations
|
| 11 |
-
|
| 12 |
-
from loguru import logger
|
| 13 |
-
|
| 14 |
-
from config import settings
|
| 15 |
from providers.base import (
|
| 16 |
Provider,
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
| 23 |
)
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def _safe_register(module_path: str, class_name: str, enable_flag: bool) -> None:
|
| 27 |
-
"""Import + register a provider; swallow ImportError if optional dep missing."""
|
| 28 |
-
if not enable_flag:
|
| 29 |
-
return
|
| 30 |
-
try:
|
| 31 |
-
import importlib
|
| 32 |
-
module = importlib.import_module(module_path)
|
| 33 |
-
cls = getattr(module, class_name)
|
| 34 |
-
instance = cls()
|
| 35 |
-
register_provider(instance)
|
| 36 |
-
logger.info(f"Registered provider: {instance.name} ({instance.capability.value})")
|
| 37 |
-
except ImportError as e:
|
| 38 |
-
logger.warning(
|
| 39 |
-
f"Provider {class_name} from {module_path} skipped (missing optional dep): {e}"
|
| 40 |
-
)
|
| 41 |
-
except Exception as e:
|
| 42 |
-
logger.error(f"Failed to register {class_name}: {e}")
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def initialize_providers() -> None:
|
| 46 |
-
"""Construct and register every enabled provider. Call once at startup."""
|
| 47 |
-
# Detection
|
| 48 |
-
_safe_register("providers.detection.haar", "HaarDetector", settings.enable_haar)
|
| 49 |
-
_safe_register("providers.detection.dnn", "DNNDetector", settings.enable_dnn)
|
| 50 |
-
_safe_register("providers.detection.mtcnn", "MTCNNDetector", settings.enable_mtcnn)
|
| 51 |
-
_safe_register("providers.detection.retinaface", "RetinaFaceDetector", settings.enable_retinaface)
|
| 52 |
-
|
| 53 |
-
# Recognition
|
| 54 |
-
_safe_register(
|
| 55 |
-
"providers.recognition.face_recognition_provider", "FaceRecognitionProvider",
|
| 56 |
-
settings.enable_face_recognition,
|
| 57 |
-
)
|
| 58 |
-
_safe_register(
|
| 59 |
-
"providers.recognition.deepface_provider", "DeepFaceProvider",
|
| 60 |
-
settings.enable_deepface,
|
| 61 |
-
)
|
| 62 |
-
_safe_register(
|
| 63 |
-
"providers.recognition.insightface_provider", "InsightFaceProvider",
|
| 64 |
-
settings.enable_insightface,
|
| 65 |
-
)
|
| 66 |
-
|
| 67 |
-
# Scraping
|
| 68 |
-
_safe_register(
|
| 69 |
-
"providers.scraper.beautifulsoup_scraper", "BeautifulSoupScraper",
|
| 70 |
-
settings.enable_beautifulsoup_scraper,
|
| 71 |
-
)
|
| 72 |
-
_safe_register(
|
| 73 |
-
"providers.scraper.selenium_scraper", "SeleniumScraper",
|
| 74 |
-
settings.enable_selenium_scraper,
|
| 75 |
-
)
|
| 76 |
-
_safe_register(
|
| 77 |
-
"providers.scraper.bing_scraper", "BingImageScraper",
|
| 78 |
-
settings.enable_bing_scraper,
|
| 79 |
-
)
|
| 80 |
-
_safe_register(
|
| 81 |
-
"providers.scraper.duckduckgo_scraper", "DuckDuckGoScraper",
|
| 82 |
-
settings.enable_duckduckgo_scraper,
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
# Reverse image search
|
| 86 |
-
_safe_register(
|
| 87 |
-
"providers.reverse.google_lens", "GoogleLensProvider",
|
| 88 |
-
settings.enable_google_lens,
|
| 89 |
-
)
|
| 90 |
-
_safe_register(
|
| 91 |
-
"providers.reverse.serpapi", "SerpAPIProvider",
|
| 92 |
-
settings.enable_serpapi,
|
| 93 |
-
)
|
| 94 |
-
_safe_register(
|
| 95 |
-
"providers.reverse.yandex", "YandexReverseProvider",
|
| 96 |
-
settings.enable_yandex,
|
| 97 |
-
)
|
| 98 |
-
_safe_register(
|
| 99 |
-
"providers.reverse.tineye", "TinEyeProvider",
|
| 100 |
-
settings.enable_tineye,
|
| 101 |
-
)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def provider_statuses() -> list[dict]:
|
| 105 |
-
"""Snapshot of every provider's availability + status."""
|
| 106 |
-
out: list[dict] = []
|
| 107 |
-
for p in list_providers():
|
| 108 |
-
try:
|
| 109 |
-
available = p.is_available()
|
| 110 |
-
except Exception:
|
| 111 |
-
available = False
|
| 112 |
-
status = ProviderStatus.HEALTHY if available else ProviderStatus.NOT_CONFIGURED
|
| 113 |
-
out.append({
|
| 114 |
-
"name": p.name,
|
| 115 |
-
"capability": p.capability.value,
|
| 116 |
-
"status": status.value,
|
| 117 |
-
"available": available,
|
| 118 |
-
})
|
| 119 |
-
return out
|
| 120 |
-
|
| 121 |
|
| 122 |
__all__ = [
|
| 123 |
"Provider",
|
| 124 |
-
"
|
| 125 |
-
"
|
| 126 |
-
"
|
| 127 |
-
"
|
| 128 |
-
"
|
| 129 |
-
"clear_registry",
|
| 130 |
-
"register_provider",
|
| 131 |
-
"provider_statuses",
|
| 132 |
]
|
|
|
|
| 1 |
"""
|
| 2 |
+
Providers package.
|
| 3 |
+
|
| 4 |
+
After the refactor:
|
| 5 |
+
- base.py defines the Protocol + BaseProvider + ProviderResult
|
| 6 |
+
- registry.py defines ProviderRegistry (injectable, no globals)
|
| 7 |
+
- detection/ detection providers
|
| 8 |
+
- recognition/ recognition providers
|
| 9 |
+
- scraper/ web-scraping providers
|
| 10 |
+
- reverse/ reverse-image-search providers
|
| 11 |
+
|
| 12 |
+
There is NO module-level singleton. Construct a ProviderRegistry via
|
| 13 |
+
the DI container in api/deps.py.
|
| 14 |
"""
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from providers.base import (
|
| 17 |
Provider,
|
| 18 |
+
BaseProvider,
|
| 19 |
+
ProviderResult,
|
| 20 |
+
)
|
| 21 |
+
from providers.registry import (
|
| 22 |
+
ProviderRegistry,
|
| 23 |
+
PROVIDER_MANIFEST,
|
| 24 |
+
ManifestEntry,
|
| 25 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
__all__ = [
|
| 28 |
"Provider",
|
| 29 |
+
"BaseProvider",
|
| 30 |
+
"ProviderResult",
|
| 31 |
+
"ProviderRegistry",
|
| 32 |
+
"PROVIDER_MANIFEST",
|
| 33 |
+
"ManifestEntry",
|
|
|
|
|
|
|
|
|
|
| 34 |
]
|