feat(maturity): add dedicated NSFW/maturity analysis service

- Add maturity/ service (FastAPI + Falconsai/nsfw_image_detection ViT classifier)
  - /analyze (URL) and /analyze/file (multipart upload) endpoints
  - Normalized response: maturity_label, confidence, score, labels,
    action_hint (safe/review/flag_high), advisory, threshold_used,
    analysis_time_ms, model, source
  - Configurable thresholds via MATURITY_THRESHOLD_MATURE / MATURITY_THRESHOLD_REVIEW
  - Reuses common/image_io for URL validation and file-size enforcement
  - Explicit 502/503 errors on failure — no silent safe fallback
  - Per-request structured logging (score, label, threshold path, elapsed ms)

- Update gateway/main.py
  - MATURITY_URL + MATURITY_ENABLED env vars
  - POST /analyze/maturity and POST /analyze/maturity/file endpoints
  - /health includes maturity service status
  - _assert_maturity_enabled() guard for clean 503 when disabled
  - All existing endpoints untouched (additive change)

- Update docker-compose.yml
  - Add maturity service with healthcheck (start_period: 90s)
  - Gateway environment: MATURITY_URL, MATURITY_ENABLED
  - Gateway depends_on: maturity (service_healthy)

- Update README.md and USAGE.md
  - Document maturity service, env vars, curl examples,
    full response schema table, action_hint logic, failure guidance
This commit is contained in:
2026-04-11 17:29:26 +02:00
parent f681ab980d
commit baf497b015
7 changed files with 608 additions and 126 deletions

View File

