think
16px
820px

Task 6 report — ClassifyAdapter (interactive-priority classify path)

Branch: feat/gateway-p2-jobs-ocr · base HEAD: 57e973d · package internal/jobs

Note: this file previously held a stale P1 streaming report reusing the same
task-6 filename; it has been replaced with the P2 gateway Task 6 report.

What shipped

  • internal/jobs/adapter_classify.go — new ClassifyAdapter implementing Adapter.
  • internal/jobs/adapter_classify_test.go — 10 table-free, namespace-safe tests (t6-* tenants, T6_* env) with an httptest stub classifier.
  • internal/jobs/adapter.go (MODIFY) — extracted the shared synchronous POST core syncPost(...) and refactored SyncHTTPAdapter onto it (see "How it shares" below).

How it shares vs. differs from SyncHTTPAdapter

Classify is a plain sync request/response call, so rather than copy-paste I extracted the common core into a package-level helper and pointed BOTH adapters at it:

syncPost(ctx, client, upstream, job, payload, target, maxBytes) (json.RawMessage, error)

syncPost owns everything the two adapters share, per the Task 4 sync contract:
- builds POST target with the payload;
- forwards the job's submit Content-Type (default application/json);
- server-side API-key injection from Upstream.APIKeyEnv (Authorization: Bearer …) — a client key is never forwarded;
- error mapping: 2xx → body verbatim; 429/503 → AdapterError{Retryable:true, RetryAfter}; anything else → AdapterError{Retryable:false, Code:"UPSTREAM_<n>"};
- ctx deadline/cancel → prompt ADAPTER_TIMEOUT (no goroutine/body leak);
- bounded body read via the shared readCapped/io.LimitReader helper (from adapter_azuredi.go) — no unbounded io.ReadAll; body closed on every path.

ClassifyAdapter stays a distinct type the manager can select for classify-operation upstreams. It differs from SyncHTTPAdapter only in:
- default submit path /classify (vs. /ocr), still overridable by Upstream.SubmitPath;
- being the type the manager routes classify to and applies interactive-priority defaults around (at submit, Task 7).

No priority/class is hardcoded in the adapter — per the brief, Job.Class is decided at submit in Task 7. The adapter just calls report("processing", 0) then syncPost.

Side benefit: the refactor gives SyncHTTPAdapter the same bounded read (it previously used an unbounded io.ReadAll, flagged in Task 4). Behavior is otherwise byte-identical; all pre-existing sync/azure tests stay green.

Verbatim passthrough proof (incl. low-confidence — NOT gated)

TestClassifyAdapter_Verbatim asserts a full {label,confidence,scores{…}} body returns byte-for-byte, with default path /classify, POST, intact payload, forwarded content-type, and report(processing,0) first.

TestClassifyAdapter_LowConfidencePassthrough is the contract-critical case: the stub returns {"label":"unknown","confidence":0.3} and the test asserts the result bytes are identical (string(res) == lowConf) and re-parse to {unknown, 0.3}. The gateway does not editorialize or gate on confidence — the OCR engine applies its own classifierMinConfidence downstream.

RED → GREEN

  • RED: with adapter_classify.go moved aside, go test ./internal/jobs/undefined: ClassifyAdapter (build failed).
  • GREEN: impl restored → all 10 TestClassifyAdapter_* pass under -race; full internal/jobs package passes -race.

Test coverage: verbatim 200; low-confidence passthrough (unchanged); 500 non-retryable w/ code+status; 429 retryable w/ Retry-After; 503 retryable; ctx timeout & ctx cancel (prompt ADAPTER_TIMEOUT, no leak, 2s watchdog); API key injected when env set; absent when unset; bounded read caps an 8×-oversize body at MaxBytes.

Gate results

  • go build ./... — OK
  • go vet ./... — OK
  • go test -race ./internal/jobs/... — ok (classify + all pre-existing jobs tests)

Concerns / notes for downstream

  • The syncPost refactor touches shipped Task 4 code. It is behavior-preserving except for adding the bounded read (a strict improvement, matching the Azure DI adapter's cap). Worth a glance in the whole-branch review.
  • defaultSyncMaxBytes = 32<<20 is shared by both sync + classify; classify responses are tiny, so this is generous headroom, not a tight bound. Overridable per-adapter via MaxBytes.
  • Interactive-class default for classify is not here by design; it must be wired at submit in Task 7 (default classify → interactive).

Files

  • internal/jobs/adapter_classify.go (new)
  • internal/jobs/adapter_classify_test.go (new)
  • internal/jobs/adapter.go (modified — shared syncPost extraction)