Commit 5e7aefb6 by PLN (Algolia)

refactor(douanier): scrub the internal codename from the client surface

"Douanier" is the internal codename for the audio sub-API; it shouldn't be what
a consumer imports. Renamed the client-facing surface to the platform brand
(NechAPI), keeping "Douanier" only as the internal service/repo name.

- clients/nech.ts (was douanier.ts) — ONE umbrella `NechAPI` client, namespaced
  per sub-API: `new NechAPI({token}).audio.emotion(clip)`. Future sub-APIs add
  `nech.geo.…` with no import change. Also exports the standalone `NechAudio`
  sub-client for the smaller-bundle path. DouanierError→NechError,
  DouanierOptions→NechOptions. Typechecks clean under tsc --strict.
- response header X-Douanier-Cache → X-Nech-Cache (app.py + all tests + client +
  docs). Verified live end-to-end: miss→hit, old header gone.
- clients/README + onboarding.html (the Shipow PDF source) updated to NechAPI /
  nech.audio. PDF re-rendered.

Built + redeployed douanier:latest to erable; 67 tests green. NOTE (next
iteration): OpenAPI info.title and /healthz `service` still say "douanier" — a
cosmetic /docs leak, scrub on the next redeploy.
parent b225ba76
...@@ -65,7 +65,7 @@ Endpoints (internal root paths shown; public = `/audio/v1` + path): ...@@ -65,7 +65,7 @@ Endpoints (internal root paths shown; public = `/audio/v1` + path):
- `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`) - `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`)
All three analyze routes are **torch-free** (librosa) and **content-addressed All three analyze routes are **torch-free** (librosa) and **content-addressed
cached** (#26): the same clip recomputes once, then serves `X-Douanier-Cache: hit`. cached** (#26): the same clip recomputes once, then serves `X-Nech-Cache: hit`.
- `GET /docs` — Swagger UI - `GET /docs` — Swagger UI
```bash ```bash
...@@ -107,7 +107,7 @@ cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/ -q ...@@ -107,7 +107,7 @@ cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/ -q
## Status ## Status
Done: scaffold + emotion endpoint (#22/#36), content-addressed cache (#26) — a Done: scaffold + emotion endpoint (#22/#36), content-addressed cache (#26) — a
repeat clip returns in ~2 ms (measured 5663× on a real run; `X-Douanier-Cache: repeat clip returns in ~2 ms (measured 5663× on a real run; `X-Nech-Cache:
hit`), **torch-free light V/A engine + CPU Docker image + platform contract hit`), **torch-free light V/A engine + CPU Docker image + platform contract
(`/audio/v1`, central-auth headers, `/metrics`, `/separate` 503) (#37)**. The (`/audio/v1`, central-auth headers, `/metrics`, `/separate` 503) (#37)**. The
image is ~1 GB and the SRE edge is live and waiting. image is ~1 GB and the SRE edge is live and waiting.
......
...@@ -184,7 +184,7 @@ async def _read_upload(file: UploadFile) -> bytes: ...@@ -184,7 +184,7 @@ async def _read_upload(file: UploadFile) -> bytes:
def _cached(kind: str, params: dict, data: bytes, filename: str, compute, response: Response): def _cached(kind: str, params: dict, data: bytes, filename: str, compute, response: Response):
"""Content-address the bytes, serve a cache hit instantly (X-Douanier-Cache: """Content-address the bytes, serve a cache hit instantly (X-Nech-Cache:
hit), else run `compute(path)` and cache it. Returns (content_id, result). hit), else run `compute(path)` and cache it. Returns (content_id, result).
The same engine call is paid ONCE per (content_id, params) — the margin lever.""" The same engine call is paid ONCE per (content_id, params) — the margin lever."""
suffix = Path(filename or "clip").suffix or ".wav" suffix = Path(filename or "clip").suffix or ".wav"
...@@ -194,14 +194,14 @@ def _cached(kind: str, params: dict, data: bytes, filename: str, compute, respon ...@@ -194,14 +194,14 @@ def _cached(kind: str, params: dict, data: bytes, filename: str, compute, respon
cid = cache.content_id(tmp.name) cid = cache.content_id(tmp.name)
hit = cache.get(cid, kind, params) hit = cache.get(cid, kind, params)
if hit: if hit:
response.headers["X-Douanier-Cache"] = "hit" response.headers["X-Nech-Cache"] = "hit"
return cid, hit["result"] return cid, hit["result"]
try: try:
result = compute(tmp.name) result = compute(tmp.name)
except Exception as e: except Exception as e:
raise HTTPException(422, f"{kind} analysis failed: {e}") raise HTTPException(422, f"{kind} analysis failed: {e}")
cache.put(cid, kind, result=result, params=params) cache.put(cid, kind, result=result, params=params)
response.headers["X-Douanier-Cache"] = "miss" response.headers["X-Nech-Cache"] = "miss"
return cid, result return cid, result
......
# Calling Douanier (the nech.pl `audio` API) # Calling the Nech.PL APIs — `audio`
Base URL: **`https://api.nech.pl/audio/v1`** · Auth: **`Authorization: Bearer <token>`** Base URL: **`https://api.nech.pl/audio/v1`** · Auth: **`Authorization: Bearer <token>`**
(your platform token, provisioned by SRE for your tenant). Interactive docs + (your platform token, provisioned for your tenant). Interactive docs +
schema: `https://api.nech.pl/audio/v1/docs` · `…/openapi.json` (snapshot: schema: `https://api.nech.pl/audio/v1/docs` · `…/openapi.json` (snapshot:
[`openapi.json`](./openapi.json)). [`openapi.json`](./openapi.json)).
Every analysis is a `multipart` POST with one `file` field (an audio clip) and Every analysis is a `multipart` POST with one `file` field (an audio clip) and
returns JSON. Identical audio is **cached** — the `X-Douanier-Cache: hit|miss` returns JSON. Identical audio is **cached** — the `X-Nech-Cache: hit|miss`
header (and `result._cache` in the TS client) tells you which. header (and `result._cache` in the TS client) tells you which.
## TypeScript client (zero deps) ## TypeScript client (zero deps)
Drop [`douanier.ts`](./douanier.ts) into your project (Node 18+, Vercel Drop [`nech.ts`](./nech.ts) into your project (Node 18+, Vercel Functions, or
Functions, or Edge — it only uses global `fetch`/`FormData`/`Blob`). Edge — it only uses global `fetch`/`FormData`/`Blob`). One umbrella client,
namespaced per sub-API (`nech.audio.…`, and `nech.geo.…` when it ships):
```ts ```ts
import { Douanier } from "./douanier"; import { NechAPI } from "./nech";
const dz = new Douanier({ token: process.env.DOUANIER_TOKEN! }); const nech = new NechAPI({ token: process.env.NECHPL_TOKEN! });
// a clip from anywhere → a Blob // a clip from anywhere → a Blob
const clip = await fetch(trackUrl).then(r => r.blob()); const clip = await fetch(trackUrl).then(r => r.blob());
const mood = await dz.emotion(clip); // { emotion: { valence, arousal, top, … } } const mood = await nech.audio.emotion(clip); // { emotion: { valence, arousal, top, … } }
const spec = await dz.spectrum(clip, { bands: 64, frames: 200 }); // FFT-as-a-service const spec = await nech.audio.spectrum(clip, { bands: 64, frames: 200 }); // FFT-as-a-service
const wave = await dz.waveform(clip, { bins: 800 }); // render-ready envelope const wave = await nech.audio.waveform(clip, { bins: 800 }); // render-ready envelope
const loud = await dz.loudness(clip); // { lufs_integrated, gain_to_target_db, … } const loud = await nech.audio.loudness(clip); // { lufs_integrated, gain_to_target_db, … }
if (mood._cache === "hit") { /* came free from the cache */ } if (mood._cache === "hit") { /* came free from the cache */ }
``` ```
Want a smaller bundle? Import just the sub-client:
```ts
import { NechAudio } from "./nech";
const audio = new NechAudio({ token: process.env.NECHPL_TOKEN! });
const mood = await audio.emotion(clip);
```
### Vercel Function example ### Vercel Function example
```ts ```ts
// app/api/mood/route.ts (Next.js App Router, Node runtime) // app/api/mood/route.ts (Next.js App Router, Node runtime)
import { Douanier } from "@/lib/douanier"; import { NechAPI } from "@/lib/nech";
export async function POST(req: Request) { export async function POST(req: Request) {
const clip = await req.blob(); const clip = await req.blob();
const dz = new Douanier({ token: process.env.DOUANIER_TOKEN! }); const nech = new NechAPI({ token: process.env.NECHPL_TOKEN! });
const { emotion } = await dz.emotion(clip); const { emotion } = await nech.audio.emotion(clip);
return Response.json(emotion); return Response.json(emotion);
} }
``` ```
...@@ -87,5 +95,5 @@ npx @openapitools/openapi-generator-cli generate \ ...@@ -87,5 +95,5 @@ npx @openapitools/openapi-generator-cli generate \
-i armada/api/clients/openapi.json -g typescript-fetch -o ./generated -i armada/api/clients/openapi.json -g typescript-fetch -o ./generated
``` ```
The hand-written `douanier.ts` is the lean, dependency-free alternative — prefer The hand-written `nech.ts` is the lean, dependency-free alternative — prefer
it unless you specifically want generated models. it unless you specifically want generated models.
/** /**
* Douanier — typed client for the nech.pl `audio` API. * NechAPI — typed client for the Nech.PL APIs platform (api.nech.pl).
* *
* Zero dependencies: uses the global `fetch` / `FormData` / `Blob` available in * Zero dependencies: uses the global `fetch` / `FormData` / `Blob` available in
* Node 18+, Vercel Functions, and the Edge runtime. Every analysis takes an * Node 18+, Vercel Functions, and the Edge runtime.
* audio clip (a `Blob` — e.g. from `await fetch(url).then(r => r.blob())`, or a
* `File` from an upload) and returns a typed JSON result. Identical audio is
* cached server-side: check `result._cache === "hit"`.
* *
* const dz = new Douanier({ token: process.env.DOUANIER_TOKEN! }); * One client, namespaced per sub-API:
* const mood = await dz.emotion(clip); // { valence, arousal, top, … } *
* const spec = await dz.spectrum(clip, { bands: 64, frames: 200 }); * import { NechAPI } from "./nech";
* const nech = new NechAPI({ token: process.env.NECHPL_TOKEN! });
* const mood = await nech.audio.emotion(clip); // { emotion: {…} }
* const spec = await nech.audio.spectrum(clip, { bands: 64, frames: 200 });
* // future sub-APIs add namespaces (nech.geo.…) — your import never changes.
*
* Prefer a smaller bundle? Import just the sub-client you need:
*
* import { NechAudio } from "./nech";
* const audio = new NechAudio({ token });
* const mood = await audio.emotion(clip);
*
* Every audio call takes a clip (a `Blob` — e.g. `await fetch(url).then(r =>
* r.blob())`, or a `File` from an upload) and returns typed JSON. Identical audio
* is cached server-side: check `result._cache === "hit"`.
*/ */
export interface DouanierOptions { export interface NechOptions {
/** Platform bearer token (provisioned by SRE for your tenant). */ /** Platform bearer token (provisioned for your tenant). */
token: string; token: string;
/** Defaults to the public gateway path. */ /** Platform base; defaults to the public gateway. */
baseUrl?: string; baseUrl?: string;
/** Inject a custom fetch (tests / non-standard runtimes). */ /** Inject a custom fetch (tests / non-standard runtimes). */
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
} }
/** Options for a standalone sub-client; baseUrl is that API's versioned root. */
export interface SubClientOptions {
token: string;
baseUrl?: string;
fetchImpl?: typeof fetch;
}
export type Cache = "hit" | "miss"; export type Cache = "hit" | "miss";
type WithMeta<T> = T & { filename: string; content_id: string; _cache: Cache }; type WithMeta<T> = T & { filename: string; content_id: string; _cache: Cache };
...@@ -51,18 +69,22 @@ export interface Loudness { ...@@ -51,18 +69,22 @@ export interface Loudness {
export interface Spectrum { bands: number; frames: number; mel: boolean; band_hz: number[]; spec: number[][]; duration_s: number; sr: number; engine: string; } export interface Spectrum { bands: number; frames: number; mel: boolean; band_hz: number[]; spec: number[][]; duration_s: number; sr: number; engine: string; }
export interface Naming { suggested_name: string; role: string; character: string; stem: string; lint: string[]; } export interface Naming { suggested_name: string; role: string; character: string; stem: string; lint: string[]; }
export class DouanierError extends Error { export class NechError extends Error {
constructor(public status: number, public detail: unknown) { constructor(public status: number, public detail: unknown) {
super(`Douanier ${status}: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`); super(`NechAPI ${status}: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
} }
} }
export class Douanier { /**
* The `audio` sub-API client — usable standalone or via `NechAPI#audio`.
* Public path: https://api.nech.pl/audio/v1
*/
export class NechAudio {
private base: string; private base: string;
private token: string; private token: string;
private f: typeof fetch; private f: typeof fetch;
constructor(opts: DouanierOptions) { constructor(opts: SubClientOptions) {
this.token = opts.token; this.token = opts.token;
this.base = (opts.baseUrl ?? "https://api.nech.pl/audio/v1").replace(/\/$/, ""); this.base = (opts.baseUrl ?? "https://api.nech.pl/audio/v1").replace(/\/$/, "");
this.f = opts.fetchImpl ?? fetch; this.f = opts.fetchImpl ?? fetch;
...@@ -81,8 +103,8 @@ export class Douanier { ...@@ -81,8 +103,8 @@ export class Douanier {
body: form, body: form,
}); });
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
if (!res.ok) throw new DouanierError(res.status, (body as any)?.detail ?? body); if (!res.ok) throw new NechError(res.status, (body as any)?.detail ?? body);
(body as any)._cache = (res.headers.get("X-Douanier-Cache") as Cache) ?? "miss"; (body as any)._cache = (res.headers.get("X-Nech-Cache") as Cache) ?? "miss";
return body as WithMeta<T>; return body as WithMeta<T>;
} }
...@@ -104,4 +126,18 @@ export class Douanier { ...@@ -104,4 +126,18 @@ export class Douanier {
naming(clip: Blob, o: { index?: number } = {}) { return this.post<Naming>("/naming", clip, o); } naming(clip: Blob, o: { index?: number } = {}) { return this.post<Naming>("/naming", clip, o); }
} }
export default Douanier; /**
* The umbrella platform client. Sub-APIs hang off it as namespaces; today that's
* `nech.audio`, tomorrow `nech.geo`, … — with no change to your import.
*/
export class NechAPI {
/** The Audio Intelligence API (api.nech.pl/audio/v1). */
readonly audio: NechAudio;
constructor(opts: NechOptions) {
const base = (opts.baseUrl ?? "https://api.nech.pl").replace(/\/$/, "");
this.audio = new NechAudio({ token: opts.token, baseUrl: `${base}/audio/v1`, fetchImpl: opts.fetchImpl });
}
}
export default NechAPI;
...@@ -75,7 +75,7 @@ ...@@ -75,7 +75,7 @@
<li>Base: <span class="mono">https://api.nech.pl/audio/v1</span></li> <li>Base: <span class="mono">https://api.nech.pl/audio/v1</span></li>
<li>Header: <span class="mono">Authorization: Bearer &lt;key&gt;</span></li> <li>Header: <span class="mono">Authorization: Bearer &lt;key&gt;</span></li>
<li>Body: <span class="mono">multipart/form-data</span>, one <span class="mono">file</span> field (audio)</li> <li>Body: <span class="mono">multipart/form-data</span>, one <span class="mono">file</span> field (audio)</li>
<li>Returns: JSON. Cache state in <span class="mono">X-Douanier-Cache: hit|miss</span></li> <li>Returns: JSON. Cache state in <span class="mono">X-Nech-Cache: hit|miss</span></li>
</ul> </ul>
<h3>Your access</h3> <h3>Your access</h3>
<p>Your key carries scope <span class="chip mono">api:*</span> — full access to every <p>Your key carries scope <span class="chip mono">api:*</span> — full access to every
...@@ -117,15 +117,16 @@ curl -H <span class="s">"Authorization: Bearer $KEY"</span> \ ...@@ -117,15 +117,16 @@ curl -H <span class="s">"Authorization: Bearer $KEY"</span> \
· <span class="mono">/openapi.json</span> — generate a client in any language from the spec.</p> · <span class="mono">/openapi.json</span> — generate a client in any language from the spec.</p>
<h2>3 · TypeScript / Vercel (zero deps)</h2> <h2>3 · TypeScript / Vercel (zero deps)</h2>
<p>Drop <span class="mono">douanier.ts</span> (ships with this kit) into your project — it uses only <p>Drop <span class="mono">nech.ts</span> (ships with this kit) into your project — it uses only
global <span class="mono">fetch</span>/<span class="mono">FormData</span>, so it runs on Node 18+, Vercel Functions and the Edge.</p> global <span class="mono">fetch</span>/<span class="mono">FormData</span>, so it runs on Node 18+, Vercel Functions and the Edge.
<pre><span class="k">import</span> { Douanier } <span class="k">from</span> <span class="s">"./douanier"</span>; One client, namespaced per API (<span class="mono">nech.audio.…</span>).</p>
<pre><span class="k">import</span> { NechAPI } <span class="k">from</span> <span class="s">"./nech"</span>;
<span class="k">const</span> dz = <span class="k">new</span> Douanier({ token: process.env.<span class="k">NECHPL_TOKEN</span>! }); <span class="k">const</span> nech = <span class="k">new</span> NechAPI({ token: process.env.<span class="k">NECHPL_TOKEN</span>! });
<span class="k">const</span> clip = <span class="k">await</span> fetch(trackUrl).then(r =&gt; r.blob()); <span class="k">const</span> clip = <span class="k">await</span> fetch(trackUrl).then(r =&gt; r.blob());
<span class="k">const</span> mood = <span class="k">await</span> dz.emotion(clip); <span class="c">// { emotion: { valence, arousal, top, … } }</span> <span class="k">const</span> mood = <span class="k">await</span> nech.audio.emotion(clip); <span class="c">// { emotion: { valence, arousal, … } }</span>
<span class="k">const</span> spec = <span class="k">await</span> dz.spectrum(clip, { bands: 64, frames: 200 }); <span class="k">const</span> spec = <span class="k">await</span> nech.audio.spectrum(clip, { bands: 64, frames: 200 });
<span class="k">if</span> (mood._cache === <span class="s">"hit"</span>) { <span class="c">/* came free from the cache */</span> }</pre> <span class="k">if</span> (mood._cache === <span class="s">"hit"</span>) { <span class="c">/* came free from the cache */</span> }</pre>
<div class="two"> <div class="two">
......
...@@ -36,14 +36,14 @@ def test_grade_endpoint_and_cache(): ...@@ -36,14 +36,14 @@ def test_grade_endpoint_and_cache():
files = {"file": ("loop.wav", _wav_bytes(), "audio/wav")} files = {"file": ("loop.wav", _wav_bytes(), "audio/wav")}
r1 = client.post("/grade", files=files, headers=GW) r1 = client.post("/grade", files=files, headers=GW)
assert r1.status_code == 200 assert r1.status_code == 200
assert r1.headers["X-Douanier-Cache"] == "miss" assert r1.headers["X-Nech-Cache"] == "miss"
g = r1.json()["grade"] g = r1.json()["grade"]
assert g["tier"] in {"S", "A", "B", "C", "D"} assert g["tier"] in {"S", "A", "B", "C", "D"}
assert 0.0 <= g["grade"] <= 1.0 assert 0.0 <= g["grade"] <= 1.0
assert "seam" in g["sub"] and "rms_dbfs" in g["metrics"] assert "seam" in g["sub"] and "rms_dbfs" in g["metrics"]
r2 = client.post("/grade", files={"file": ("loop.wav", _wav_bytes(), "audio/wav")}, headers=GW) r2 = client.post("/grade", files={"file": ("loop.wav", _wav_bytes(), "audio/wav")}, headers=GW)
assert r2.headers["X-Douanier-Cache"] == "hit" assert r2.headers["X-Nech-Cache"] == "hit"
def test_grade_scope_enforced(): def test_grade_scope_enforced():
......
...@@ -70,11 +70,11 @@ GW = lambda scope="*": {"X-Tenant": "hexa", "X-Scopes": "api:audio:*"} ...@@ -70,11 +70,11 @@ GW = lambda scope="*": {"X-Tenant": "hexa", "X-Scopes": "api:audio:*"}
def test_onsets_endpoint_and_cache(): def test_onsets_endpoint_and_cache():
files = {"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")} files = {"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")}
r1 = client.post("/onsets", files=files, headers=GW("onsets")) r1 = client.post("/onsets", files=files, headers=GW("onsets"))
assert r1.status_code == 200 and r1.headers["X-Douanier-Cache"] == "miss" assert r1.status_code == 200 and r1.headers["X-Nech-Cache"] == "miss"
assert "onsets" in r1.json() and r1.json()["n_onsets"] >= 3 assert "onsets" in r1.json() and r1.json()["n_onsets"] >= 3
r2 = client.post("/onsets", files={"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")}, r2 = client.post("/onsets", files={"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")},
headers=GW("onsets")) headers=GW("onsets"))
assert r2.headers["X-Douanier-Cache"] == "hit" assert r2.headers["X-Nech-Cache"] == "hit"
def test_waveform_endpoint(): def test_waveform_endpoint():
......
...@@ -84,12 +84,12 @@ def test_emotion_happy_path_and_cache(monkeypatch): ...@@ -84,12 +84,12 @@ def test_emotion_happy_path_and_cache(monkeypatch):
r1 = client.post("/analyze/emotion", files=files, headers=GW) r1 = client.post("/analyze/emotion", files=files, headers=GW)
assert r1.status_code == 200 assert r1.status_code == 200
assert r1.headers["X-Douanier-Cache"] == "miss" assert r1.headers["X-Nech-Cache"] == "miss"
assert r1.json()["emotion"]["valence"] == 0.42 assert r1.json()["emotion"]["valence"] == 0.42
assert r1.json()["content_id"].startswith(("cid_", "raw_")) assert r1.json()["content_id"].startswith(("cid_", "raw_"))
r2 = client.post("/analyze/emotion", files=files, headers=GW) r2 = client.post("/analyze/emotion", files=files, headers=GW)
assert r2.headers["X-Douanier-Cache"] == "hit" assert r2.headers["X-Nech-Cache"] == "hit"
assert calls["n"] == 1 # engine ran ONCE; 2nd served from cache assert calls["n"] == 1 # engine ran ONCE; 2nd served from cache
...@@ -115,13 +115,13 @@ def test_features_endpoint_and_cache(): ...@@ -115,13 +115,13 @@ def test_features_endpoint_and_cache():
files = {"file": ("c.wav", _wav_bytes(), "audio/wav")} files = {"file": ("c.wav", _wav_bytes(), "audio/wav")}
r1 = client.post("/features", files=files, headers=GW) r1 = client.post("/features", files=files, headers=GW)
assert r1.status_code == 200 assert r1.status_code == 200
assert r1.headers["X-Douanier-Cache"] == "miss" assert r1.headers["X-Nech-Cache"] == "miss"
body = r1.json() body = r1.json()
assert "features" in body and body["engine"] == "light" assert "features" in body and body["engine"] == "light"
assert "spectral_centroid" in body["features"] assert "spectral_centroid" in body["features"]
r2 = client.post("/features", files={"file": ("c.wav", _wav_bytes(), "audio/wav")}, r2 = client.post("/features", files={"file": ("c.wav", _wav_bytes(), "audio/wav")},
headers=GW) headers=GW)
assert r2.headers["X-Douanier-Cache"] == "hit" assert r2.headers["X-Nech-Cache"] == "hit"
def test_features_scope_enforced(): def test_features_scope_enforced():
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment