Files
watermarks-remover/service/scripts/format_dispatch.py
T
Guillaume Meyer (The Opinionated Man)andGitHub 55d4bdc9fc feat: split skill from service, add HTTP API and Docker distribution (#60)
* feat: split skill from service, add HTTP API and Docker distribution

The agent skill (skills/remove-ai-marks/) is now a code-free remote client:
all implementation moved to service/scripts/ and runs behind a stdlib HTTP
service (server.py) with /health, /capabilities, /inspect, /clean and a
dynamically generated OpenAPI 3.0.3 spec at /openapi.json.

- Move scripts/ and the backend Dockerfiles under service/
- server.py: JSON/base64 HTTP entrypoint with size caps, binary guard,
  atomic writes, loopback default, optional bearer auth
- Core Dockerfile (exiftool/qpdf/c2patool preinstalled) and a GHCR publish
  workflow for the core/markllm/markdiffusion images
- compose.yaml (wr-* services, harness/heavy profiles) + compose-check.sh
  to validate the running stack (exit code only)
- Fix markllm image build (tokenizers 0.22.2, CPU-only torch) and ctrlregen
  build (python:3.11 base for the 2023-era research pins)
- Fix markllm/markdiffusion harness images missing common.py at runtime

* docs: add .env.example and service configuration guide

* fix: disable chain-of-thought for openai-compatible Layer B rewrites

deepseek-v4-flash is a reasoning model: a one-line paraphrase burned 9,894
reasoning tokens (~100s) and hit the default timeout. Send
reasoning_effort=none by default for the openai-compatible backend
(--reasoning-effort / WATERMARKS_REWRITE_REASONING_EFFORT; 'off' omits the
parameter), cutting the same rewrite to ~1s / 12 tokens. Tested end-to-end
against api.deepseek.com.

* fix: sanitize client-supplied filename in HTTP service

CodeQL 'uncontrolled data in path expression' (server.py): a name like
'../../x' flowed into Path(tmpdir) / name, letting an upload escape the
request temp dir on write. Sanitize name to its basename in _decode_input
(_safe_name) and refuse any joined path whose parent is not the tmpdir at
the write sites (_tmp_path). Tests cover traversal names.

* chore: gitignore .env (contains local rewrite credentials)

* chore: deny-by-default gitignore and dockerignore; document compose env config

.gitignore and service/.dockerignore now exclude everything by default and
explicitly allow only what is publishable/needed: tracked source, docs,
tests, .github, and (for images) the service/scripts/ tree that every
Dockerfile COPYs. Root .dockerignore documents that all builds use service/
as context. README Configuration section now covers .env setup for docker
compose, host-side export for CLI runs, and the full variable table.
2026-08-14 15:42:48 -07:00

77 lines
2.1 KiB
Python

"""Route a file or byte stream to the text, image or container pipeline.
The routers (inspect_file, clean_file) and the audits (audit_lib) all need the
same answer: given a path or bytes, which pipeline owns it? That decision used
to live in three copies with subtly different extension tables and sniffing.
This module is the single interface for it.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from container_meta import detect_container_format
from image_meta import detect_format as detect_image_format
Kind = Literal["text", "image", "container"]
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
CONTAINER_EXTS = {
".svg",
".pdf",
".docx",
".odt",
".html",
".htm",
".md",
".markdown",
".mdx",
}
TEXT_EXTS = {
".txt",
".text",
".css",
".js",
".py",
".rs",
".go",
".json",
".yaml",
".yml",
".toml",
".csv",
}
def classify_bytes(data: bytes, suffix: str | None = None) -> Kind:
"""Classify *data* by extension first, then by magic bytes.
The extension wins when it names a known format; otherwise the bytes are
sniffed for image/container signatures. Unrecognized bytes fall back to
"text" — callers that must not mangle unknown binaries guard themselves.
*data* must cover the whole file: zip-based containers (docx/odt) are
detected from their central directory, which sits at the end of the bytes.
"""
ext = (suffix or "").lower()
if ext in IMAGE_EXTS:
return "image"
if ext in CONTAINER_EXTS:
return "container"
if ext in TEXT_EXTS:
return "text"
if detect_image_format(data) in ("png", "jpeg", "webp"):
return "image"
if data:
sniff_path = Path("input") if not ext else Path(f"input{ext}")
if detect_container_format(sniff_path, data) != "unknown":
return "container"
return "text"
def classify(path: Path) -> Kind:
"""Classify a file on disk by extension, then by its bytes."""
data = path.read_bytes()
return classify_bytes(data, path.suffix)