feat(server): add POST /detect/batch endpoint for batch watermark detection (#151)

* feat(server): add POST /detect/batch endpoint for batch watermark detection (#149)

* fix: satisfy ruff lint and format checks

---------

Co-authored-by: Guillaume Meyer (The Opinionated Man) <1385518+guillaumemeyer@users.noreply.github.com>
This commit is contained in:
Sparsh :)
2026-08-18 13:38:23 -07:00
committed by GitHub
co-authored by Guillaume Meyer
parent 939df90ccd
commit 063119d7e5
2 changed files with 194 additions and 43 deletions
+121 -43
View File
@@ -14,10 +14,12 @@ Endpoints:
-> {"cleaned": <base64>, "report": {...}}
POST /inspect/batch -> {"files": [{"file": <base64>, "name": "x.png"}, ...]}
-> {"results": [{"name", "ok", "kind", "report", "suspicious"}, ...]}
POST /detect/batch -> {"files": [{"file": <base64>, "name": "x.txt"}, ...]}
-> {"results": [{"name", "ok", "kind", "detections", "report"}, ...]}
POST /clean/batch -> {"files": [{"file": <base64>, "name": "x.png", "options": {...}}, ...]}
-> {"results": [{"name", "ok", "kind", "cleaned", "report"}, ...]}
Batch endpoints loop the same single-file pipeline as /inspect and /clean; a
Batch endpoints loop the same single-file pipeline as /inspect, /detect, and /clean; a
per-file failure (unknown format, oversized name, bad option) shows up as
that entry's "ok": false with an "error" string and never aborts the rest of
the batch. Capped at WATERMARKS_MAX_BATCH_FILES entries per request (default
@@ -346,6 +348,50 @@ _OPENAPI_PATHS: dict[str, dict[str, Any]] = {
},
}
},
"/detect/batch": {
"post": {
"summary": f"Run watermark detectors on up to {MAX_BATCH_FILES} files in one request",
"requestBody": _schema(
required=True,
content={
"application/json": _schema(
schema=_schema(
type="object",
required=["files"],
properties={"files": _schema(type="array", items=_file_request())},
)
)
},
),
"responses": {
"200": _schema(
type="object",
properties={
"ok": _schema(type="boolean"),
"results": _schema(
type="array",
items=_schema(
type="object",
properties={
"name": _schema(type="string"),
"ok": _schema(type="boolean"),
"kind": _schema(
type="string",
enum=["text", "image", "container", "av"],
),
"detections": _schema(
type="array", items=_schema(type="object")
),
"report": _schema(type="object"),
"error": _schema(type="string"),
},
),
),
},
)
},
}
},
"/clean/batch": {
"post": {
"summary": f"Clean up to {MAX_BATCH_FILES} files in one request",
@@ -589,6 +635,54 @@ def _inspect_payload(data: bytes, name: str, run_detect: bool) -> dict[str, Any]
return {"ok": True, "kind": kind, "report": report, "suspicious": suspicious}
def _detect_payload(data: bytes, name: str) -> dict[str, Any]:
kind = classify_bytes(data, Path(name).suffix)
with tempfile.TemporaryDirectory(prefix="wm-detect-") as tmp:
path = _tmp_path(Path(tmp), name or "input")
path.write_bytes(data)
if kind == "text":
if looks_binary(data):
raise ValueError(
"refusing to detect bytes that look like a binary container as text"
)
raw_text = data.decode("utf-8", errors="surrogateescape")
detections: list[dict[str, Any]] = run_all_text_detectors(raw_text)
s_rep = score_text_stylometry(raw_text, path=name or "<text>")
detections.append({"detector": "stylometry", "available": True, **s_rep.to_dict()})
return {"ok": True, "kind": kind, "detections": detections}
elif kind == "image":
score = run_synthid_score(path)
if score is None:
score = {
"detector": "synthid",
"available": False,
"error": (
"no SynthID scorer configured (set "
"WATERMARKS_SYNTHID_SCORER_URL or REVERSE_SYNTHID_DIR)"
),
}
else:
score.setdefault("detector", "synthid")
detections = [score]
return {"ok": True, "kind": kind, "detections": detections}
elif kind == "av":
return {
"ok": True,
"kind": kind,
"detections": [],
"report": inspect_av(path).to_dict(),
}
else:
detections = []
report = inspect_container(path).to_dict()
return {
"ok": True,
"kind": kind,
"detections": detections,
"report": report,
}
def _clean_payload(data: bytes, name: str, options: dict[str, Any]) -> dict[str, Any]:
kind = classify_bytes(data, Path(name).suffix)
if kind == "unknown":
@@ -751,7 +845,14 @@ class Handler(BaseHTTPRequestHandler):
if not self._authorized():
self._respond(HTTPStatus.UNAUTHORIZED, {"ok": False, "error": "unauthorized"})
return
if path not in ("/inspect", "/clean", "/detect", "/inspect/batch", "/clean/batch"):
if path not in (
"/inspect",
"/clean",
"/detect",
"/inspect/batch",
"/detect/batch",
"/clean/batch",
):
self._respond(HTTPStatus.NOT_FOUND, {"ok": False, "error": "not found"})
return
body = self._read_json()
@@ -766,6 +867,8 @@ class Handler(BaseHTTPRequestHandler):
try:
if path == "/inspect/batch":
self._handle_inspect_batch(body)
elif path == "/detect/batch":
self._handle_detect_batch(body)
elif path == "/clean/batch":
self._handle_clean_batch(body)
else:
@@ -805,47 +908,22 @@ class Handler(BaseHTTPRequestHandler):
self._respond(HTTPStatus.OK, {"ok": True, "results": results})
def _handle_detect(self, data: bytes, name: str) -> None:
kind = classify_bytes(data, Path(name).suffix)
with tempfile.TemporaryDirectory(prefix="wm-detect-") as tmp:
path = _tmp_path(Path(tmp), name or "input")
path.write_bytes(data)
if kind == "text":
if looks_binary(data):
raise ValueError(
"refusing to detect bytes that look like a binary container as text"
)
raw_text = data.decode("utf-8", errors="surrogateescape")
detections: list[dict[str, Any]] = run_all_text_detectors(raw_text)
s_rep = score_text_stylometry(raw_text, path=name or "<text>")
detections.append({"detector": "stylometry", "available": True, **s_rep.to_dict()})
elif kind == "image":
score = run_synthid_score(path)
if score is None:
score = {
"detector": "synthid",
"available": False,
"error": (
"no SynthID scorer configured (set "
"WATERMARKS_SYNTHID_SCORER_URL or REVERSE_SYNTHID_DIR)"
),
}
else:
score.setdefault("detector", "synthid")
detections = [score]
else:
detections = []
report = inspect_container(path).to_dict()
self._respond(
HTTPStatus.OK,
{
"ok": True,
"kind": kind,
"detections": detections,
"report": report,
},
)
return
self._respond(HTTPStatus.OK, {"ok": True, "kind": kind, "detections": detections})
self._respond(HTTPStatus.OK, _detect_payload(data, name))
def _handle_detect_batch(self, body: dict[str, Any]) -> None:
items = _batch_items(body)
results = []
for name, data, _options, error in items:
if error is not None:
results.append({"name": name, "ok": False, "error": error})
continue
try:
payload = _detect_payload(data, name)
except ValueError as e:
results.append({"name": name, "ok": False, "error": str(e)})
continue
results.append({"name": name, **payload})
self._respond(HTTPStatus.OK, {"ok": True, "results": results})
def _handle_clean(self, data: bytes, name: str, body: dict[str, Any]) -> None:
options = _parse_clean_options(body.get("options"))
+73
View File
@@ -253,3 +253,76 @@ def test_detect_image_no_scorer(conn):
assert status == 200
assert body["kind"] == "image"
assert body["detections"][0]["available"] is False
def test_detect_batch_text_files(conn):
status, body = _post(
conn,
"/detect/batch",
{
"files": [
{"file": _b64(b"Hello world from simple clean text."), "name": "doc1.txt"},
{"file": _b64(b"Second document test."), "name": "doc2.txt"},
]
},
)
assert status == 200
assert body["ok"] is True
assert len(body["results"]) == 2
res1, res2 = body["results"]
assert res1["name"] == "doc1.txt"
assert res1["ok"] is True
assert res1["kind"] == "text"
assert any(d["detector"] == "stylometry" for d in res1["detections"])
assert res2["name"] == "doc2.txt"
assert res2["ok"] is True
def test_detect_batch_mixed_formats(conn):
status, body = _post(
conn,
"/detect/batch",
{
"files": [
{"file": _b64(b"Plain text note"), "name": "a.txt"},
{"file": _b64(_watermarked_png()), "name": "b.png"},
]
},
)
assert status == 200
assert body["ok"] is True
results = {r["name"]: r for r in body["results"]}
assert results["a.txt"]["kind"] == "text"
assert results["b.png"]["kind"] == "image"
def test_detect_batch_bad_entry_does_not_abort_others(conn):
status, body = _post(
conn,
"/detect/batch",
{
"files": [
{"file": "!!!not_base64!!!", "name": "bad.txt"},
{"file": _b64(b"Valid text"), "name": "good.txt"},
]
},
)
assert status == 200
assert body["ok"] is True
results = {r["name"]: r for r in body["results"]}
assert results["bad.txt"]["ok"] is False
assert "base64" in results["bad.txt"]["error"]
assert results["good.txt"]["ok"] is True
def test_detect_batch_empty_rejected(conn):
status, body = _post(conn, "/detect/batch", {"files": []})
assert status == 400
assert "must not be empty" in body["error"]
def test_detect_batch_openapi_spec_registered(conn):
status, body = _get(conn, "/openapi.json")
assert status == 200
assert "/detect/batch" in body["paths"]
assert "post" in body["paths"]["/detect/batch"]