@@ -1,7 +1,10 @@
from __future__ import annotations
import os
import asyncio
import logging
import os
import time
from contextlib import asynccontextmanager
from typing import Any, Dict, Optional
import httpx
@@ -10,16 +13,76 @@ from fastapi.responses import JSONResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware
from pydantic import BaseModel, Field
logger = logging.getLogger("gateway")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
CLIP_URL = os.getenv("CLIP_URL", "http://clip:8000")
BLIP_URL = os.getenv("BLIP_URL", "http://blip:8000")
YOLO_URL = os.getenv("YOLO_URL", "http://yolo:8000")
QDRANT_SVC_URL = os.getenv("QDRANT_SVC_URL", "http://qdrant-svc:8000")
CARD_RENDERER_URL = os.getenv("CARD_RENDERER_URL", "http://card-renderer:8000")
MATURITY_URL = os.getenv("MATURITY_URL", "http://maturity:8000")
MATURITY_ENABLED = os.getenv("MATURITY_ENABLED", "true").lower() not in ("0", "false", "no")
VISION_TIMEOUT = float(os.getenv("VISION_TIMEOUT", "20"))
# API key (set via env var `API_KEY`). If not set, gateway will reject requests.
API_KEY = os.getenv("API_KEY")
# ---------------------------------------------------------------------------
# Shared persistent HTTP client — created once at startup, reused across all
# requests. This eliminates per-request TCP connect + DNS latency (the main
# cause of 20 s first-request latency observed on /vectors/inspect).
# ---------------------------------------------------------------------------
_http_client: httpx.AsyncClient | None = None
def get_http_client() -> httpx.AsyncClient:
"""Return the shared httpx client. Raises if called before lifespan starts."""
if _http_client is None:
raise RuntimeError("HTTP client not initialised — lifespan not running")
return _http_client
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: create shared HTTP client and warm upstream connections."""
global _http_client
t0 = time.perf_counter()
logger.info("gateway startup: creating shared HTTP client")
limits = httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
keepalive_expiry=30,
)
_http_client = httpx.AsyncClient(
timeout=httpx.Timeout(VISION_TIMEOUT, connect=10),
limits=limits,
)
# Warm the qdrant-svc connection so the first real request does not pay
# the TCP handshake + DNS cost. Failure is non-fatal — the service may
# still be starting when the gateway starts.
try:
t_warm = time.perf_counter()
r = await _http_client.get(f"{QDRANT_SVC_URL}/health", timeout=8)
logger.info(
"gateway startup: qdrant-svc warm ping done status=%s elapsed_ms=%.1f",
r.status_code, (time.perf_counter() - t_warm) * 1000,
)
except Exception as exc:
logger.warning("gateway startup: qdrant-svc warm ping failed (non-fatal): %s", exc)
logger.info("gateway startup complete elapsed_ms=%.1f", (time.perf_counter() - t0) * 1000)
yield # application runs
logger.info("gateway shutdown: closing shared HTTP client")
await _http_client.aclose()
_http_client = None
class APIKeyMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# allow health and docs endpoints without API key
@@ -30,7 +93,7 @@ class APIKeyMiddleware(BaseHTTPMiddleware):
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
return await call_next(request)
app = FastAPI(title="Skinbase Vision Gateway", version="1.0.0")
app = FastAPI(title="Skinbase Vision Gateway", version="1.0.0", lifespan=lifespan)
app.add_middleware(APIKeyMiddleware)
@@ -51,19 +114,26 @@ class YoloRequest(BaseModel):
conf: float = Field(default=0.25, ge=0.0, le=1.0)
async def _get_health(client: httpx.AsyncClient, base: str) -> Dict[str, Any]:
class MaturityRequest(BaseModel):
url: Optional[str] = None
async def _get_health(base: str) -> Dict[str, Any]:
try:
r = await client.get(f"{base}/health")
r = await get_http_client().get(f"{base}/health", timeout=5)
return r.json() if r.status_code == 200 else {"status": "bad", "code": r.status_code}
except Exception:
return {"status": "unreachable"}
async def _post_json(client: httpx.AsyncClient, url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
async def _post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
t0 = time.perf_counter()
try:
r = await client.post(url, json=payload)
r = await get_http_client().post(url, json=payload)
except httpx.RequestError as e:
raise HTTPException(status_code=502, detail=f"Upstream request failed {url}: {str(e)}")
elapsed = (time.perf_counter() - t0) * 1000
logger.debug("POST %s status=%s elapsed_ms=%.1f", url, r.status_code, elapsed)
if r.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Upstream error {url}: {r.status_code} {r.text[:1000]}")
try:
@@ -73,12 +143,15 @@ async def _post_json(client: httpx.AsyncClient, url: str, payload: Dict[str, Any
raise HTTPException(status_code=502, detail=f"Upstream returned non-JSON at {url}: {r.status_code} {r.text[:1000]}")
async def _post_file(client: httpx.AsyncClient, url: str, data: bytes, fields: Dict[str, Any]) -> Dict[str, Any]:
async def _post_file(url: str, data: bytes, fields: Dict[str, Any]) -> Dict[str, Any]:
files = {"file": ("image", data, "application/octet-stream")}
t0 = time.perf_counter()
try:
r = await client.post(url, data={k: str(v) for k, v in fields.items()}, files=files)
r = await get_http_client().post(url, data={k: str(v) for k, v in fields.items()}, files=files)
except httpx.RequestError as e:
raise HTTPException(status_code=502, detail=f"Upstream request failed {url}: {str(e)}")
elapsed = (time.perf_counter() - t0) * 1000
logger.debug("POST(file) %s status=%s elapsed_ms=%.1f", url, r.status_code, elapsed)
if r.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Upstream error {url}: {r.status_code} {r.text[:1000]}")
try:
@@ -87,11 +160,14 @@ async def _post_file(client: httpx.AsyncClient, url: str, data: bytes, fields: D
raise HTTPException(status_code=502, detail=f"Upstream returned non-JSON at {url}: {r.status_code} {r.text[:1000]}")
async def _get_json(client: httpx.AsyncClient, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
async def _get_json(url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
t0 = time.perf_counter()
try:
r = await client.get(url, params=params)
r = await get_http_client().get(url, params=params)
except httpx.RequestError as e:
raise HTTPException(status_code=502, detail=f"Upstream request failed {url}: {str(e)}")
elapsed = (time.perf_counter() - t0) * 1000
logger.debug("GET %s status=%s elapsed_ms=%.1f", url, r.status_code, elapsed)
if r.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Upstream error {url}: {r.status_code} {r.text[:1000]}")
try:
@@ -102,14 +178,25 @@ async def _get_json(client: httpx.AsyncClient, url: str, params: Optional[Dict[s
@app.get("/health")
async def health():
async with httpx.AsyncClient(timeout=5) as client:
clip_h, blip_h, yolo_h, qdrant_h = await asyncio.gather(
_get_health(client, CLIP_URL),
_get_health(client, BLIP_URL),
_get_health(client, YOLO_URL),
_get_health(client, QDRANT_SVC_URL),
)
return {"status": "ok", "services": {"clip": clip_h, "blip": blip_h, "yolo": yolo_h, "qdrant": qdrant_h}}
health_checks = [
_get_health(CLIP_URL),
_get_health(BLIP_URL),
_get_health(YOLO_URL),
_get_health(QDRANT_SVC_URL),
]
if MATURITY_ENABLED:
health_checks.append(_get_health(MATURITY_URL))
results = await asyncio.gather(*health_checks)
services: Dict[str, Any] = {
"clip": results[0],
"blip": results[1],
"yolo": results[2],
"qdrant": results[3],
}
if MATURITY_ENABLED:
services["maturity"] = results[4]
return {"status": "ok", "services": services}
# ---- Individual analyze endpoints (URL) ----
@@ -118,24 +205,21 @@ async def health():
async def analyze_clip(req: ClipRequest):
if not req.url:
raise HTTPException(400, "url is required")
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{CLIP_URL}/analyze", req.model_dump())
return await _post_json(f"{CLIP_URL}/analyze", req.model_dump())
@app.post("/analyze/blip")
async def analyze_blip(req: BlipRequest):
if not req.url:
raise HTTPException(400, "url is required")
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{BLIP_URL}/caption", req.model_dump())
return await _post_json(f"{BLIP_URL}/caption", req.model_dump())
@app.post("/analyze/yolo")
async def analyze_yolo(req: YoloRequest):
if not req.url:
raise HTTPException(400, "url is required")
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{YOLO_URL}/detect", req.model_dump())
return await _post_json(f"{YOLO_URL}/detect", req.model_dump())
# ---- Individual analyze endpoints (file upload) ----
@@ -151,8 +235,7 @@ async def analyze_clip_file(
fields: Dict[str, Any] = {"limit": int(limit)}
if threshold is not None:
fields["threshold"] = float(threshold)
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_file(client, f"{CLIP_URL}/analyze/file", data, fields)
return await _post_file(f"{CLIP_URL}/analyze/file", data, fields)
@app.post("/analyze/blip/file")
@@ -163,8 +246,7 @@ async def analyze_blip_file(
):
data = await file.read()
fields = {"variants": int(variants), "max_length": int(max_length)}
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_file(client, f"{BLIP_URL}/caption/file", data, fields)
return await _post_file(f"{BLIP_URL}/caption/file", data, fields)
@app.post("/analyze/yolo/file")
@@ -174,8 +256,7 @@ async def analyze_yolo_file(
):
data = await file.read()
fields = {"conf": float(conf)}
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_file(client, f"{YOLO_URL}/detect/file", data, fields)
return await _post_file(f"{YOLO_URL}/detect/file", data, fields)
@app.post("/analyze/all")
@@ -188,13 +269,11 @@ async def analyze_all(payload: Dict[str, Any]):
blip_req = {"url": url, "variants": int(payload.get("variants", 3)), "max_length": int(payload.get("max_length", 60))}
yolo_req = {"url": url, "conf": float(payload.get("conf", 0.25))}
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
clip_task = _post_json(client, f"{CLIP_URL}/analyze", clip_req)
blip_task = _post_json(client, f"{BLIP_URL}/caption", blip_req)
yolo_task = _post_json(client, f"{YOLO_URL}/detect", yolo_req)
clip_res, blip_res, yolo_res = await asyncio.gather(clip_task, blip_task, yolo_task)
clip_res, blip_res, yolo_res = await asyncio.gather(
_post_json(f"{CLIP_URL}/analyze", clip_req),
_post_json(f"{BLIP_URL}/caption", blip_req),
_post_json(f"{YOLO_URL}/detect", yolo_req),
)
return {"clip": clip_res, "blip": blip_res, "yolo": yolo_res}
@@ -202,8 +281,7 @@ async def analyze_all(payload: Dict[str, Any]):
@app.post("/vectors/upsert")
async def vectors_upsert(payload: Dict[str, Any]):
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{QDRANT_SVC_URL}/upsert", payload)
return await _post_json(f"{QDRANT_SVC_URL}/upsert", payload)
@app.post("/vectors/upsert/file")
@@ -221,20 +299,17 @@ async def vectors_upsert_file(
fields["collection"] = collection
if metadata_json is not None:
fields["metadata_json"] = metadata_json
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_file(client, f"{QDRANT_SVC_URL}/upsert/file", data, fields)
return await _post_file(f"{QDRANT_SVC_URL}/upsert/file", data, fields)
@app.post("/vectors/upsert/vector")
async def vectors_upsert_vector(payload: Dict[str, Any]):
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{QDRANT_SVC_URL}/upsert/vector", payload)
return await _post_json(f"{QDRANT_SVC_URL}/upsert/vector", payload)
@app.post("/vectors/search")
async def vectors_search(payload: Dict[str, Any]):
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{QDRANT_SVC_URL}/search", payload)
return await _post_json(f"{QDRANT_SVC_URL}/search", payload)
@app.post("/vectors/search/file")
@@ -258,72 +333,69 @@ async def vectors_search_file(
fields["hnsw_ef"] = int(hnsw_ef)
if filter_metadata_json is not None:
fields["filter_metadata_json"] = filter_metadata_json
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_file(client, f"{QDRANT_SVC_URL}/search/file", data, fields)
return await _post_file(f"{QDRANT_SVC_URL}/search/file", data, fields)
@app.post("/vectors/search/vector")
async def vectors_search_vector(payload: Dict[str, Any]):
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{QDRANT_SVC_URL}/search/vector", payload)
return await _post_json(f"{QDRANT_SVC_URL}/search/vector", payload)
@app.post("/vectors/delete")
async def vectors_delete(payload: Dict[str, Any]):
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{QDRANT_SVC_URL}/delete", payload)
return await _post_json(f"{QDRANT_SVC_URL}/delete", payload)
@app.get("/vectors/collections")
async def vectors_collections():
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _get_json(client, f"{QDRANT_SVC_URL}/collections")
return await _get_json(f"{QDRANT_SVC_URL}/collections")
@app.post("/vectors/collections")
async def vectors_create_collection(payload: Dict[str, Any]):
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{QDRANT_SVC_URL}/collections", payload)
return await _post_json(f"{QDRANT_SVC_URL}/collections", payload)
@app.get("/vectors/collections/{name}")
async def vectors_collection_info(name: str):
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _get_json(client, f"{QDRANT_SVC_URL}/collections/{name}")
return await _get_json(f"{QDRANT_SVC_URL}/collections/{name}")
@app.get("/vectors/inspect")
async def vectors_inspect():
"""Full diagnostic summary for all Qdrant collections (HNSW, optimizer, payload indexes, RAM estimate)."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _get_json(client, f"{QDRANT_SVC_URL}/inspect")
t0 = time.perf_counter()
logger.info("vectors_inspect: start")
result = await _get_json(f"{QDRANT_SVC_URL}/inspect")
logger.info("vectors_inspect: done elapsed_ms=%.1f", (time.perf_counter() - t0) * 1000)
return result
@app.delete("/vectors/collections/{name}")
async def vectors_delete_collection(name: str):
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
r = await client.delete(f"{QDRANT_SVC_URL}/collections/{name}")
if r.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Upstream error: {r.status_code}")
return r.json()
try:
r = await get_http_client().delete(f"{QDRANT_SVC_URL}/collections/{name}")
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"Upstream request failed: {exc}")
if r.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Upstream error: {r.status_code}")
return r.json()
@app.get("/vectors/points/{point_id}")
async def vectors_get_point(point_id: str, collection: Optional[str] = None):
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
params = {}
if collection:
params["collection"] = collection
return await _get_json(client, f"{QDRANT_SVC_URL}/points/{point_id}", params=params)
params = {}
if collection:
params["collection"] = collection
return await _get_json(f"{QDRANT_SVC_URL}/points/{point_id}", params=params)
@app.get("/vectors/points/by-original-id/{original_id}")
async def vectors_get_point_by_original_id(original_id: str, collection: Optional[str] = None):
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
params = {}
if collection:
params["collection"] = collection
return await _get_json(client, f"{QDRANT_SVC_URL}/points/by-original-id/{original_id}", params=params)
params = {}
if collection:
params["collection"] = collection
return await _get_json(f"{QDRANT_SVC_URL}/points/by-original-id/{original_id}", params=params)
# ---- File-based universal analyze ----
@@ -337,39 +409,69 @@ async def analyze_all_file(
max_length: int = Form(60),
):
data = await file.read()
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
clip_task = _post_file(client, f"{CLIP_URL}/analyze/file", data, {"limit": limit})
blip_task = _post_file(client, f"{BLIP_URL}/caption/file", data, {"variants": variants, "max_length": max_length})
yolo_task = _post_file(client, f"{YOLO_URL}/detect/file", data, {"conf": conf})
clip_res, blip_res, yolo_res = await asyncio.gather(clip_task, blip_task, yolo_task)
clip_res, blip_res, yolo_res = await asyncio.gather(
_post_file(f"{CLIP_URL}/analyze/file", data, {"limit": limit}),
_post_file(f"{BLIP_URL}/caption/file", data, {"variants": variants, "max_length": max_length}),
_post_file(f"{YOLO_URL}/detect/file", data, {"conf": conf}),
)
return {"clip": clip_res, "blip": blip_res, "yolo": yolo_res}
# ---- Maturity / NSFW analysis endpoints ----
def _assert_maturity_enabled() -> None:
if not MATURITY_ENABLED:
raise HTTPException(status_code=503, detail="Maturity service is disabled")
@app.post("/analyze/maturity")
async def analyze_maturity(req: MaturityRequest):
"""Analyze an image URL for maturity / NSFW content.
Returns a normalized maturity signal including maturity_label (safe/mature),
confidence, score, optional sublabels, and an action_hint for Nova moderation.
"""
_assert_maturity_enabled()
if not req.url:
raise HTTPException(status_code=400, detail="url is required")
logger.info("analyze_maturity: url=%s", req.url)
return await _post_json(f"{MATURITY_URL}/analyze", req.model_dump())
@app.post("/analyze/maturity/file")
async def analyze_maturity_file(file: UploadFile = File(...)):
"""Analyze an uploaded image file for maturity / NSFW content.
Returns the same normalized maturity signal as /analyze/maturity.
"""
_assert_maturity_enabled()
data = await file.read()
logger.info("analyze_maturity_file: filename=%s size=%d", file.filename, len(data))
return await _post_file(f"{MATURITY_URL}/analyze/file", data, {})
# ---- Card renderer endpoints ----
@app.get("/cards/templates")
async def cards_templates():
"""List available card templates."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _get_json(client, f"{CARD_RENDERER_URL}/templates")
return await _get_json(f"{CARD_RENDERER_URL}/templates")
@app.post("/cards/render")
async def cards_render(payload: Dict[str, Any]):
"""Render a Nova card from a remote image URL. Returns binary image bytes."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
try:
resp = await client.post(f"{CARD_RENDERER_URL}/render", json=payload)
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"card-renderer unreachable: {exc}")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"card-renderer error {resp.status_code}: {resp.text[:1000]}")
return Response(
content=resp.content,
media_type=resp.headers.get("content-type", "image/webp"),
)
try:
resp = await get_http_client().post(f"{CARD_RENDERER_URL}/render", json=payload)
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"card-renderer unreachable: {exc}")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"card-renderer error {resp.status_code}: {resp.text[:1000]}")
return Response(
content=resp.content,
media_type=resp.headers.get("content-type", "image/webp"),
)
@app.post("/cards/render/file")
@@ -409,28 +511,26 @@ async def cards_render_file(
fields["tags_json"] = tags_json
upload_files = {"file": (file.filename or "image", data, file.content_type or "application/octet-stream")}
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
try:
resp = await client.post(
f"{CARD_RENDERER_URL}/render/file",
data={k: str(v) for k, v in fields.items()},
files=upload_files,
)
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"card-renderer unreachable: {exc}")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"card-renderer error {resp.status_code}: {resp.text[:1000]}")
return Response(
content=resp.content,
media_type=resp.headers.get("content-type", "image/webp"),
try:
resp = await get_http_client().post(
f"{CARD_RENDERER_URL}/render/file",
data={k: str(v) for k, v in fields.items()},
files=upload_files,
)
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"card-renderer unreachable: {exc}")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"card-renderer error {resp.status_code}: {resp.text[:1000]}")
return Response(
content=resp.content,
media_type=resp.headers.get("content-type", "image/webp"),
)
@app.post("/cards/render/meta")
async def cards_render_meta(payload: Dict[str, Any]):
"""Return crop and layout metadata for a card render (no image produced)."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{CARD_RENDERER_URL}/render/meta", payload)
return await _post_json(f"{CARD_RENDERER_URL}/render/meta", payload)
# ---- Qdrant administration endpoints (index management + collection config) ----
@@ -438,26 +538,22 @@ async def cards_render_meta(payload: Dict[str, Any]):
@app.get("/vectors/collections/{name}/indexes")
async def vectors_collection_indexes(name: str):
"""List payload indexes for a collection."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _get_json(client, f"{QDRANT_SVC_URL}/collections/{name}/indexes")
return await _get_json(f"{QDRANT_SVC_URL}/collections/{name}/indexes")
@app.post("/vectors/collections/{name}/indexes")
async def vectors_create_payload_index(name: str, payload: Dict[str, Any]):
"""Create a payload index on a field in a collection."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{QDRANT_SVC_URL}/collections/{name}/indexes", payload)
return await _post_json(f"{QDRANT_SVC_URL}/collections/{name}/indexes", payload)
@app.post("/vectors/collections/{name}/ensure-indexes")
async def vectors_ensure_indexes(name: str, payload: Dict[str, Any]):
"""Idempotently ensure payload indexes exist for a list of fields."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{QDRANT_SVC_URL}/collections/{name}/ensure-indexes", payload)
return await _post_json(f"{QDRANT_SVC_URL}/collections/{name}/ensure-indexes", payload)
@app.post("/vectors/collections/{name}/configure")
async def vectors_configure_collection(name: str, payload: Dict[str, Any]):
"""Update HNSW and optimizer configuration for a collection."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{QDRANT_SVC_URL}/collections/{name}/configure", payload)
return await _post_json(f"{QDRANT_SVC_URL}/collections/{name}/configure", payload)