diff --git a/core/api/main.py b/core/api/main.py
index fa307e1..4177145 100644
--- a/core/api/main.py
+++ b/core/api/main.py
@@ -40,7 +40,7 @@ app = FastAPI(
# CORS
app.add_middleware(
CORSMiddleware,
- allow_origins=["http://mpr.local.ar", "http://localhost:5173"],
+ allow_origins=["http://mpr.local.ar", "http://k8s.mpr.local.ar", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -57,6 +57,11 @@ app.include_router(chunker_router)
app.include_router(detect_router)
+@app.get("/health")
+def health():
+ return {"status": "ok"}
+
+
@app.get("/")
def root():
"""API root."""
diff --git a/ctrl/Dockerfile b/ctrl/Dockerfile
index 247b054..de26256 100644
--- a/ctrl/Dockerfile
+++ b/ctrl/Dockerfile
@@ -1,11 +1,13 @@
FROM python:3.11-slim
+RUN pip install --no-cache-dir uv
+
WORKDIR /app
COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
+RUN uv pip install --system --no-cache -r requirements.txt
-# No COPY . . — code is volume-mounted in dev (..:/app)
-# This image only provides the Python runtime + dependencies
+# Copy code into image (k8s uses this, docker-compose volume-mounts over it)
+COPY . .
CMD ["python", "admin/manage.py", "runserver", "0.0.0.0:8000"]
diff --git a/ctrl/Dockerfile.worker b/ctrl/Dockerfile.worker
index e69ff5b..9f62a11 100644
--- a/ctrl/Dockerfile.worker
+++ b/ctrl/Dockerfile.worker
@@ -1,5 +1,7 @@
FROM python:3.11-slim
+RUN pip install --no-cache-dir uv
+
RUN apt-get update && apt-get install -y \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
@@ -7,9 +9,9 @@ RUN apt-get update && apt-get install -y \
WORKDIR /app
COPY requirements.txt requirements-worker.txt ./
-RUN pip install --no-cache-dir -r requirements-worker.txt
+RUN uv pip install --system --no-cache -r requirements-worker.txt
-# No COPY . . — code is volume-mounted in dev (..:/app)
-# This image only provides Python runtime + FFmpeg + dependencies
+# Copy code into image (k8s uses this, docker-compose volume-mounts over it)
+COPY . .
CMD ["celery", "-A", "admin.mpr", "worker", "--loglevel=info"]
diff --git a/ctrl/Tiltfile b/ctrl/Tiltfile
new file mode 100644
index 0000000..63499fd
--- /dev/null
+++ b/ctrl/Tiltfile
@@ -0,0 +1,43 @@
+# MPR — Tilt development environment
+# Usage: cd ctrl && tilt up
+# Cluster: kind (name: mpr)
+
+allow_k8s_contexts('kind-mpr')
+
+# Apply k8s manifests via kustomize (dev overlay)
+k8s_yaml(kustomize('k8s/overlays/dev'))
+
+# --- Images — reuse existing Dockerfiles ---
+
+# FastAPI (Python backend)
+docker_build(
+ 'mpr-fastapi',
+ context='..',
+ dockerfile='Dockerfile',
+ live_update=[
+ sync('..', '/app'),
+ ],
+)
+
+# Detection UI (Vue 3)
+docker_build(
+ 'mpr-detection',
+ context='../ui/detection-app',
+ dockerfile='../ui/detection-app/Dockerfile',
+ live_update=[
+ sync('../ui/detection-app/src', '/app/src'),
+ sync('../ui/detection-app/index.html', '/app/index.html'),
+ sync('../ui/detection-app/vite.config.ts', '/app/vite.config.ts'),
+ ],
+)
+
+# Framework changes trigger a full rebuild (live_update can't reach outside context)
+watch_file('../ui/framework/src')
+
+# --- Resources ---
+
+k8s_resource('redis')
+k8s_resource('fastapi', resource_deps=['redis'])
+k8s_resource('detection-ui')
+k8s_resource('gateway', resource_deps=['fastapi', 'detection-ui'],
+ port_forwards=['8080:8080'])
diff --git a/ctrl/k8s/base/configmap.yaml b/ctrl/k8s/base/configmap.yaml
new file mode 100644
index 0000000..08b6304
--- /dev/null
+++ b/ctrl/k8s/base/configmap.yaml
@@ -0,0 +1,11 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: mpr-config
+ namespace: mpr
+data:
+ REDIS_URL: redis://redis:6379/0
+ DEBUG: "1"
+ FASTAPI_PORT: "8702"
+ DETECTION_UI_PORT: "5175"
+ GATEWAY_PORT: "8080"
diff --git a/ctrl/k8s/base/detection-ui.yaml b/ctrl/k8s/base/detection-ui.yaml
new file mode 100644
index 0000000..24fe285
--- /dev/null
+++ b/ctrl/k8s/base/detection-ui.yaml
@@ -0,0 +1,44 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: detection-ui
+ namespace: mpr
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: detection-ui
+ template:
+ metadata:
+ labels:
+ app: detection-ui
+ spec:
+ containers:
+ - name: detection-ui
+ image: mpr-detection
+ ports:
+ - containerPort: 5175
+ envFrom:
+ - configMapRef:
+ name: mpr-config
+ env:
+ - name: VITE_ALLOWED_HOSTS
+ value: "k8s.mpr.local.ar"
+ resources:
+ requests:
+ memory: 64Mi
+ cpu: 50m
+ limits:
+ memory: 256Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: detection-ui
+ namespace: mpr
+spec:
+ selector:
+ app: detection-ui
+ ports:
+ - port: 5175
+ targetPort: 5175
diff --git a/ctrl/k8s/base/fastapi.yaml b/ctrl/k8s/base/fastapi.yaml
new file mode 100644
index 0000000..90d1833
--- /dev/null
+++ b/ctrl/k8s/base/fastapi.yaml
@@ -0,0 +1,48 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: fastapi
+ namespace: mpr
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: fastapi
+ template:
+ metadata:
+ labels:
+ app: fastapi
+ spec:
+ containers:
+ - name: fastapi
+ image: mpr-fastapi
+ command: ["sh", "-c", "uvicorn core.api.main:app --host 0.0.0.0 --port $FASTAPI_PORT --reload"]
+ ports:
+ - containerPort: 8702
+ envFrom:
+ - configMapRef:
+ name: mpr-config
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 8702
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ resources:
+ requests:
+ memory: 128Mi
+ cpu: 100m
+ limits:
+ memory: 512Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: fastapi
+ namespace: mpr
+spec:
+ selector:
+ app: fastapi
+ ports:
+ - port: 8702
+ targetPort: 8702
diff --git a/ctrl/k8s/base/gateway.yaml b/ctrl/k8s/base/gateway.yaml
new file mode 100644
index 0000000..3122984
--- /dev/null
+++ b/ctrl/k8s/base/gateway.yaml
@@ -0,0 +1,128 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: envoy-gateway-config
+ namespace: mpr
+data:
+ envoy.yaml: |
+ static_resources:
+ listeners:
+ - name: http
+ address:
+ socket_address:
+ address: 0.0.0.0
+ port_value: 8080
+ filter_chains:
+ - filters:
+ - name: envoy.filters.network.http_connection_manager
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+ stat_prefix: ingress
+ codec_type: AUTO
+ route_config:
+ name: local_routes
+ virtual_hosts:
+ - name: mpr
+ domains: ["k8s.mpr.local.ar", "*"]
+ routes:
+ # SSE — long timeout, no buffering
+ - match:
+ prefix: "/api/detect/stream/"
+ route:
+ cluster: fastapi
+ timeout: 3600s
+ idle_timeout: 3600s
+ # FastAPI — strip /api/ prefix
+ - match:
+ prefix: "/api/"
+ route:
+ cluster: fastapi
+ prefix_rewrite: "/"
+ # Detection UI
+ - match:
+ prefix: "/detection/"
+ route:
+ cluster: detection-ui
+ # Default
+ - match:
+ prefix: "/"
+ route:
+ cluster: detection-ui
+ prefix_rewrite: "/detection/"
+ http_filters:
+ - name: envoy.filters.http.router
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
+ clusters:
+ - name: fastapi
+ connect_timeout: 5s
+ type: STRICT_DNS
+ lb_policy: ROUND_ROBIN
+ load_assignment:
+ cluster_name: fastapi
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: fastapi
+ port_value: 8702
+ - name: detection-ui
+ connect_timeout: 5s
+ type: STRICT_DNS
+ lb_policy: ROUND_ROBIN
+ load_assignment:
+ cluster_name: detection-ui
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: detection-ui
+ port_value: 5175
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: gateway
+ namespace: mpr
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: gateway
+ template:
+ metadata:
+ labels:
+ app: gateway
+ spec:
+ containers:
+ - name: envoy
+ image: envoyproxy/envoy:v1.28-latest
+ ports:
+ - containerPort: 8080
+ volumeMounts:
+ - name: config
+ mountPath: /etc/envoy
+ resources:
+ requests:
+ memory: 64Mi
+ cpu: 50m
+ limits:
+ memory: 256Mi
+ volumes:
+ - name: config
+ configMap:
+ name: envoy-gateway-config
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: gateway
+ namespace: mpr
+spec:
+ selector:
+ app: gateway
+ ports:
+ - port: 80
+ targetPort: 8080
diff --git a/ctrl/k8s/base/kustomization.yaml b/ctrl/k8s/base/kustomization.yaml
new file mode 100644
index 0000000..cc501ab
--- /dev/null
+++ b/ctrl/k8s/base/kustomization.yaml
@@ -0,0 +1,12 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: mpr
+
+resources:
+ - namespace.yaml
+ - configmap.yaml
+ - redis.yaml
+ - fastapi.yaml
+ - detection-ui.yaml
+ - gateway.yaml
diff --git a/ctrl/k8s/base/namespace.yaml b/ctrl/k8s/base/namespace.yaml
new file mode 100644
index 0000000..2b8be1a
--- /dev/null
+++ b/ctrl/k8s/base/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: mpr
diff --git a/ctrl/k8s/base/redis.yaml b/ctrl/k8s/base/redis.yaml
new file mode 100644
index 0000000..09fdb5d
--- /dev/null
+++ b/ctrl/k8s/base/redis.yaml
@@ -0,0 +1,43 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: redis
+ namespace: mpr
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: redis
+ template:
+ metadata:
+ labels:
+ app: redis
+ spec:
+ containers:
+ - name: redis
+ image: redis:7-alpine
+ ports:
+ - containerPort: 6379
+ readinessProbe:
+ exec:
+ command: ["redis-cli", "ping"]
+ initialDelaySeconds: 2
+ periodSeconds: 5
+ resources:
+ requests:
+ memory: 64Mi
+ cpu: 50m
+ limits:
+ memory: 256Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: redis
+ namespace: mpr
+spec:
+ selector:
+ app: redis
+ ports:
+ - port: 6379
+ targetPort: 6379
diff --git a/ctrl/k8s/k8s/base/detection-ui.yaml b/ctrl/k8s/k8s/base/detection-ui.yaml
new file mode 100644
index 0000000..3005735
--- /dev/null
+++ b/ctrl/k8s/k8s/base/detection-ui.yaml
@@ -0,0 +1,38 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: detection-ui
+ namespace: mpr
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: detection-ui
+ template:
+ metadata:
+ labels:
+ app: detection-ui
+ spec:
+ containers:
+ - name: detection-ui
+ image: mpr-detection
+ ports:
+ - containerPort: 5175
+ resources:
+ requests:
+ memory: 64Mi
+ cpu: 50m
+ limits:
+ memory: 256Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: detection-ui
+ namespace: mpr
+spec:
+ selector:
+ app: detection-ui
+ ports:
+ - port: 5175
+ targetPort: 5175
diff --git a/ctrl/k8s/k8s/base/fastapi.yaml b/ctrl/k8s/k8s/base/fastapi.yaml
new file mode 100644
index 0000000..1178684
--- /dev/null
+++ b/ctrl/k8s/k8s/base/fastapi.yaml
@@ -0,0 +1,52 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: fastapi
+ namespace: mpr
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: fastapi
+ template:
+ metadata:
+ labels:
+ app: fastapi
+ spec:
+ containers:
+ - name: fastapi
+ image: mpr-fastapi
+ command: ["uvicorn", "core.api.main:app", "--host", "0.0.0.0", "--port", "8702", "--reload"]
+ ports:
+ - containerPort: 8702
+ env:
+ - name: REDIS_URL
+ value: redis://redis:6379/0
+ - name: DJANGO_ALLOW_ASYNC_UNSAFE
+ value: "true"
+ - name: DEBUG
+ value: "1"
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 8702
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ resources:
+ requests:
+ memory: 128Mi
+ cpu: 100m
+ limits:
+ memory: 512Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: fastapi
+ namespace: mpr
+spec:
+ selector:
+ app: fastapi
+ ports:
+ - port: 8702
+ targetPort: 8702
diff --git a/ctrl/k8s/k8s/base/gateway.yaml b/ctrl/k8s/k8s/base/gateway.yaml
new file mode 100644
index 0000000..82254e5
--- /dev/null
+++ b/ctrl/k8s/k8s/base/gateway.yaml
@@ -0,0 +1,128 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: envoy-gateway-config
+ namespace: mpr
+data:
+ envoy.yaml: |
+ static_resources:
+ listeners:
+ - name: http
+ address:
+ socket_address:
+ address: 0.0.0.0
+ port_value: 8080
+ filter_chains:
+ - filters:
+ - name: envoy.filters.network.http_connection_manager
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+ stat_prefix: ingress
+ codec_type: AUTO
+ route_config:
+ name: local_routes
+ virtual_hosts:
+ - name: mpr
+ domains: ["k8s.mpr.local.ar", "*"]
+ routes:
+ # SSE — disable buffering
+ - match:
+ prefix: "/api/detect/stream/"
+ route:
+ cluster: fastapi
+ timeout: 3600s
+ idle_timeout: 3600s
+ # FastAPI — strip /api/ prefix
+ - match:
+ prefix: "/api/"
+ route:
+ cluster: fastapi
+ prefix_rewrite: "/"
+ # Detection UI
+ - match:
+ prefix: "/detection/"
+ route:
+ cluster: detection-ui
+ # Default — detection UI
+ - match:
+ prefix: "/"
+ route:
+ cluster: detection-ui
+ prefix_rewrite: "/detection/"
+ http_filters:
+ - name: envoy.filters.http.router
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
+ clusters:
+ - name: fastapi
+ connect_timeout: 5s
+ type: STRICT_DNS
+ lb_policy: ROUND_ROBIN
+ load_assignment:
+ cluster_name: fastapi
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: fastapi
+ port_value: 8702
+ - name: detection-ui
+ connect_timeout: 5s
+ type: STRICT_DNS
+ lb_policy: ROUND_ROBIN
+ load_assignment:
+ cluster_name: detection-ui
+ endpoints:
+ - lb_endpoints:
+ - endpoint:
+ address:
+ socket_address:
+ address: detection-ui
+ port_value: 5175
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: gateway
+ namespace: mpr
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: gateway
+ template:
+ metadata:
+ labels:
+ app: gateway
+ spec:
+ containers:
+ - name: envoy
+ image: envoyproxy/envoy:v1.28-latest
+ ports:
+ - containerPort: 8080
+ volumeMounts:
+ - name: config
+ mountPath: /etc/envoy
+ resources:
+ requests:
+ memory: 64Mi
+ cpu: 50m
+ limits:
+ memory: 256Mi
+ volumes:
+ - name: config
+ configMap:
+ name: envoy-gateway-config
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: gateway
+ namespace: mpr
+spec:
+ selector:
+ app: gateway
+ ports:
+ - port: 80
+ targetPort: 8080
diff --git a/ctrl/k8s/k8s/base/kustomization.yaml b/ctrl/k8s/k8s/base/kustomization.yaml
new file mode 100644
index 0000000..1f851ad
--- /dev/null
+++ b/ctrl/k8s/k8s/base/kustomization.yaml
@@ -0,0 +1,11 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: mpr
+
+resources:
+ - namespace.yaml
+ - redis.yaml
+ - fastapi.yaml
+ - detection-ui.yaml
+ - gateway.yaml
diff --git a/ctrl/k8s/k8s/base/namespace.yaml b/ctrl/k8s/k8s/base/namespace.yaml
new file mode 100644
index 0000000..2b8be1a
--- /dev/null
+++ b/ctrl/k8s/k8s/base/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: mpr
diff --git a/ctrl/k8s/k8s/base/redis.yaml b/ctrl/k8s/k8s/base/redis.yaml
new file mode 100644
index 0000000..09fdb5d
--- /dev/null
+++ b/ctrl/k8s/k8s/base/redis.yaml
@@ -0,0 +1,43 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: redis
+ namespace: mpr
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: redis
+ template:
+ metadata:
+ labels:
+ app: redis
+ spec:
+ containers:
+ - name: redis
+ image: redis:7-alpine
+ ports:
+ - containerPort: 6379
+ readinessProbe:
+ exec:
+ command: ["redis-cli", "ping"]
+ initialDelaySeconds: 2
+ periodSeconds: 5
+ resources:
+ requests:
+ memory: 64Mi
+ cpu: 50m
+ limits:
+ memory: 256Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: redis
+ namespace: mpr
+spec:
+ selector:
+ app: redis
+ ports:
+ - port: 6379
+ targetPort: 6379
diff --git a/ctrl/k8s/k8s/overlays/cloud/kustomization.yaml b/ctrl/k8s/k8s/overlays/cloud/kustomization.yaml
new file mode 100644
index 0000000..7291ef7
--- /dev/null
+++ b/ctrl/k8s/k8s/overlays/cloud/kustomization.yaml
@@ -0,0 +1,20 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - ../../base
+
+patches:
+ # Gateway as cloud LoadBalancer
+ - target:
+ kind: Service
+ name: gateway
+ patch: |
+ - op: replace
+ path: /spec/type
+ value: LoadBalancer
+ - op: add
+ path: /metadata/annotations
+ value:
+ service.beta.kubernetes.io/aws-load-balancer-type: nlb
+ service.beta.kubernetes.io/aws-load-balancer-scheme: internal
diff --git a/ctrl/k8s/k8s/overlays/dev/kustomization.yaml b/ctrl/k8s/k8s/overlays/dev/kustomization.yaml
new file mode 100644
index 0000000..6dddd62
--- /dev/null
+++ b/ctrl/k8s/k8s/overlays/dev/kustomization.yaml
@@ -0,0 +1,30 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - ../../base
+
+patches:
+ # Gateway as NodePort for local access
+ - target:
+ kind: Service
+ name: gateway
+ patch: |
+ - op: replace
+ path: /spec/type
+ value: NodePort
+ - op: add
+ path: /spec/ports/0/nodePort
+ value: 30080
+
+ # Redis as NodePort for redis-cli access from host
+ - target:
+ kind: Service
+ name: redis
+ patch: |
+ - op: replace
+ path: /spec/type
+ value: NodePort
+ - op: add
+ path: /spec/ports/0/nodePort
+ value: 30379
diff --git a/ctrl/k8s/k8s/overlays/onprem/kustomization.yaml b/ctrl/k8s/k8s/overlays/onprem/kustomization.yaml
new file mode 100644
index 0000000..f3668a1
--- /dev/null
+++ b/ctrl/k8s/k8s/overlays/onprem/kustomization.yaml
@@ -0,0 +1,15 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - ../../base
+
+patches:
+ # Gateway as LoadBalancer — MetalLB assigns a LAN IP
+ - target:
+ kind: Service
+ name: gateway
+ patch: |
+ - op: replace
+ path: /spec/type
+ value: LoadBalancer
diff --git a/ctrl/k8s/kind-config.yaml b/ctrl/k8s/kind-config.yaml
new file mode 100644
index 0000000..e05ad0f
--- /dev/null
+++ b/ctrl/k8s/kind-config.yaml
@@ -0,0 +1,15 @@
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+name: mpr
+nodes:
+ - role: control-plane
+ extraPortMappings:
+ # Gateway → http://k8s.mpr.local.ar (bind to 127.0.0.2 to avoid conflict with docker-compose on 127.0.0.1:80)
+ - containerPort: 30080
+ hostPort: 80
+ listenAddress: "127.0.0.2"
+ protocol: TCP
+ # Redis
+ - containerPort: 30379
+ hostPort: 6382
+ protocol: TCP
diff --git a/ctrl/k8s/overlays/cloud/kustomization.yaml b/ctrl/k8s/overlays/cloud/kustomization.yaml
new file mode 100644
index 0000000..7291ef7
--- /dev/null
+++ b/ctrl/k8s/overlays/cloud/kustomization.yaml
@@ -0,0 +1,20 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - ../../base
+
+patches:
+ # Gateway as cloud LoadBalancer
+ - target:
+ kind: Service
+ name: gateway
+ patch: |
+ - op: replace
+ path: /spec/type
+ value: LoadBalancer
+ - op: add
+ path: /metadata/annotations
+ value:
+ service.beta.kubernetes.io/aws-load-balancer-type: nlb
+ service.beta.kubernetes.io/aws-load-balancer-scheme: internal
diff --git a/ctrl/k8s/overlays/dev/kustomization.yaml b/ctrl/k8s/overlays/dev/kustomization.yaml
new file mode 100644
index 0000000..6dddd62
--- /dev/null
+++ b/ctrl/k8s/overlays/dev/kustomization.yaml
@@ -0,0 +1,30 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - ../../base
+
+patches:
+ # Gateway as NodePort for local access
+ - target:
+ kind: Service
+ name: gateway
+ patch: |
+ - op: replace
+ path: /spec/type
+ value: NodePort
+ - op: add
+ path: /spec/ports/0/nodePort
+ value: 30080
+
+ # Redis as NodePort for redis-cli access from host
+ - target:
+ kind: Service
+ name: redis
+ patch: |
+ - op: replace
+ path: /spec/type
+ value: NodePort
+ - op: add
+ path: /spec/ports/0/nodePort
+ value: 30379
diff --git a/ctrl/k8s/overlays/onprem/kustomization.yaml b/ctrl/k8s/overlays/onprem/kustomization.yaml
new file mode 100644
index 0000000..f3668a1
--- /dev/null
+++ b/ctrl/k8s/overlays/onprem/kustomization.yaml
@@ -0,0 +1,15 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - ../../base
+
+patches:
+ # Gateway as LoadBalancer — MetalLB assigns a LAN IP
+ - target:
+ kind: Service
+ name: gateway
+ patch: |
+ - op: replace
+ path: /spec/type
+ value: LoadBalancer
diff --git a/detect/profiles/__init__.py b/detect/profiles/__init__.py
new file mode 100644
index 0000000..e4664dd
--- /dev/null
+++ b/detect/profiles/__init__.py
@@ -0,0 +1,23 @@
+from .base import (
+ ContentTypeProfile,
+ BrandDictionary,
+ CropContext,
+ DetectionConfig,
+ FrameExtractionConfig,
+ OCRConfig,
+ ResolverConfig,
+ SceneFilterConfig,
+)
+from .soccer import SoccerBroadcastProfile
+
+__all__ = [
+ "ContentTypeProfile",
+ "BrandDictionary",
+ "CropContext",
+ "DetectionConfig",
+ "FrameExtractionConfig",
+ "OCRConfig",
+ "ResolverConfig",
+ "SceneFilterConfig",
+ "SoccerBroadcastProfile",
+]
diff --git a/detect/profiles/base.py b/detect/profiles/base.py
new file mode 100644
index 0000000..81c386d
--- /dev/null
+++ b/detect/profiles/base.py
@@ -0,0 +1,71 @@
+"""
+ContentTypeProfile protocol and config dataclasses.
+
+The pipeline graph is fixed — what varies per content type is configuration
+and hooks. Each profile provides stage configs, a brand dictionary,
+VLM prompt templates, and an aggregation strategy.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Protocol
+
+from detect.models import BrandDetection, DetectionReport
+
+
+@dataclass
+class FrameExtractionConfig:
+ fps: float = 2.0
+ max_frames: int = 500
+
+
+@dataclass
+class SceneFilterConfig:
+ hamming_threshold: int = 8
+ enabled: bool = True
+
+
+@dataclass
+class DetectionConfig:
+ model_name: str = "yolov8n.pt"
+ confidence_threshold: float = 0.3
+ target_classes: list[str] = field(default_factory=lambda: ["logo", "text"])
+
+
+@dataclass
+class OCRConfig:
+ languages: list[str] = field(default_factory=lambda: ["en"])
+ min_confidence: float = 0.5
+
+
+@dataclass
+class ResolverConfig:
+ fuzzy_threshold: int = 75
+
+
+@dataclass
+class BrandDictionary:
+ """Maps canonical brand name → list of known aliases/spellings."""
+ brands: dict[str, list[str]] = field(default_factory=dict)
+
+
+@dataclass
+class CropContext:
+ image: bytes
+ surrounding_text: str = ""
+ position_hint: str = ""
+
+
+class ContentTypeProfile(Protocol):
+ name: str
+
+ def frame_extraction_config(self) -> FrameExtractionConfig: ...
+ def scene_filter_config(self) -> SceneFilterConfig: ...
+ def detection_config(self) -> DetectionConfig: ...
+ def ocr_config(self) -> OCRConfig: ...
+ def brand_dictionary(self) -> BrandDictionary: ...
+ def resolver_config(self) -> ResolverConfig: ...
+ def vlm_prompt(self, crop_context: CropContext) -> str: ...
+ def aggregate(self, detections: list[BrandDetection]) -> DetectionReport: ...
+ def auxiliary_detections(self, source: str) -> list[BrandDetection]: ...
diff --git a/detect/profiles/soccer.py b/detect/profiles/soccer.py
new file mode 100644
index 0000000..66e2704
--- /dev/null
+++ b/detect/profiles/soccer.py
@@ -0,0 +1,92 @@
+"""Soccer broadcast profile — pitch hoardings, kits, scoreboards."""
+
+from __future__ import annotations
+
+from detect.models import BrandDetection, BrandStats, DetectionReport, PipelineStats
+
+from .base import (
+ BrandDictionary,
+ CropContext,
+ DetectionConfig,
+ FrameExtractionConfig,
+ OCRConfig,
+ ResolverConfig,
+ SceneFilterConfig,
+)
+
+
+class SoccerBroadcastProfile:
+ name = "soccer_broadcast"
+
+ def frame_extraction_config(self) -> FrameExtractionConfig:
+ return FrameExtractionConfig(fps=2.0, max_frames=500)
+
+ def scene_filter_config(self) -> SceneFilterConfig:
+ return SceneFilterConfig(hamming_threshold=8, enabled=True)
+
+ def detection_config(self) -> DetectionConfig:
+ return DetectionConfig(
+ model_name="yolov8n.pt",
+ confidence_threshold=0.3,
+ target_classes=["logo", "text", "banner", "scoreboard"],
+ )
+
+ def ocr_config(self) -> OCRConfig:
+ return OCRConfig(languages=["en", "es"], min_confidence=0.5)
+
+ def brand_dictionary(self) -> BrandDictionary:
+ return BrandDictionary(brands={
+ "Nike": ["nike", "NIKE", "swoosh"],
+ "Adidas": ["adidas", "ADIDAS", "adi"],
+ "Puma": ["puma", "PUMA"],
+ "Emirates": ["emirates", "fly emirates", "EMIRATES"],
+ "Coca-Cola": ["coca-cola", "coca cola", "coke", "COCA-COLA"],
+ "Pepsi": ["pepsi", "PEPSI"],
+ "Mastercard": ["mastercard", "MASTERCARD"],
+ "Heineken": ["heineken", "HEINEKEN"],
+ "Santander": ["santander", "SANTANDER"],
+ "Gazprom": ["gazprom", "GAZPROM"],
+ "Qatar Airways": ["qatar airways", "QATAR AIRWAYS"],
+ "Lay's": ["lays", "lay's", "LAYS", "LAY'S"],
+ })
+
+ def resolver_config(self) -> ResolverConfig:
+ return ResolverConfig(fuzzy_threshold=75)
+
+ def vlm_prompt(self, crop_context: CropContext) -> str:
+ hint = f" Position: {crop_context.position_hint}." if crop_context.position_hint else ""
+ text = f" Nearby text: '{crop_context.surrounding_text}'." if crop_context.surrounding_text else ""
+ return (
+ f"Identify the brand or sponsor visible in this cropped region "
+ f"from a soccer broadcast.{hint}{text} "
+ f"Respond with: brand, confidence (0-1), reasoning."
+ )
+
+ def aggregate(self, detections: list[BrandDetection]) -> DetectionReport:
+ brands: dict[str, BrandStats] = {}
+ for d in detections:
+ if d.brand not in brands:
+ brands[d.brand] = BrandStats()
+ s = brands[d.brand]
+ s.total_appearances += 1
+ s.total_screen_time += d.duration
+ s.avg_confidence = (
+ (s.avg_confidence * (s.total_appearances - 1) + d.confidence)
+ / s.total_appearances
+ )
+ if s.first_seen == 0.0 or d.timestamp < s.first_seen:
+ s.first_seen = d.timestamp
+ if d.timestamp > s.last_seen:
+ s.last_seen = d.timestamp
+
+ return DetectionReport(
+ video_source="",
+ content_type=self.name,
+ duration_seconds=0.0,
+ brands=brands,
+ timeline=sorted(detections, key=lambda d: d.timestamp),
+ pipeline_stats=PipelineStats(),
+ )
+
+ def auxiliary_detections(self, source: str) -> list[BrandDetection]:
+ return []
diff --git a/detect/profiles/stubs.py b/detect/profiles/stubs.py
new file mode 100644
index 0000000..9ccd489
--- /dev/null
+++ b/detect/profiles/stubs.py
@@ -0,0 +1,108 @@
+"""Stub profiles — interfaces defined, not yet implemented."""
+
+from __future__ import annotations
+
+from detect.models import BrandDetection, DetectionReport
+
+from .base import (
+ BrandDictionary,
+ CropContext,
+ DetectionConfig,
+ FrameExtractionConfig,
+ OCRConfig,
+ ResolverConfig,
+ SceneFilterConfig,
+)
+
+
+class NewsBroadcastProfile:
+ name = "news_broadcast"
+
+ def frame_extraction_config(self) -> FrameExtractionConfig:
+ raise NotImplementedError
+
+ def scene_filter_config(self) -> SceneFilterConfig:
+ raise NotImplementedError
+
+ def detection_config(self) -> DetectionConfig:
+ raise NotImplementedError
+
+ def ocr_config(self) -> OCRConfig:
+ raise NotImplementedError
+
+ def brand_dictionary(self) -> BrandDictionary:
+ raise NotImplementedError
+
+ def resolver_config(self) -> ResolverConfig:
+ raise NotImplementedError
+
+ def vlm_prompt(self, crop_context: CropContext) -> str:
+ raise NotImplementedError
+
+ def aggregate(self, detections: list[BrandDetection]) -> DetectionReport:
+ raise NotImplementedError
+
+ def auxiliary_detections(self, source: str) -> list[BrandDetection]:
+ raise NotImplementedError
+
+
+class AdvertisingProfile:
+ name = "advertising"
+
+ def frame_extraction_config(self) -> FrameExtractionConfig:
+ raise NotImplementedError
+
+ def scene_filter_config(self) -> SceneFilterConfig:
+ raise NotImplementedError
+
+ def detection_config(self) -> DetectionConfig:
+ raise NotImplementedError
+
+ def ocr_config(self) -> OCRConfig:
+ raise NotImplementedError
+
+ def brand_dictionary(self) -> BrandDictionary:
+ raise NotImplementedError
+
+ def resolver_config(self) -> ResolverConfig:
+ raise NotImplementedError
+
+ def vlm_prompt(self, crop_context: CropContext) -> str:
+ raise NotImplementedError
+
+ def aggregate(self, detections: list[BrandDetection]) -> DetectionReport:
+ raise NotImplementedError
+
+ def auxiliary_detections(self, source: str) -> list[BrandDetection]:
+ raise NotImplementedError
+
+
+class TranscriptProfile:
+ name = "transcript"
+
+ def frame_extraction_config(self) -> FrameExtractionConfig:
+ raise NotImplementedError
+
+ def scene_filter_config(self) -> SceneFilterConfig:
+ raise NotImplementedError
+
+ def detection_config(self) -> DetectionConfig:
+ raise NotImplementedError
+
+ def ocr_config(self) -> OCRConfig:
+ raise NotImplementedError
+
+ def brand_dictionary(self) -> BrandDictionary:
+ raise NotImplementedError
+
+ def resolver_config(self) -> ResolverConfig:
+ raise NotImplementedError
+
+ def vlm_prompt(self, crop_context: CropContext) -> str:
+ raise NotImplementedError
+
+ def aggregate(self, detections: list[BrandDetection]) -> DetectionReport:
+ raise NotImplementedError
+
+ def auxiliary_detections(self, source: str) -> list[BrandDetection]:
+ raise NotImplementedError
diff --git a/tests/detect/test_profiles.py b/tests/detect/test_profiles.py
new file mode 100644
index 0000000..c1817e5
--- /dev/null
+++ b/tests/detect/test_profiles.py
@@ -0,0 +1,73 @@
+"""Tests for ContentTypeProfile implementations."""
+
+import pytest
+
+from detect.models import BrandDetection
+from detect.profiles.base import ContentTypeProfile, CropContext
+from detect.profiles.soccer import SoccerBroadcastProfile
+from detect.profiles.stubs import AdvertisingProfile, NewsBroadcastProfile, TranscriptProfile
+
+
+def test_soccer_satisfies_protocol():
+ profile: ContentTypeProfile = SoccerBroadcastProfile()
+ assert profile.name == "soccer_broadcast"
+
+
+def test_soccer_frame_extraction_config():
+ cfg = SoccerBroadcastProfile().frame_extraction_config()
+ assert cfg.fps > 0
+ assert cfg.max_frames > 0
+
+
+def test_soccer_detection_config():
+ cfg = SoccerBroadcastProfile().detection_config()
+ assert 0 < cfg.confidence_threshold < 1
+ assert len(cfg.target_classes) > 0
+
+
+def test_soccer_brand_dictionary_non_empty():
+ bd = SoccerBroadcastProfile().brand_dictionary()
+ assert len(bd.brands) > 0
+ for canonical, aliases in bd.brands.items():
+ assert len(aliases) > 0
+
+
+def test_soccer_vlm_prompt():
+ ctx = CropContext(image=b"fake", surrounding_text="Emirates", position_hint="top-center")
+ prompt = SoccerBroadcastProfile().vlm_prompt(ctx)
+ assert "brand" in prompt.lower()
+ assert "Emirates" in prompt
+
+
+def test_soccer_aggregate_empty():
+ report = SoccerBroadcastProfile().aggregate([])
+ assert len(report.brands) == 0
+ assert len(report.timeline) == 0
+
+
+def test_soccer_aggregate_groups():
+ detections = [
+ BrandDetection(brand="Nike", timestamp=1.0, duration=0.5, confidence=0.9, source="ocr"),
+ BrandDetection(brand="Nike", timestamp=2.0, duration=0.5, confidence=0.8, source="ocr"),
+ BrandDetection(brand="Adidas", timestamp=3.0, duration=0.5, confidence=0.7, source="logo_match"),
+ ]
+ report = SoccerBroadcastProfile().aggregate(detections)
+ assert "Nike" in report.brands
+ assert "Adidas" in report.brands
+ assert report.brands["Nike"].total_appearances == 2
+ assert report.brands["Adidas"].total_appearances == 1
+ assert report.timeline == sorted(report.timeline, key=lambda d: d.timestamp)
+
+
+def test_soccer_auxiliary_returns_empty():
+ assert SoccerBroadcastProfile().auxiliary_detections("test.mp4") == []
+
+
+@pytest.mark.parametrize("stub_cls", [NewsBroadcastProfile, AdvertisingProfile, TranscriptProfile])
+def test_stubs_raise(stub_cls):
+ stub = stub_cls()
+ assert isinstance(stub.name, str)
+ with pytest.raises(NotImplementedError):
+ stub.frame_extraction_config()
+ with pytest.raises(NotImplementedError):
+ stub.brand_dictionary()
diff --git a/ui/detection-app/src/App.vue b/ui/detection-app/src/App.vue
index b487462..bf64bc3 100644
--- a/ui/detection-app/src/App.vue
+++ b/ui/detection-app/src/App.vue
@@ -1,12 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/ui/framework/src/components/Panel.vue b/ui/framework/src/components/Panel.vue
new file mode 100644
index 0000000..17a2609
--- /dev/null
+++ b/ui/framework/src/components/Panel.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
diff --git a/ui/framework/src/index.ts b/ui/framework/src/index.ts
index b221ea6..1906aff 100644
--- a/ui/framework/src/index.ts
+++ b/ui/framework/src/index.ts
@@ -3,3 +3,7 @@ export { DataSource, type DataSourceStatus } from './datasources/DataSource'
export { SSEDataSource } from './datasources/SSEDataSource'
export { StaticDataSource } from './datasources/StaticDataSource'
export { useDataSource } from './composables/useDataSource'
+
+// Components
+export { default as Panel } from './components/Panel.vue'
+export { default as LayoutGrid } from './components/LayoutGrid.vue'
diff --git a/ui/framework/src/tokens.css b/ui/framework/src/tokens.css
new file mode 100644
index 0000000..d719c20
--- /dev/null
+++ b/ui/framework/src/tokens.css
@@ -0,0 +1,45 @@
+/* Framework design tokens — retheme by replacing this file */
+:root {
+ /* spacing scale (4px base) */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-6: 24px;
+ --space-8: 32px;
+
+ /* color — dark theme (observability UIs are always dark) */
+ --surface-0: #0d0d0f;
+ --surface-1: #16161a;
+ --surface-2: #1e1e24;
+ --surface-3: #26262f;
+ --border: #2e2e38;
+
+ --text-primary: #e8e8f0;
+ --text-secondary: #8888a0;
+ --text-dim: #555568;
+
+ /* status colors */
+ --status-idle: #555568;
+ --status-live: #3ecf8e;
+ --status-processing: #4f9cf9;
+ --status-escalating: #f5a623;
+ --status-error: #f06565;
+
+ /* confidence color scale (low → high) */
+ --conf-low: #f06565;
+ --conf-mid: #f5a623;
+ --conf-high: #3ecf8e;
+
+ /* typography */
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+ --font-ui: 'Inter', system-ui, sans-serif;
+ --font-size-sm: 11px;
+ --font-size-base: 13px;
+ --font-size-lg: 15px;
+
+ /* panel chrome */
+ --panel-radius: 6px;
+ --panel-border: 1px solid var(--border);
+ --panel-header-height: 36px;
+}