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):
- `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`)
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
```bash
......@@ -107,7 +107,7 @@ cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/ -q
## Status
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
(`/audio/v1`, central-auth headers, `/metrics`, `/separate` 503) (#37)**. The
image is ~1 GB and the SRE edge is live and waiting.
......
......@@ -184,7 +184,7 @@ async def _read_upload(file: UploadFile) -> bytes:
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).
The same engine call is paid ONCE per (content_id, params) — the margin lever."""
suffix = Path(filename or "clip").suffix or ".wav"
......@@ -194,14 +194,14 @@ def _cached(kind: str, params: dict, data: bytes, filename: str, compute, respon
cid = cache.content_id(tmp.name)
hit = cache.get(cid, kind, params)
if hit:
response.headers["X-Douanier-Cache"] = "hit"
response.headers["X-Nech-Cache"] = "hit"
return cid, hit["result"]
try:
result = compute(tmp.name)
except Exception as e:
raise HTTPException(422, f"{kind} analysis failed: {e}")
cache.put(cid, kind, result=result, params=params)
response.headers["X-Douanier-Cache"] = "miss"
response.headers["X-Nech-Cache"] = "miss"
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>`**
(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:
[`openapi.json`](./openapi.json)).
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.
## TypeScript client (zero deps)
Drop [`douanier.ts`](./douanier.ts) into your project (Node 18+, Vercel
Functions, or Edge — it only uses global `fetch`/`FormData`/`Blob`).
Drop [`nech.ts`](./nech.ts) into your project (Node 18+, Vercel Functions, or
Edge — it only uses global `fetch`/`FormData`/`Blob`). One umbrella client,
namespaced per sub-API (`nech.audio.…`, and `nech.geo.…` when it ships):
```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
const clip = await fetch(trackUrl).then(r => r.blob());
const mood = await dz.emotion(clip); // { emotion: { valence, arousal, top, … } }
const spec = await dz.spectrum(clip, { bands: 64, frames: 200 }); // FFT-as-a-service
const wave = await dz.waveform(clip, { bins: 800 }); // render-ready envelope
const loud = await dz.loudness(clip); // { lufs_integrated, gain_to_target_db, … }
const mood = await nech.audio.emotion(clip); // { emotion: { valence, arousal, top, … } }
const spec = await nech.audio.spectrum(clip, { bands: 64, frames: 200 }); // FFT-as-a-service
const wave = await nech.audio.waveform(clip, { bins: 800 }); // render-ready envelope
const loud = await nech.audio.loudness(clip); // { lufs_integrated, gain_to_target_db, … }
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
```ts
// 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) {
const clip = await req.blob();
const dz = new Douanier({ token: process.env.DOUANIER_TOKEN! });
const { emotion } = await dz.emotion(clip);
const nech = new NechAPI({ token: process.env.NECHPL_TOKEN! });
const { emotion } = await nech.audio.emotion(clip);
return Response.json(emotion);
}
```
......@@ -87,5 +95,5 @@ npx @openapitools/openapi-generator-cli generate \
-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.
/**
* 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
* Node 18+, Vercel Functions, and the Edge runtime. Every analysis takes an
* 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"`.
* Node 18+, Vercel Functions, and the Edge runtime.
*
* const dz = new Douanier({ token: process.env.DOUANIER_TOKEN! });
* const mood = await dz.emotion(clip); // { valence, arousal, top, … }
* const spec = await dz.spectrum(clip, { bands: 64, frames: 200 });
* One client, namespaced per sub-API:
*
* 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 {
/** Platform bearer token (provisioned by SRE for your tenant). */
export interface NechOptions {
/** Platform bearer token (provisioned for your tenant). */
token: string;
/** Defaults to the public gateway path. */
/** Platform base; defaults to the public gateway. */
baseUrl?: string;
/** Inject a custom fetch (tests / non-standard runtimes). */
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";
type WithMeta<T> = T & { filename: string; content_id: string; _cache: Cache };
......@@ -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 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) {
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 token: string;
private f: typeof fetch;
constructor(opts: DouanierOptions) {
constructor(opts: SubClientOptions) {
this.token = opts.token;
this.base = (opts.baseUrl ?? "https://api.nech.pl/audio/v1").replace(/\/$/, "");
this.f = opts.fetchImpl ?? fetch;
......@@ -81,8 +103,8 @@ export class Douanier {
body: form,
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new DouanierError(res.status, (body as any)?.detail ?? body);
(body as any)._cache = (res.headers.get("X-Douanier-Cache") as Cache) ?? "miss";
if (!res.ok) throw new NechError(res.status, (body as any)?.detail ?? body);
(body as any)._cache = (res.headers.get("X-Nech-Cache") as Cache) ?? "miss";
return body as WithMeta<T>;
}
......@@ -104,4 +126,18 @@ export class Douanier {
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 @@
<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>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>
<h3>Your access</h3>
<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> \
· <span class="mono">/openapi.json</span> — generate a client in any language from the spec.</p>
<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
global <span class="mono">fetch</span>/<span class="mono">FormData</span>, so it runs on Node 18+, Vercel Functions and the Edge.</p>
<pre><span class="k">import</span> { Douanier } <span class="k">from</span> <span class="s">"./douanier"</span>;
<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.
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> mood = <span class="k">await</span> dz.emotion(clip); <span class="c">// { emotion: { valence, arousal, top, … } }</span>
<span class="k">const</span> spec = <span class="k">await</span> dz.spectrum(clip, { bands: 64, frames: 200 });
<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> 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>
<div class="two">
......
......@@ -36,14 +36,14 @@ def test_grade_endpoint_and_cache():
files = {"file": ("loop.wav", _wav_bytes(), "audio/wav")}
r1 = client.post("/grade", files=files, headers=GW)
assert r1.status_code == 200
assert r1.headers["X-Douanier-Cache"] == "miss"
assert r1.headers["X-Nech-Cache"] == "miss"
g = r1.json()["grade"]
assert g["tier"] in {"S", "A", "B", "C", "D"}
assert 0.0 <= g["grade"] <= 1.0
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)
assert r2.headers["X-Douanier-Cache"] == "hit"
assert r2.headers["X-Nech-Cache"] == "hit"
def test_grade_scope_enforced():
......
......@@ -70,11 +70,11 @@ GW = lambda scope="*": {"X-Tenant": "hexa", "X-Scopes": "api:audio:*"}
def test_onsets_endpoint_and_cache():
files = {"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")}
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
r2 = client.post("/onsets", files={"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")},
headers=GW("onsets"))
assert r2.headers["X-Douanier-Cache"] == "hit"
assert r2.headers["X-Nech-Cache"] == "hit"
def test_waveform_endpoint():
......
......@@ -84,12 +84,12 @@ def test_emotion_happy_path_and_cache(monkeypatch):
r1 = client.post("/analyze/emotion", files=files, headers=GW)
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()["content_id"].startswith(("cid_", "raw_"))
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
......@@ -115,13 +115,13 @@ def test_features_endpoint_and_cache():
files = {"file": ("c.wav", _wav_bytes(), "audio/wav")}
r1 = client.post("/features", files=files, headers=GW)
assert r1.status_code == 200
assert r1.headers["X-Douanier-Cache"] == "miss"
assert r1.headers["X-Nech-Cache"] == "miss"
body = r1.json()
assert "features" in body and body["engine"] == "light"
assert "spectral_centroid" in body["features"]
r2 = client.post("/features", files={"file": ("c.wav", _wav_bytes(), "audio/wav")},
headers=GW)
assert r2.headers["X-Douanier-Cache"] == "hit"
assert r2.headers["X-Nech-Cache"] == "hit"
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