@openparachute/vault 0.7.9-rc.6 → 0.7.9-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/history.test.ts +57 -0
- package/core/src/history.ts +4 -1
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +49 -27
- package/src/auto-transcribe-integration.test.ts +82 -0
- package/src/auto-transcribe.test.ts +126 -374
- package/src/auto-transcribe.ts +43 -215
- package/src/server.ts +7 -7
- package/src/transcription-registry.ts +9 -7
- package/src/vault.test.ts +40 -17
package/core/src/history.test.ts
CHANGED
|
@@ -227,3 +227,60 @@ describe("vault#524 history retention and blob integrity", () => {
|
|
|
227
227
|
expect(await store.getNote(n.id)).toBeNull();
|
|
228
228
|
});
|
|
229
229
|
});
|
|
230
|
+
|
|
231
|
+
describe("vault#743 whole-blob integrity", () => {
|
|
232
|
+
it("reads valid whole blobs including empty and multibyte text", async () => {
|
|
233
|
+
const empty = await store.createNote("placeholder");
|
|
234
|
+
await store.updateNote(empty.id, { content: "" });
|
|
235
|
+
await store.updateNote(empty.id, { content: "final" });
|
|
236
|
+
expect((await store.getNoteVersion(empty.id, 1))!.content).toBe("");
|
|
237
|
+
|
|
238
|
+
const multi = await store.createNote("é".repeat(1000));
|
|
239
|
+
await store.updateNote(multi.id, { content: "next" });
|
|
240
|
+
expect((await store.getNoteVersion(multi.id, 0))!.content).toBe("é".repeat(1000));
|
|
241
|
+
|
|
242
|
+
const plain = await store.createNote("whole body");
|
|
243
|
+
await store.updateNote(plain.id, { content: "updated" });
|
|
244
|
+
expect((await store.getNoteVersion(plain.id, 0))!.content).toBe("whole body");
|
|
245
|
+
});
|
|
246
|
+
it("rejects a corrupted whole blob on direct read", async () => {
|
|
247
|
+
const n = await store.createNote("original");
|
|
248
|
+
await store.updateNote(n.id, { content: "live" });
|
|
249
|
+
const hash = (await store.listNoteVersions(n.id))[0]!.content_hash;
|
|
250
|
+
db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
|
|
251
|
+
"tampered",
|
|
252
|
+
hash,
|
|
253
|
+
);
|
|
254
|
+
expect(() => history!.readBlobContent(db, hash!)).toThrow(
|
|
255
|
+
history!.HistoryDeltaOrphanError,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
it("rejects a corrupted whole blob through version read", async () => {
|
|
259
|
+
const n = await store.createNote("original");
|
|
260
|
+
await store.updateNote(n.id, { content: "live" });
|
|
261
|
+
const hash = (await store.listNoteVersions(n.id))[0]!.content_hash;
|
|
262
|
+
db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
|
|
263
|
+
"tampered",
|
|
264
|
+
hash,
|
|
265
|
+
);
|
|
266
|
+
await expect(store.getNoteVersion(n.id, 0)).rejects.toMatchObject({
|
|
267
|
+
code: "HISTORY_UNRECOVERABLE",
|
|
268
|
+
reason: "delta_orphan",
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
it("rejects restoring a corrupted whole blob and leaves live note and history unchanged", async () => {
|
|
272
|
+
const n = await store.createNote("original");
|
|
273
|
+
await store.updateNote(n.id, { content: "live" });
|
|
274
|
+
const versionsBefore = await store.listNoteVersions(n.id);
|
|
275
|
+
const hash = versionsBefore[0]!.content_hash;
|
|
276
|
+
db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
|
|
277
|
+
"tampered",
|
|
278
|
+
hash,
|
|
279
|
+
);
|
|
280
|
+
await expect(store.restoreNoteVersion(n.id, 0, {})).rejects.toMatchObject({
|
|
281
|
+
code: "HISTORY_UNRECOVERABLE",
|
|
282
|
+
});
|
|
283
|
+
expect((await store.getNote(n.id))!.content).toBe("live");
|
|
284
|
+
expect(await store.listNoteVersions(n.id)).toEqual(versionsBefore);
|
|
285
|
+
});
|
|
286
|
+
});
|
package/core/src/history.ts
CHANGED
|
@@ -438,8 +438,11 @@ interface BlobReadRow {
|
|
|
438
438
|
function materialise(db: Database, hash: string, row: BlobReadRow): string | null {
|
|
439
439
|
if (row.content === null)
|
|
440
440
|
return null;
|
|
441
|
-
if (row.blob_encoding === null)
|
|
441
|
+
if (row.blob_encoding === null) {
|
|
442
|
+
if (hashContent(row.content) !== hash)
|
|
443
|
+
throw new HistoryDeltaOrphanError(hash, row.delta_of, "identity_mismatch");
|
|
442
444
|
return row.content;
|
|
445
|
+
}
|
|
443
446
|
if (row.blob_encoding !== "fossil-delta")
|
|
444
447
|
throw new HistoryDeltaOrphanError(hash, row.delta_of, "unknown_encoding");
|
|
445
448
|
const base = row.delta_of ? db.prepare("SELECT content, encoding FROM note_blobs WHERE hash = ?").get(row.delta_of) as {
|
package/package.json
CHANGED
|
@@ -29,6 +29,7 @@ const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
|
29
29
|
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
30
30
|
const { writeVaultConfig } = await import("./config.ts");
|
|
31
31
|
const { getVaultStore } = await import("./vault-store.ts");
|
|
32
|
+
const { clearScribeUrlCache } = await import("./scribe-discovery.ts");
|
|
32
33
|
const {
|
|
33
34
|
getSharedAttachmentTicketProvider,
|
|
34
35
|
InProcessAttachmentTicketProvider,
|
|
@@ -275,33 +276,54 @@ describe("attachment tickets — upload lifecycle", () => {
|
|
|
275
276
|
// exactly what a new box looks like. The upload must not come back looking
|
|
276
277
|
// like an ordinary file: it carries a `failed` status and an actionable
|
|
277
278
|
// reason, so the state is visible in the API and the admin SPA.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
279
|
+
//
|
|
280
|
+
// Fixture: select the remote provider so the presence/absence of a local
|
|
281
|
+
// whisper-cpp install on the host cannot change the message. With no
|
|
282
|
+
// SCRIBE_URL configured, auto-transcribe is still unavailable and reports
|
|
283
|
+
// the same actionable no-provider error on every box.
|
|
284
|
+
test("audio + auto-transcribe on + scribe-http without URL → attachment records the failure, not silence", async () => {
|
|
285
|
+
const prevProvider = process.env.TRANSCRIPTION_PROVIDER;
|
|
286
|
+
const prevScribeUrl = process.env.SCRIBE_URL;
|
|
287
|
+
process.env.TRANSCRIPTION_PROVIDER = "scribe-http";
|
|
288
|
+
delete process.env.SCRIBE_URL;
|
|
289
|
+
clearScribeUrlCache();
|
|
290
|
+
try {
|
|
291
|
+
const vaultName = freshVault("tickets-no-provider");
|
|
292
|
+
const store = getVaultStore(vaultName);
|
|
293
|
+
const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
|
|
294
|
+
|
|
295
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
296
|
+
note: note.id,
|
|
297
|
+
filename: "memo.webm",
|
|
298
|
+
size_bytes: 4,
|
|
299
|
+
// NO `transcribe: true` — this is the AUTO path, the one that used to
|
|
300
|
+
// silently do nothing.
|
|
301
|
+
});
|
|
302
|
+
const res = await routeReq(
|
|
303
|
+
new Request(mint.url, {
|
|
304
|
+
method: "PUT",
|
|
305
|
+
headers: { "content-type": "audio/webm" },
|
|
306
|
+
body: new Uint8Array([1, 2, 3, 4]),
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
expect(res.status).toBe(201);
|
|
310
|
+
const attachment = (await res.json()) as any;
|
|
311
|
+
expect(attachment.metadata.transcribe_status).toBe("failed");
|
|
312
|
+
expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
|
|
313
|
+
// The reason has to be actionable — naming both routes out of it.
|
|
314
|
+
expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
|
|
315
|
+
expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
|
|
316
|
+
expect(attachment.metadata.transcribe_origin).toBe("auto");
|
|
317
|
+
const unchanged = await store.getNote(note.id);
|
|
318
|
+
expect(unchanged!.content).toBe("# Voice memo\n");
|
|
319
|
+
expect((unchanged!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
320
|
+
} finally {
|
|
321
|
+
if (prevProvider === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
|
|
322
|
+
else process.env.TRANSCRIPTION_PROVIDER = prevProvider;
|
|
323
|
+
if (prevScribeUrl === undefined) delete process.env.SCRIBE_URL;
|
|
324
|
+
else process.env.SCRIBE_URL = prevScribeUrl;
|
|
325
|
+
clearScribeUrlCache();
|
|
326
|
+
}
|
|
305
327
|
});
|
|
306
328
|
|
|
307
329
|
// The counter-case: turning auto-transcribe OFF must stay silent. The
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { writeVaultConfig } from "./config.ts";
|
|
3
|
+
import { getVaultStore, defaultHookRegistry } from "./vault-store.ts";
|
|
4
|
+
import { handleNotes } from "./routes.ts";
|
|
5
|
+
import { handleScopedMcp } from "./mcp-http.ts";
|
|
6
|
+
import { route } from "./routing.ts";
|
|
7
|
+
import { registerTranscriptionHook } from "./transcription-worker.ts";
|
|
8
|
+
import { getTranscriptionWorker, getTranscriptionWorkerProvider, setTranscriptionWorker } from "./transcription-registry.ts";
|
|
9
|
+
|
|
10
|
+
// Fake the wired worker, not module imports or real ASR. Exercise both upload
|
|
11
|
+
// doors and the actual attachment-created hook without network/subprocess work.
|
|
12
|
+
for (const door of ["rest", "ticket"] as const) {
|
|
13
|
+
for (const mode of ["local", "remote", "missing", "mismatch", "unready", "disabled", "explicit", "non-audio"] as const) {
|
|
14
|
+
test(`${door}: ${mode} automatic worker eligibility`, async () => {
|
|
15
|
+
const previous = { provider: process.env.TRANSCRIPTION_PROVIDER, url: process.env.SCRIBE_URL, model: process.env.TRANSCRIPTION_MODEL, assets: process.env.ASSETS_DIR };
|
|
16
|
+
const oldWorker = getTranscriptionWorker();
|
|
17
|
+
const oldProvider = getTranscriptionWorkerProvider();
|
|
18
|
+
const vault = `auto-worker-${crypto.randomUUID()}`;
|
|
19
|
+
const kicks: string[] = [];
|
|
20
|
+
const worker = { stop: async () => {}, tick: async () => 0,
|
|
21
|
+
kick: async (_vault: string, attachment: { id: string }) => { kicks.push(attachment.id); } };
|
|
22
|
+
writeVaultConfig({ name: vault, api_keys: [], created_at: new Date().toISOString(),
|
|
23
|
+
auto_transcribe: { enabled: mode !== "disabled" && mode !== "explicit" } });
|
|
24
|
+
const store = getVaultStore(vault);
|
|
25
|
+
const unregister = registerTranscriptionHook(defaultHookRegistry, worker, (s) => s === store ? vault : undefined);
|
|
26
|
+
try {
|
|
27
|
+
// Sibling files may set a process-global storage root. These uploads
|
|
28
|
+
// belong only to this unique vault, not that shared test directory.
|
|
29
|
+
delete process.env.ASSETS_DIR;
|
|
30
|
+
process.env.TRANSCRIPTION_PROVIDER = mode === "remote" ? "scribe-http" : mode === "unready" ? "whisper-cpp" : "transcribe-cpp";
|
|
31
|
+
// A stale URL must never make an absent/mismatched local worker ready.
|
|
32
|
+
if (mode === "local") delete process.env.SCRIBE_URL;
|
|
33
|
+
else process.env.SCRIBE_URL = "http://unused.invalid";
|
|
34
|
+
if (mode === "unready") process.env.TRANSCRIPTION_MODEL = "not-a-real-model";
|
|
35
|
+
setTranscriptionWorker(mode === "missing" ? null : worker,
|
|
36
|
+
mode === "mismatch" ? "scribe-http" : process.env.TRANSCRIPTION_PROVIDER);
|
|
37
|
+
const note = await store.createNote("original body", { path: "memo" });
|
|
38
|
+
const mime = mode === "non-audio" ? "image/png" : "audio/wav";
|
|
39
|
+
let response: Response;
|
|
40
|
+
if (door === "rest") {
|
|
41
|
+
response = await handleNotes(new Request(`http://localhost/notes/${note.id}/attachments`, {
|
|
42
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
43
|
+
body: JSON.stringify({ path: "memos/audio.wav", mimeType: mime,
|
|
44
|
+
...(mode === "explicit" ? { transcribe: true } : {}) }),
|
|
45
|
+
}), store, `/${note.id}/attachments`, vault);
|
|
46
|
+
} else {
|
|
47
|
+
const mint = await handleScopedMcp(new Request(`http://localhost/vault/${vault}/mcp`, {
|
|
48
|
+
method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
49
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: {
|
|
50
|
+
name: "request-attachment-upload", arguments: { note: note.id, filename: "memo.wav", size_bytes: 4,
|
|
51
|
+
mime_type: mime, ...(mode === "explicit" ? { transcribe: true } : {}) },
|
|
52
|
+
} }),
|
|
53
|
+
}), vault, { permission: "full", scopes: ["vault:read", "vault:write"], legacyDerived: false, scoped_tags: null } as any);
|
|
54
|
+
const rpc = await mint.json() as any;
|
|
55
|
+
expect(rpc.error).toBeUndefined();
|
|
56
|
+
const ticket = JSON.parse(rpc.result.content[0].text);
|
|
57
|
+
const req = new Request(ticket.url, { method: "PUT", headers: { "content-type": mime }, body: new Uint8Array([1, 2, 3, 4]) });
|
|
58
|
+
response = await route(req, new URL(req.url).pathname);
|
|
59
|
+
}
|
|
60
|
+
expect(response.status).toBe(201);
|
|
61
|
+
const attachment = await response.json() as any;
|
|
62
|
+
await defaultHookRegistry.drain();
|
|
63
|
+
const eligible = ["local", "remote", "explicit"].includes(mode);
|
|
64
|
+
const unavailable = ["missing", "mismatch", "unready"].includes(mode);
|
|
65
|
+
expect(attachment.metadata?.transcribe_status).toBe(eligible ? "pending" : unavailable ? "failed" : undefined);
|
|
66
|
+
expect(kicks).toEqual(eligible ? [attachment.id] : []);
|
|
67
|
+
if (unavailable) expect(attachment.metadata.transcribe_error).toContain("transcription status");
|
|
68
|
+
if (eligible || unavailable) expect(attachment.metadata.transcribe_origin).toBe(mode === "explicit" ? "legacy" : "auto");
|
|
69
|
+
const current = await store.getNote(note.id);
|
|
70
|
+
expect(current!.content).toBe("original body");
|
|
71
|
+
if (mode !== "explicit") expect((current!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
72
|
+
} finally {
|
|
73
|
+
await defaultHookRegistry.drain();
|
|
74
|
+
unregister();
|
|
75
|
+
setTranscriptionWorker(oldWorker, oldProvider);
|
|
76
|
+
for (const [key, value] of [["TRANSCRIPTION_PROVIDER", previous.provider], ["SCRIBE_URL", previous.url], ["TRANSCRIPTION_MODEL", previous.model], ["ASSETS_DIR", previous.assets]] as const) {
|
|
77
|
+
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -1,388 +1,140 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auto-transcribe gating decisions (vault#353).
|
|
3
|
-
*
|
|
4
|
-
* Three independent guards: mime-type prefix, enabled toggle, scribe URL
|
|
5
|
-
* present. Pure function — exercise the truth table.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
import { describe, test, expect } from "bun:test";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe("shouldAutoTranscribe", () => {
|
|
26
|
-
const scribePresent = () => "http://127.0.0.1:1943";
|
|
27
|
-
const scribeAbsent = () => undefined;
|
|
28
|
-
|
|
29
|
-
test("triggers on audio/* mime-type when enabled + scribe reachable", () => {
|
|
30
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
31
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
32
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
33
|
-
})).toBe(true);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("triggers on audio/mp4 (m4a)", () => {
|
|
37
|
-
expect(shouldAutoTranscribe("audio/mp4", {
|
|
38
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
39
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
40
|
-
})).toBe(true);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("triggers on audio/webm", () => {
|
|
44
|
-
expect(shouldAutoTranscribe("audio/webm", {
|
|
45
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
46
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
47
|
-
})).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("triggers case-insensitively (AUDIO/WAV)", () => {
|
|
51
|
-
expect(shouldAutoTranscribe("AUDIO/WAV", {
|
|
52
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
53
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
54
|
-
})).toBe(true);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("skips non-audio mime-types (image/png, application/pdf, video/mp4)", () => {
|
|
58
|
-
expect(shouldAutoTranscribe("image/png", {
|
|
59
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
60
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
61
|
-
})).toBe(false);
|
|
62
|
-
expect(shouldAutoTranscribe("application/pdf", {
|
|
63
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
64
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
65
|
-
})).toBe(false);
|
|
66
|
-
expect(shouldAutoTranscribe("video/mp4", {
|
|
67
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
68
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
69
|
-
})).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("skips when enabled is false (default off)", () => {
|
|
73
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
74
|
-
readGlobalConfigImpl: readGlobalConfig(false),
|
|
75
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
76
|
-
})).toBe(false);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("fires when enabled is unset — unset config means ON", () => {
|
|
80
|
-
// Default behavior (no `auto_transcribe` block in config) is opt-out:
|
|
81
|
-
// once an operator has scribe reachable, audio attachments transcribe
|
|
82
|
-
// automatically. Operators wanting it OFF set
|
|
83
|
-
// `auto_transcribe.enabled: false` explicitly. Previously default-off;
|
|
84
|
-
// flipped to default-on so installing scribe is the only opt-in signal.
|
|
85
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
86
|
-
readGlobalConfigImpl: readGlobalConfig(undefined),
|
|
87
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
88
|
-
})).toBe(true);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("skips when scribe URL is undefined (no services.json entry, no env)", () => {
|
|
92
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
93
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
94
|
-
getCachedScribeUrlImpl: scribeAbsent,
|
|
95
|
-
})).toBe(false);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("skips when scribe URL is empty string", () => {
|
|
99
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
100
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
101
|
-
getCachedScribeUrlImpl: () => "",
|
|
102
|
-
})).toBe(false);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("skips on garbage mime-type input", () => {
|
|
106
|
-
expect(shouldAutoTranscribe("", {
|
|
107
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
108
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
109
|
-
})).toBe(false);
|
|
110
|
-
expect(shouldAutoTranscribe("not-a-mime", {
|
|
111
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
112
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
113
|
-
})).toBe(false);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("respects enabledOverride when present", () => {
|
|
117
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
118
|
-
readGlobalConfigImpl: readGlobalConfig(false),
|
|
119
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
120
|
-
enabledOverride: true,
|
|
121
|
-
})).toBe(true);
|
|
122
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
123
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
124
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
125
|
-
enabledOverride: false,
|
|
126
|
-
})).toBe(false);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("per-vault precedence (per-vault → global → true)", () => {
|
|
130
|
-
test("per-vault true wins even when global is false", () => {
|
|
131
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
132
|
-
readGlobalConfigImpl: readGlobalConfig(false),
|
|
133
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
134
|
-
perVaultEnabled: true,
|
|
135
|
-
})).toBe(true);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("per-vault false wins even when global is true", () => {
|
|
139
|
-
// The whole point: linking scribe to vault X (perVault true) elsewhere
|
|
140
|
-
// must not force-on a vault that set its own false.
|
|
141
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
142
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
143
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
144
|
-
perVaultEnabled: false,
|
|
145
|
-
})).toBe(false);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("per-vault unset falls back to global", () => {
|
|
149
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
150
|
-
readGlobalConfigImpl: readGlobalConfig(true),
|
|
151
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
152
|
-
perVaultEnabled: undefined,
|
|
153
|
-
})).toBe(true);
|
|
154
|
-
expect(shouldAutoTranscribe("audio/wav", {
|
|
155
|
-
readGlobalConfigImpl: readGlobalConfig(false),
|
|
156
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
157
|
-
perVaultEnabled: undefined,
|
|
158
|
-
})).toBe(false);
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { findModel } from "./transcription/models.ts";
|
|
6
|
+
import { classifyAutoTranscribe, shouldAutoTranscribe, noProviderErrorFor,
|
|
7
|
+
NO_PROVIDER_ERROR, warnNoTranscriptionProvider, _resetNoProviderWarnForTest } from "./auto-transcribe.ts";
|
|
8
|
+
import { getTranscriptionWorker, getTranscriptionWorkerProvider, setTranscriptionWorker } from "./transcription-registry.ts";
|
|
9
|
+
|
|
10
|
+
const config = (enabled?: boolean) => () => ({ port: 1940, auto_transcribe: { enabled } }) as any;
|
|
11
|
+
const ready = () => ({ ready: true, localProvider: "whisper-cpp" });
|
|
12
|
+
const absent = () => ({ ready: false, localProvider: null });
|
|
13
|
+
|
|
14
|
+
describe("automatic transcription eligibility", () => {
|
|
15
|
+
for (const mime of ["audio/wav", "audio/mp4", "audio/webm", "AUDIO/WAV"]) {
|
|
16
|
+
test(`ready worker accepts ${mime}`, () => {
|
|
17
|
+
expect(shouldAutoTranscribe(mime, { readGlobalConfigImpl: config(), providerStateImpl: ready })).toBe(true);
|
|
159
18
|
});
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
getCachedScribeUrlImpl: scribePresent,
|
|
165
|
-
perVaultEnabled: undefined,
|
|
166
|
-
})).toBe(true);
|
|
19
|
+
}
|
|
20
|
+
for (const mime of ["image/png", "video/mp4", "application/pdf", "", null as any]) {
|
|
21
|
+
test(`non-audio ${mime} never probes readiness`, () => {
|
|
22
|
+
expect(classifyAutoTranscribe(mime, { providerStateImpl: () => { throw Error("unexpected probe"); } }).kind).toBe("not-audio");
|
|
167
23
|
});
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
24
|
+
}
|
|
25
|
+
for (const global of [undefined, true, false]) {
|
|
26
|
+
for (const vault of [undefined, true, false]) {
|
|
27
|
+
for (const override of [undefined, true, false]) {
|
|
28
|
+
test(`precedence global=${global} vault=${vault} override=${override}`, () => {
|
|
29
|
+
const enabled = override ?? vault ?? global ?? true;
|
|
30
|
+
const d = classifyAutoTranscribe("audio/wav", { readGlobalConfigImpl: config(global),
|
|
31
|
+
perVaultEnabled: vault, enabledOverride: override, providerStateImpl: ready });
|
|
32
|
+
expect(d.kind).toBe(enabled ? "transcribe" : "disabled");
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
test("disabled bypasses readiness entirely", () => {
|
|
38
|
+
expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: false,
|
|
39
|
+
providerStateImpl: () => { throw Error("unexpected probe"); } }).kind).toBe("disabled");
|
|
40
|
+
});
|
|
41
|
+
for (const local of [null, "whisper-cpp"]) {
|
|
42
|
+
test(`not ready remains unavailable with installed=${local}`, () => {
|
|
43
|
+
expect(classifyAutoTranscribe("audio/wav", { readGlobalConfigImpl: config(),
|
|
44
|
+
providerStateImpl: () => ({ ready: false, localProvider: local }) })).toEqual({ kind: "unavailable", localProvider: local });
|
|
177
45
|
});
|
|
46
|
+
}
|
|
47
|
+
test("unavailable is false, not an enqueue", () => {
|
|
48
|
+
expect(shouldAutoTranscribe("audio/wav", { readGlobalConfigImpl: config(), providerStateImpl: absent })).toBe(false);
|
|
178
49
|
});
|
|
179
50
|
});
|
|
180
51
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
).
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
test("operator turned it off → disabled (silence is CORRECT here)", () => {
|
|
207
|
-
expect(
|
|
208
|
-
classifyAutoTranscribe(audio, {
|
|
209
|
-
perVaultEnabled: false,
|
|
210
|
-
getCachedScribeUrlImpl: withProvider,
|
|
211
|
-
}).kind,
|
|
212
|
-
).toBe("disabled");
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test("enabled + no provider → unavailable, NOT disabled — the whole point", () => {
|
|
216
|
-
expect(
|
|
217
|
-
classifyAutoTranscribe(audio, {
|
|
218
|
-
perVaultEnabled: true,
|
|
219
|
-
getCachedScribeUrlImpl: noProvider,
|
|
220
|
-
}).kind,
|
|
221
|
-
).toBe("unavailable");
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
test("a blank provider URL is unavailable, not a provider", () => {
|
|
225
|
-
expect(
|
|
226
|
-
classifyAutoTranscribe(audio, {
|
|
227
|
-
perVaultEnabled: true,
|
|
228
|
-
getCachedScribeUrlImpl: () => " ",
|
|
229
|
-
}).kind,
|
|
230
|
-
).toBe("unavailable");
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test("the default (no toggle set) with no provider is unavailable — the fresh-install case", () => {
|
|
234
|
-
expect(
|
|
235
|
-
classifyAutoTranscribe(audio, {
|
|
236
|
-
readGlobalConfigImpl: (() => ({})) as never,
|
|
237
|
-
getCachedScribeUrlImpl: noProvider,
|
|
238
|
-
}).kind,
|
|
239
|
-
).toBe("unavailable");
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test("enabled + provider → transcribe", () => {
|
|
243
|
-
expect(
|
|
244
|
-
classifyAutoTranscribe(audio, {
|
|
245
|
-
perVaultEnabled: true,
|
|
246
|
-
getCachedScribeUrlImpl: withProvider,
|
|
247
|
-
}).kind,
|
|
248
|
-
).toBe("transcribe");
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test("shouldAutoTranscribe stays a faithful boolean view of the classifier", () => {
|
|
252
|
-
for (const opts of [
|
|
253
|
-
{ perVaultEnabled: false, getCachedScribeUrlImpl: withProvider },
|
|
254
|
-
{ perVaultEnabled: true, getCachedScribeUrlImpl: noProvider },
|
|
255
|
-
{ perVaultEnabled: true, getCachedScribeUrlImpl: withProvider },
|
|
256
|
-
]) {
|
|
257
|
-
expect(shouldAutoTranscribe(audio, opts)).toBe(
|
|
258
|
-
classifyAutoTranscribe(audio, opts).kind === "transcribe",
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
});
|
|
52
|
+
test("real gate requires a matching registered worker; stale URL cannot enable a missing local worker", () => {
|
|
53
|
+
const previous = { provider: process.env.TRANSCRIPTION_PROVIDER, url: process.env.SCRIBE_URL };
|
|
54
|
+
const worker = getTranscriptionWorker();
|
|
55
|
+
const provider = getTranscriptionWorkerProvider();
|
|
56
|
+
const fake = { stop: async () => {}, tick: async () => 0, kick: async () => {} };
|
|
57
|
+
try {
|
|
58
|
+
process.env.TRANSCRIPTION_PROVIDER = "transcribe-cpp";
|
|
59
|
+
process.env.SCRIBE_URL = "http://stale.invalid";
|
|
60
|
+
setTranscriptionWorker(null);
|
|
61
|
+
expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
|
|
62
|
+
setTranscriptionWorker(fake, "scribe-http");
|
|
63
|
+
expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
|
|
64
|
+
setTranscriptionWorker(fake, "transcribe-cpp");
|
|
65
|
+
expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("transcribe");
|
|
66
|
+
process.env.TRANSCRIPTION_PROVIDER = "scribe-http";
|
|
67
|
+
expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
|
|
68
|
+
setTranscriptionWorker(fake, "scribe-http");
|
|
69
|
+
expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("transcribe");
|
|
70
|
+
} finally {
|
|
71
|
+
setTranscriptionWorker(worker, provider);
|
|
72
|
+
if (previous.provider === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
|
|
73
|
+
else process.env.TRANSCRIPTION_PROVIDER = previous.provider;
|
|
74
|
+
if (previous.url === undefined) delete process.env.SCRIBE_URL;
|
|
75
|
+
else process.env.SCRIBE_URL = previous.url;
|
|
76
|
+
}
|
|
262
77
|
});
|
|
263
78
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
expect(seen.length).toBe(1);
|
|
275
|
-
expect(seen[0]).toMatch(/no transcription provider configured/i);
|
|
276
|
-
// Past the window → one more.
|
|
277
|
-
now += 61_000;
|
|
278
|
-
warnNoTranscriptionProvider(clock);
|
|
279
|
-
expect(seen.length).toBe(2);
|
|
280
|
-
} finally {
|
|
281
|
-
console.warn = orig;
|
|
282
|
-
_resetNoProviderWarnForTest();
|
|
283
|
-
}
|
|
284
|
-
});
|
|
79
|
+
test("diagnostics distinguish installed from ready without reviving Scribe", () => {
|
|
80
|
+
expect(noProviderErrorFor(null)).toBe(NO_PROVIDER_ERROR);
|
|
81
|
+
expect(NO_PROVIDER_ERROR).toContain("transcription status");
|
|
82
|
+
expect(NO_PROVIDER_ERROR).toContain("standalone Scribe service is retired");
|
|
83
|
+
const message = noProviderErrorFor("whisper-cpp");
|
|
84
|
+
expect(message).toContain("binary is installed");
|
|
85
|
+
expect(message).toContain("not active and ready");
|
|
86
|
+
expect(message).toContain("transcription status");
|
|
87
|
+
expect(message).not.toContain("point SCRIBE_URL");
|
|
88
|
+
expect(message).not.toContain("resolves only a scribe URL");
|
|
285
89
|
});
|
|
286
90
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
);
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
test("a runnable local provider → a message that does not contradict the box", () => {
|
|
320
|
-
const d = classifyAutoTranscribe(audio, {
|
|
321
|
-
perVaultEnabled: true,
|
|
322
|
-
getCachedScribeUrlImpl: noScribe,
|
|
323
|
-
localProviderImpl: () => "whisper-cpp",
|
|
324
|
-
});
|
|
325
|
-
expect(d.kind).toBe("unavailable");
|
|
326
|
-
const msg = noProviderErrorFor(d.kind === "unavailable" ? d.localProvider : null);
|
|
327
|
-
|
|
328
|
-
// The three false claims the old string made on such a box.
|
|
329
|
-
expect(msg).not.toContain("no transcription provider configured");
|
|
330
|
-
expect(msg).not.toContain("set TRANSCRIPTION_PROVIDER to a local provider");
|
|
331
|
-
expect(msg).not.toContain("transcription install");
|
|
332
|
-
// And what it must say instead: the local install is fine, this path just
|
|
333
|
-
// doesn't read it, and don't go reinstalling anything.
|
|
334
|
-
expect(msg).toContain("whisper-cpp");
|
|
335
|
-
expect(msg).toContain("SCRIBE_URL");
|
|
336
|
-
expect(msg).toMatch(/does not consult it/);
|
|
337
|
-
expect(msg).toMatch(/Reinstalling whisper-cpp will not change this/);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
test("the decision itself is untouched — this diff moves no behaviour", () => {
|
|
341
|
-
// Same inputs, both local-provider states: still `unavailable` either way.
|
|
342
|
-
// If this ever diverges, the change stopped being diagnostic-only and
|
|
343
|
-
// started deciding what gets transcribed.
|
|
344
|
-
for (const local of [null, "whisper-cpp"]) {
|
|
345
|
-
expect(
|
|
346
|
-
classifyAutoTranscribe(audio, {
|
|
347
|
-
perVaultEnabled: true,
|
|
348
|
-
getCachedScribeUrlImpl: noScribe,
|
|
349
|
-
localProviderImpl: () => local,
|
|
350
|
-
}).kind,
|
|
351
|
-
).toBe("unavailable");
|
|
91
|
+
test("whisper-cpp real readiness requires model plus matching active worker, without Scribe", () => {
|
|
92
|
+
const keys = ["PARACHUTE_HOME", "WHISPER_CPP_BIN_DIR", "TRANSCRIPTION_PROVIDER", "TRANSCRIPTION_MODEL", "SCRIBE_URL"] as const;
|
|
93
|
+
const original = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
94
|
+
const home = mkdtempSync(join(tmpdir(), "auto-whisper-"));
|
|
95
|
+
const oldWorker = getTranscriptionWorker();
|
|
96
|
+
const oldProvider = getTranscriptionWorkerProvider();
|
|
97
|
+
try {
|
|
98
|
+
process.env.PARACHUTE_HOME = home;
|
|
99
|
+
process.env.WHISPER_CPP_BIN_DIR = join(home, "bin");
|
|
100
|
+
process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
|
|
101
|
+
process.env.TRANSCRIPTION_MODEL = "whisper-base.en";
|
|
102
|
+
delete process.env.SCRIBE_URL;
|
|
103
|
+
mkdirSync(join(home, "bin"));
|
|
104
|
+
// Snapshot checks presence only; these are never executed.
|
|
105
|
+
writeFileSync(join(home, "bin", "whisper-cli"), "fixture");
|
|
106
|
+
writeFileSync(join(home, "bin", "ffmpeg"), "fixture");
|
|
107
|
+
const worker = { stop: async () => {}, tick: async () => 0, kick: async () => {} };
|
|
108
|
+
setTranscriptionWorker(worker, "whisper-cpp");
|
|
109
|
+
expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
|
|
110
|
+
const models = join(home, "transcription", "models");
|
|
111
|
+
mkdirSync(models, { recursive: true });
|
|
112
|
+
writeFileSync(join(models, findModel("whisper-base.en")!.filename), "fixture");
|
|
113
|
+
expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("transcribe");
|
|
114
|
+
setTranscriptionWorker(null);
|
|
115
|
+
expect(classifyAutoTranscribe("audio/wav", { perVaultEnabled: true }).kind).toBe("unavailable");
|
|
116
|
+
} finally {
|
|
117
|
+
setTranscriptionWorker(oldWorker, oldProvider);
|
|
118
|
+
for (const key of keys) {
|
|
119
|
+
if (original[key] === undefined) delete process.env[key]; else process.env[key] = original[key];
|
|
352
120
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
perVaultEnabled: true,
|
|
357
|
-
getCachedScribeUrlImpl: () => "http://127.0.0.1:1943",
|
|
358
|
-
localProviderImpl: () => "whisper-cpp",
|
|
359
|
-
}).kind,
|
|
360
|
-
).toBe("transcribe");
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
test("an installed provider with ffmpeg missing STILL gets the honest message", () => {
|
|
364
|
-
// The trap that cost a rewrite: `snap.ready` also demands ffmpeg, so it
|
|
365
|
-
// varies box to box. A machine with the provider installed and ffmpeg
|
|
366
|
-
// missing is still one where "no transcription provider configured" is
|
|
367
|
-
// false, so the discriminator is INSTALLED, not READY — it answers the
|
|
368
|
-
// question the sentence asks, identically everywhere.
|
|
369
|
-
const msg = noProviderErrorFor("whisper-cpp");
|
|
370
|
-
expect(msg).not.toBe(NO_PROVIDER_ERROR);
|
|
371
|
-
// And it must not over-claim in the other direction: with ffmpeg missing
|
|
372
|
-
// the install is present but not necessarily working, so the message says
|
|
373
|
-
// requests are ROUTED to it and points at status for the real state.
|
|
374
|
-
expect(msg).toMatch(/routed to it/);
|
|
375
|
-
expect(msg).toContain("transcription status");
|
|
376
|
-
expect(msg).not.toMatch(/is fine/);
|
|
377
|
-
});
|
|
121
|
+
rmSync(home, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
378
124
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
125
|
+
test("missing-provider warning is throttled and resets", () => {
|
|
126
|
+
const previous = console.warn;
|
|
127
|
+
const messages: unknown[] = [];
|
|
128
|
+
console.warn = (...args) => { messages.push(args); };
|
|
129
|
+
try {
|
|
130
|
+
_resetNoProviderWarnForTest();
|
|
131
|
+
warnNoTranscriptionProvider(() => 60_000);
|
|
132
|
+
warnNoTranscriptionProvider(() => 60_001);
|
|
133
|
+
expect(messages).toHaveLength(1);
|
|
134
|
+
warnNoTranscriptionProvider(() => 120_000);
|
|
135
|
+
expect(messages).toHaveLength(2);
|
|
136
|
+
_resetNoProviderWarnForTest();
|
|
137
|
+
warnNoTranscriptionProvider(() => 120_001);
|
|
138
|
+
expect(messages).toHaveLength(3);
|
|
139
|
+
} finally { console.warn = previous; _resetNoProviderWarnForTest(); }
|
|
388
140
|
});
|
package/src/auto-transcribe.ts
CHANGED
|
@@ -1,247 +1,75 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auto-transcribe trigger decision (vault#353, design 2026-05-21 Part 2).
|
|
3
|
-
*
|
|
4
|
-
* One pure function: given an attachment's mime-type + the operator's
|
|
5
|
-
* settings + whether scribe is reachable, decide whether to enqueue the
|
|
6
|
-
* attachment for the transcription worker. Lives in its own module so the
|
|
7
|
-
* attachment-write code path (`routes.ts`) and the retry endpoint share the
|
|
8
|
-
* same gate without duplicating logic.
|
|
9
|
-
*/
|
|
10
|
-
|
|
1
|
+
/** Automatic uploads follow the selected, wired transcription worker (#751). */
|
|
11
2
|
import { readGlobalConfig } from "./config.ts";
|
|
12
|
-
import { getCachedScribeUrl } from "./scribe-discovery.ts";
|
|
13
3
|
import { buildTranscriptionSnapshot } from "./transcription-routes.ts";
|
|
4
|
+
import { getTranscriptionWorkerProvider } from "./transcription-registry.ts";
|
|
14
5
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
* Delegates to the same snapshot `transcription status` and the admin SPA
|
|
19
|
-
* answer from, so these surfaces share one implementation and cannot drift.
|
|
20
|
-
*
|
|
21
|
-
* Keyed on `binary.path`, deliberately, NOT on `snap.ready`. Readiness also
|
|
22
|
-
* demands ffmpeg, so it varies box to box in a way this message shouldn't: a
|
|
23
|
-
* machine with the provider installed but ffmpeg missing is still a machine
|
|
24
|
-
* where "no transcription provider configured — set TRANSCRIPTION_PROVIDER to
|
|
25
|
-
* a local provider" is false. `installed` answers the question the sentence
|
|
26
|
-
* actually asks, and answers it the same way everywhere.
|
|
27
|
-
* (Caught by running the real probe rather than the injected one — the unit
|
|
28
|
-
* tests inject `localProviderImpl` and would never have shown the difference.)
|
|
29
|
-
*
|
|
30
|
-
* Errors swallow to null: a broken probe must degrade to the old, flat message,
|
|
31
|
-
* never take down an attachment upload.
|
|
32
|
-
*/
|
|
33
|
-
function installedLocalProvider(): string | null {
|
|
6
|
+
type ProviderState = { ready: boolean; localProvider: string | null };
|
|
7
|
+
function providerState(): ProviderState {
|
|
34
8
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
9
|
+
const snapshot = buildTranscriptionSnapshot();
|
|
10
|
+
return {
|
|
11
|
+
ready: snapshot.active && snapshot.ready &&
|
|
12
|
+
getTranscriptionWorkerProvider() === snapshot.provider,
|
|
13
|
+
// The snapshot's binary field describes whisper.cpp tools, not every
|
|
14
|
+
// provider. Do not use it to claim a Python/transcribe-cpp install exists.
|
|
15
|
+
localProvider: snapshot.provider === "whisper-cpp" && snapshot.binary.path
|
|
16
|
+
? snapshot.provider : null,
|
|
17
|
+
};
|
|
38
18
|
} catch {
|
|
39
|
-
|
|
19
|
+
// A failed readiness probe must not break attachment upload.
|
|
20
|
+
return { ready: false, localProvider: null };
|
|
40
21
|
}
|
|
41
22
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
* 1. mime-type starts with `audio/` (case-insensitive).
|
|
51
|
-
* 2. The resolved auto-transcribe toggle is not `false`. Resolution is
|
|
52
|
-
* **per-vault → global → true**:
|
|
53
|
-
* - `perVaultEnabled` (the owning vault's own `auto_transcribe.enabled`)
|
|
54
|
-
* wins when set — this is what makes scribe's "link to vault X" affect
|
|
55
|
-
* only X, not the whole server.
|
|
56
|
-
* - else the server-wide `globalConfig.auto_transcribe?.enabled`.
|
|
57
|
-
* - else `true` (default ON — once scribe is reachable, audio
|
|
58
|
-
* transcribes without a separate config step). Operators who want it
|
|
59
|
-
* OFF set `auto_transcribe.enabled: false` explicitly (per-vault or
|
|
60
|
-
* globally).
|
|
61
|
-
* `enabledOverride`, when present, hard-overrides the whole chain (used
|
|
62
|
-
* by the explicit caller-opt-in path).
|
|
63
|
-
* 3. Scribe is discoverable (services.json entry OR SCRIBE_URL env).
|
|
64
|
-
*
|
|
65
|
-
* The three conditions are independent guards: a single `false` is sufficient
|
|
66
|
-
* to skip enqueuing. The audio stays as a regular attachment in that case.
|
|
67
|
-
*/
|
|
68
|
-
export function shouldAutoTranscribe(
|
|
69
|
-
mimeType: string,
|
|
70
|
-
opts: {
|
|
71
|
-
/** Injection seam for tests — defaults to live globals. */
|
|
72
|
-
readGlobalConfigImpl?: typeof readGlobalConfig;
|
|
73
|
-
getCachedScribeUrlImpl?: () => string | undefined;
|
|
74
|
-
/**
|
|
75
|
-
* The owning vault's per-vault `auto_transcribe.enabled` (vault.yaml).
|
|
76
|
-
* Takes precedence over the global toggle when set, so enabling/disabling
|
|
77
|
-
* one vault doesn't move the rest. `undefined` (the vault left it unset)
|
|
78
|
-
* falls through to the global toggle.
|
|
79
|
-
*/
|
|
80
|
-
perVaultEnabled?: boolean;
|
|
81
|
-
/**
|
|
82
|
-
* Hard override of the entire per-vault→global→true chain. Used by the
|
|
83
|
-
* explicit caller-opt-in path; not part of the normal precedence ladder.
|
|
84
|
-
*/
|
|
85
|
-
enabledOverride?: boolean;
|
|
86
|
-
} = {},
|
|
87
|
-
): boolean {
|
|
88
|
-
if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Why an audio attachment is (or isn't) being transcribed. The three outcomes
|
|
96
|
-
* are NOT interchangeable, which is the whole point of this type:
|
|
97
|
-
*
|
|
98
|
-
* - `transcribe` — enqueue it.
|
|
99
|
-
* - `disabled` — the operator turned auto-transcribe off. Silence is
|
|
100
|
-
* correct: they asked for nothing to happen.
|
|
101
|
-
* - `unavailable` — auto-transcribe is ON, but no provider is reachable.
|
|
102
|
-
* This is a MISCONFIGURATION, and silence is wrong.
|
|
103
|
-
*
|
|
104
|
-
* vault#643: `shouldAutoTranscribe` collapsed the last two into `false`, so a
|
|
105
|
-
* box with transcription enabled and no reachable provider accepted audio,
|
|
106
|
-
* transcribed nothing, wrote no marker, logged nothing, and left an attachment
|
|
107
|
-
* indistinguishable from a plain upload. Observed on a fresh install: the
|
|
108
|
-
* provider resolves to `scribe-http` by default, nothing sets `SCRIBE_URL`, and
|
|
109
|
-
* voice memos silently never transcribe.
|
|
110
|
-
*
|
|
111
|
-
* The hosted door already gets this right — `workers/vault/src/vault-do.ts`
|
|
112
|
-
* marks a terminal state ("voice not enabled for this plan", "monthly voice
|
|
113
|
-
* limit reached") rather than skipping quietly, precisely so the operator never
|
|
114
|
-
* faces an eternal spinner. This brings self-host to the same posture.
|
|
115
|
-
*/
|
|
23
|
+
type AutoTranscribeOptions = {
|
|
24
|
+
readGlobalConfigImpl?: typeof readGlobalConfig;
|
|
25
|
+
/** Test seam; production uses the registered worker and shared status probe. */
|
|
26
|
+
providerStateImpl?: () => ProviderState;
|
|
27
|
+
/** Per-vault → global → true; explicit caller overrides still win. */
|
|
28
|
+
perVaultEnabled?: boolean;
|
|
29
|
+
enabledOverride?: boolean;
|
|
30
|
+
};
|
|
116
31
|
export type AutoTranscribeDecision =
|
|
117
32
|
| { kind: "transcribe" }
|
|
118
33
|
| { kind: "not-audio" }
|
|
119
34
|
| { kind: "disabled" }
|
|
120
|
-
/**
|
|
121
|
-
* `localProvider` names a local provider INSTALLED on this box (its binary
|
|
122
|
-
* resolves), or null when there is nothing local here.
|
|
123
|
-
*
|
|
124
|
-
* Installation, deliberately, and not the configured provider NAME. The name
|
|
125
|
-
* is a trap: `resolveTranscriptionProviderName` returns `whisper-cpp` on a
|
|
126
|
-
* box where nothing at all is set up, because that is the default when no
|
|
127
|
-
* scribe URL resolves. Keying the message off the name would tell a
|
|
128
|
-
* fresh-install operator their whisper-cpp install is present when they have
|
|
129
|
-
* never installed one — trading one false sentence for another.
|
|
130
|
-
*
|
|
131
|
-
* It does NOT change the decision: `unavailable` is `unavailable` either way,
|
|
132
|
-
* because this path resolves a scribe URL and nothing else. It exists purely
|
|
133
|
-
* so the error can be true. See `noProviderErrorFor`.
|
|
134
|
-
*/
|
|
135
35
|
| { kind: "unavailable"; localProvider: string | null };
|
|
136
36
|
|
|
137
|
-
/** The full decision behind `shouldAutoTranscribe`. Same inputs, more answer. */
|
|
138
37
|
export function classifyAutoTranscribe(
|
|
139
38
|
mimeType: string,
|
|
140
|
-
opts: {
|
|
141
|
-
readGlobalConfigImpl?: typeof readGlobalConfig;
|
|
142
|
-
getCachedScribeUrlImpl?: () => string | undefined;
|
|
143
|
-
perVaultEnabled?: boolean;
|
|
144
|
-
enabledOverride?: boolean;
|
|
145
|
-
/**
|
|
146
|
-
* Injectable, same shape as the two impls above. Returns the name of a
|
|
147
|
-
* local provider installed here, or null. Defaults to the shared
|
|
148
|
-
* transcription snapshot.
|
|
149
|
-
*/
|
|
150
|
-
localProviderImpl?: () => string | null;
|
|
151
|
-
} = {},
|
|
39
|
+
opts: AutoTranscribeOptions = {},
|
|
152
40
|
): AutoTranscribeDecision {
|
|
153
41
|
if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
|
|
154
42
|
return { kind: "not-audio" };
|
|
155
43
|
}
|
|
156
|
-
const enabled = opts.enabledOverride
|
|
157
|
-
??
|
|
158
|
-
?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
|
|
159
|
-
?? true;
|
|
44
|
+
const enabled = opts.enabledOverride ?? opts.perVaultEnabled ??
|
|
45
|
+
(opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled ?? true;
|
|
160
46
|
if (!enabled) return { kind: "disabled" };
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
kind: "unavailable",
|
|
169
|
-
localProvider: (opts.localProviderImpl ?? installedLocalProvider)(),
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
return { kind: "transcribe" };
|
|
47
|
+
const state = (opts.providerStateImpl ?? providerState)();
|
|
48
|
+
return state.ready
|
|
49
|
+
? { kind: "transcribe" }
|
|
50
|
+
: { kind: "unavailable", localProvider: state.localProvider };
|
|
51
|
+
}
|
|
52
|
+
export function shouldAutoTranscribe(mimeType: string, opts: AutoTranscribeOptions = {}): boolean {
|
|
53
|
+
return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
|
|
173
54
|
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* The `transcribe_error` written when auto-transcribe is on but no provider is
|
|
177
|
-
* reachable AND nothing local is configured. Deliberately actionable — the
|
|
178
|
-
* operator needs to know which of the two things to do, not just that something
|
|
179
|
-
* went wrong.
|
|
180
|
-
*
|
|
181
|
-
* Correct only when the resolved provider is `scribe-http`. On a box that HAS a
|
|
182
|
-
* local provider this sentence is false in the most expensive way — see
|
|
183
|
-
* `noProviderErrorFor`.
|
|
184
|
-
*/
|
|
185
55
|
export const NO_PROVIDER_ERROR =
|
|
186
|
-
"no transcription provider configured —
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* The `transcribe_error` for an `unavailable` decision, told truthfully.
|
|
192
|
-
*
|
|
193
|
-
* Found live: a box with `TRANSCRIPTION_PROVIDER=whisper-cpp`, `parakeet-cli` on
|
|
194
|
-
* PATH, the model downloaded, and `[transcribe] worker started → whisper-cpp` in
|
|
195
|
-
* the same boot was writing `NO_PROVIDER_ERROR` onto its attachments — telling
|
|
196
|
-
* the operator to set the variable they had set and install the provider that
|
|
197
|
-
* was already running. Same failure mode as the `transcription status` bug
|
|
198
|
-
* (#643): a diagnostic that lies during the exact task it exists for, and this
|
|
199
|
-
* one sends someone to re-download a 400 MB model to fix nothing.
|
|
200
|
-
*
|
|
201
|
-
* The asymmetry is real and worth naming in the message rather than hiding:
|
|
202
|
-
* `server.ts` starts a local worker with no scribe URL anywhere in the branch,
|
|
203
|
-
* but this path resolves ONLY a scribe URL. So a local provider genuinely does
|
|
204
|
-
* serve explicit `transcribe: true` requests while genuinely being invisible
|
|
205
|
-
* here. An operator who is not told that will reasonably conclude their install
|
|
206
|
-
* is broken.
|
|
207
|
-
*
|
|
208
|
-
* Deliberately NOT a fix for the asymmetry itself — teaching this path to see
|
|
209
|
-
* local providers changes what gets transcribed, which is a behaviour question
|
|
210
|
-
* and not this diff's to answer.
|
|
211
|
-
*/
|
|
56
|
+
"no transcription provider configured or ready — check `parachute-vault transcription status` " +
|
|
57
|
+
"and TRANSCRIPTION_PROVIDER. For local transcription use `parachute-vault transcription install`. " +
|
|
58
|
+
"For an explicitly selected legacy remote provider, check its SCRIBE_URL and worker status. " +
|
|
59
|
+
"The standalone Scribe service is retired.";
|
|
212
60
|
export function noProviderErrorFor(localProvider: string | null): string {
|
|
213
61
|
if (!localProvider) return NO_PROVIDER_ERROR;
|
|
214
|
-
return
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
`are routed to it — this path does not consult it. Reinstalling ${localProvider} will ` +
|
|
219
|
-
`not change this. To auto-transcribe uploads, point SCRIBE_URL at a transcription ` +
|
|
220
|
-
`service; otherwise request transcription explicitly on the attachment. ` +
|
|
221
|
-
`\`parachute-vault transcription status\` reports the local install's own state.`
|
|
222
|
-
);
|
|
62
|
+
return `auto-transcribe is unavailable: a local ${localProvider} binary is installed, ` +
|
|
63
|
+
"but the selected worker is not active and ready. Check `parachute-vault transcription status` " +
|
|
64
|
+
"for missing model/runtime dependencies or a required server restart. " +
|
|
65
|
+
"A binary alone does not establish readiness; reinstalling it is not necessarily the fix.";
|
|
223
66
|
}
|
|
224
|
-
|
|
225
|
-
/** Throttle for {@link warnNoTranscriptionProvider}. */
|
|
226
67
|
const NO_PROVIDER_WARN_INTERVAL_MS = 60_000;
|
|
227
68
|
let lastNoProviderWarnAt = 0;
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Warn (at most once a minute) that audio is arriving with nowhere to send it.
|
|
231
|
-
*
|
|
232
|
-
* Throttled because it fires per-upload: a bulk import of a hundred voice
|
|
233
|
-
* memos should produce one actionable line, not a hundred. Silence was the old
|
|
234
|
-
* behaviour and it is what made this invisible — a box can accept audio for
|
|
235
|
-
* months and never say that transcription isn't wired up.
|
|
236
|
-
*/
|
|
237
69
|
export function warnNoTranscriptionProvider(now: () => number = Date.now): void {
|
|
238
70
|
const t = now();
|
|
239
71
|
if (t - lastNoProviderWarnAt < NO_PROVIDER_WARN_INTERVAL_MS) return;
|
|
240
72
|
lastNoProviderWarnAt = t;
|
|
241
|
-
console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Test seam: forget the throttle window. */
|
|
245
|
-
export function _resetNoProviderWarnForTest(): void {
|
|
246
|
-
lastNoProviderWarnAt = 0;
|
|
73
|
+
console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}`);
|
|
247
74
|
}
|
|
75
|
+
export function _resetNoProviderWarnForTest(): void { lastNoProviderWarnAt = 0; }
|
package/src/server.ts
CHANGED
|
@@ -145,7 +145,7 @@ function wireTranscriptionWorker(worker: TranscriptionWorker): void {
|
|
|
145
145
|
worker,
|
|
146
146
|
(store) => getVaultNameForStore(store as never),
|
|
147
147
|
);
|
|
148
|
-
setTranscriptionWorker(worker);
|
|
148
|
+
setTranscriptionWorker(worker, providerName);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
// Fields the worker needs regardless of provider (queue/retention/context).
|
|
@@ -157,8 +157,8 @@ const commonWorkerOpts = {
|
|
|
157
157
|
getContextPredicates: (vault: string) => readVaultConfig(vault)?.transcription?.context,
|
|
158
158
|
};
|
|
159
159
|
|
|
160
|
-
// Provider selection
|
|
161
|
-
//
|
|
160
|
+
// Provider selection uses the shared resolver (local whisper-cpp by default
|
|
161
|
+
// without legacy remote discovery). Bind this identity when wiring the worker.
|
|
162
162
|
const providerName = resolveTranscriptionProviderName();
|
|
163
163
|
if (providerName === "whisper-cpp") {
|
|
164
164
|
// whisper.cpp's prebuilt CLIs — the local, no-Python path that actually
|
|
@@ -289,9 +289,10 @@ if (providerName === "whisper-cpp") {
|
|
|
289
289
|
console.warn(
|
|
290
290
|
"[transcribe] NO transcription provider is reachable — audio attachments " +
|
|
291
291
|
"will be accepted but never transcribed. " +
|
|
292
|
-
`Provider resolved to "${providerName}".
|
|
293
|
-
"`parachute-vault transcription install`
|
|
294
|
-
"
|
|
292
|
+
`Provider resolved to "${providerName}". The standalone Scribe service is retired. ` +
|
|
293
|
+
"For explicit transcription, use `parachute-vault transcription install` and " +
|
|
294
|
+
"check `parachute-vault transcription status`. Automatic uploads require " +
|
|
295
|
+
"the selected worker to be active and ready. " +
|
|
295
296
|
"Set `auto_transcribe.enabled: false` to silence this if you don't want transcription.",
|
|
296
297
|
);
|
|
297
298
|
}
|
|
@@ -756,4 +757,3 @@ setInterval(() => {
|
|
|
756
757
|
}
|
|
757
758
|
void shutdown("STOP_SIGNAL");
|
|
758
759
|
}, STOP_POLL_MS).unref();
|
|
759
|
-
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Process-singleton holder for the active TranscriptionWorker (vault#353).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* for an event-driven re-run. Absent the worker (no scribe), the retry
|
|
8
|
-
* endpoint still flips the attachment back to `pending` so the sweep would
|
|
9
|
-
* pick it up — but it'll just sit there until scribe shows up.
|
|
4
|
+
* The server registers its selected local or remote worker at boot after
|
|
5
|
+
* wiring the attachment hook. Retry endpoints use the worker to kick jobs;
|
|
6
|
+
* automatic-upload eligibility also checks the startup provider identity.
|
|
10
7
|
*/
|
|
11
8
|
|
|
12
9
|
import type { TranscriptionWorker } from "./transcription-worker.ts";
|
|
13
10
|
|
|
14
11
|
let activeWorker: TranscriptionWorker | null = null;
|
|
12
|
+
let activeProvider: string | null = null;
|
|
15
13
|
|
|
16
|
-
export function setTranscriptionWorker(worker: TranscriptionWorker | null): void {
|
|
14
|
+
export function setTranscriptionWorker(worker: TranscriptionWorker | null, provider: string | null = null): void {
|
|
17
15
|
activeWorker = worker;
|
|
16
|
+
activeProvider = worker ? provider : null;
|
|
18
17
|
}
|
|
19
18
|
|
|
19
|
+
/** Provider bound at worker startup, not a newly edited environment value. */
|
|
20
|
+
export function getTranscriptionWorkerProvider(): string | null { return activeProvider; }
|
|
21
|
+
|
|
20
22
|
export function getTranscriptionWorker(): TranscriptionWorker | null {
|
|
21
23
|
return activeWorker;
|
|
22
24
|
}
|
package/src/vault.test.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { TagScopeCtx } from "./routes.ts";
|
|
|
17
17
|
import { extractApiKey } from "./auth.ts";
|
|
18
18
|
import { startTranscriptionWorker } from "./transcription-worker.ts";
|
|
19
19
|
import { setTranscriptionWorker } from "./transcription-registry.ts";
|
|
20
|
+
import { clearScribeUrlCache } from "./scribe-discovery.ts";
|
|
20
21
|
import type { Store } from "../core/src/types.ts";
|
|
21
22
|
|
|
22
23
|
let db: Database;
|
|
@@ -3314,23 +3315,45 @@ describe("HTTP /notes", async () => {
|
|
|
3314
3315
|
// left looking like an ordinary upload — it records WHY nothing happened.
|
|
3315
3316
|
// It is still not `pending` (nothing was enqueued) and the note is still
|
|
3316
3317
|
// untouched (no stub, since the caller never asked for one).
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3318
|
+
//
|
|
3319
|
+
// Fixture: select the remote provider so the presence/absence of a local
|
|
3320
|
+
// whisper-cpp install on the host cannot change the message. With no
|
|
3321
|
+
// SCRIBE_URL configured, auto-transcribe is still unavailable and reports
|
|
3322
|
+
// the same actionable no-provider error on every box.
|
|
3323
|
+
test("audio with no flag + scribe-http without URL records the failure; note untouched", async () => {
|
|
3324
|
+
const prevProvider = process.env.TRANSCRIPTION_PROVIDER;
|
|
3325
|
+
const prevScribeUrl = process.env.SCRIBE_URL;
|
|
3326
|
+
process.env.TRANSCRIPTION_PROVIDER = "scribe-http";
|
|
3327
|
+
delete process.env.SCRIBE_URL;
|
|
3328
|
+
clearScribeUrlCache();
|
|
3329
|
+
try {
|
|
3330
|
+
await store.createNote("note body", { id: "v2" });
|
|
3331
|
+
const res = await handleNotes(
|
|
3332
|
+
mkReq("POST", "/notes/v2/attachments", {
|
|
3333
|
+
path: "memos/memo-2.webm",
|
|
3334
|
+
mimeType: "audio/webm",
|
|
3335
|
+
}),
|
|
3336
|
+
store,
|
|
3337
|
+
"/v2/attachments",
|
|
3338
|
+
);
|
|
3339
|
+
expect(res.status).toBe(201);
|
|
3340
|
+
const att = await res.json() as any;
|
|
3341
|
+
expect(att.metadata?.transcribe_status).toBe("failed");
|
|
3342
|
+
expect(att.metadata?.transcribe_error).toMatch(/no transcription provider configured/i);
|
|
3343
|
+
expect(att.metadata?.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
|
|
3344
|
+
expect(att.metadata?.transcribe_error).toMatch(/SCRIBE_URL/);
|
|
3345
|
+
expect(att.metadata?.transcribe_origin).toBe("auto");
|
|
3346
|
+
|
|
3347
|
+
const note = await store.getNote("v2");
|
|
3348
|
+
expect(note!.content).toBe("note body");
|
|
3349
|
+
expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
3350
|
+
} finally {
|
|
3351
|
+
if (prevProvider === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
|
|
3352
|
+
else process.env.TRANSCRIPTION_PROVIDER = prevProvider;
|
|
3353
|
+
if (prevScribeUrl === undefined) delete process.env.SCRIBE_URL;
|
|
3354
|
+
else process.env.SCRIBE_URL = prevScribeUrl;
|
|
3355
|
+
clearScribeUrlCache();
|
|
3356
|
+
}
|
|
3334
3357
|
});
|
|
3335
3358
|
|
|
3336
3359
|
// The explicit opt-out. `transcribe: false` is a caller saying no, and it
|