@openparachute/vault 0.7.5-rc.5 → 0.7.5-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/package.json +1 -1
- package/src/attachment-tickets.test.ts +66 -0
- package/src/attachment-tickets.ts +21 -2
- package/src/auto-transcribe.test.ts +112 -1
- package/src/auto-transcribe.ts +81 -3
- package/src/routes.ts +28 -3
- package/src/server.ts +14 -1
- package/src/vault.test.ts +24 -2
- package/web/ui/dist/assets/{index-CD4kPSY9.js → index-cdAcASvO.js} +14 -14
- package/web/ui/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -270,6 +270,72 @@ describe("attachment tickets — upload lifecycle", () => {
|
|
|
270
270
|
expect((updatedNote!.metadata as any)?.transcribe_stub).toBe(true);
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
+
// vault#643 — the fresh-install case. Auto-transcribe is ON by default and
|
|
274
|
+
// no provider is reachable (SCRIBE_URL unset, no scribe row), which is
|
|
275
|
+
// exactly what a new box looks like. The upload must not come back looking
|
|
276
|
+
// like an ordinary file: it carries a `failed` status and an actionable
|
|
277
|
+
// reason, so the state is visible in the API and the admin SPA.
|
|
278
|
+
test("audio + auto-transcribe on + NO provider → attachment records the failure, not silence", async () => {
|
|
279
|
+
const vaultName = freshVault("tickets-no-provider");
|
|
280
|
+
const store = getVaultStore(vaultName);
|
|
281
|
+
const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
|
|
282
|
+
|
|
283
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
284
|
+
note: note.id,
|
|
285
|
+
filename: "memo.webm",
|
|
286
|
+
size_bytes: 4,
|
|
287
|
+
// NO `transcribe: true` — this is the AUTO path, the one that used to
|
|
288
|
+
// silently do nothing.
|
|
289
|
+
});
|
|
290
|
+
const res = await routeReq(
|
|
291
|
+
new Request(mint.url, {
|
|
292
|
+
method: "PUT",
|
|
293
|
+
headers: { "content-type": "audio/webm" },
|
|
294
|
+
body: new Uint8Array([1, 2, 3, 4]),
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
expect(res.status).toBe(201);
|
|
298
|
+
const attachment = (await res.json()) as any;
|
|
299
|
+
expect(attachment.metadata.transcribe_status).toBe("failed");
|
|
300
|
+
expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
|
|
301
|
+
// The reason has to be actionable — naming both routes out of it.
|
|
302
|
+
expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
|
|
303
|
+
expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
|
|
304
|
+
expect(attachment.metadata.transcribe_origin).toBe("auto");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// The counter-case: turning auto-transcribe OFF must stay silent. The
|
|
308
|
+
// operator asked for nothing to happen, so nothing happening is correct and
|
|
309
|
+
// must not be dressed up as a failure.
|
|
310
|
+
test("audio + auto-transcribe explicitly OFF → no transcribe metadata at all", async () => {
|
|
311
|
+
const vaultName = freshVault("tickets-transcribe-off");
|
|
312
|
+
const store = getVaultStore(vaultName);
|
|
313
|
+
writeVaultConfig({
|
|
314
|
+
name: vaultName,
|
|
315
|
+
api_keys: [],
|
|
316
|
+
created_at: new Date().toISOString(),
|
|
317
|
+
auto_transcribe: { enabled: false },
|
|
318
|
+
} as never);
|
|
319
|
+
const note = await store.createNote("# Voice memo\n", { path: "memo-off" });
|
|
320
|
+
|
|
321
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
322
|
+
note: note.id,
|
|
323
|
+
filename: "memo.webm",
|
|
324
|
+
size_bytes: 4,
|
|
325
|
+
});
|
|
326
|
+
const res = await routeReq(
|
|
327
|
+
new Request(mint.url, {
|
|
328
|
+
method: "PUT",
|
|
329
|
+
headers: { "content-type": "audio/webm" },
|
|
330
|
+
body: new Uint8Array([1, 2, 3, 4]),
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
expect(res.status).toBe(201);
|
|
334
|
+
const attachment = (await res.json()) as any;
|
|
335
|
+
expect(attachment.metadata.transcribe_status).toBeUndefined();
|
|
336
|
+
expect(attachment.metadata.transcribe_error).toBeUndefined();
|
|
337
|
+
});
|
|
338
|
+
|
|
273
339
|
test("segment_index (voice W2): a valid integer >= 0 rides ticket mint through to the attachment row", async () => {
|
|
274
340
|
const vaultName = freshVault("tickets-segment");
|
|
275
341
|
const store = getVaultStore(vaultName);
|
|
@@ -22,7 +22,11 @@ import type { Store } from "../core/src/types.ts";
|
|
|
22
22
|
import type { AttachmentTicket, AttachmentTicketProvider } from "../core/src/attachment/tickets.ts";
|
|
23
23
|
import { sanitizeAttachmentExtension } from "../core/src/attachment/policy.ts";
|
|
24
24
|
import { assetsDir, readVaultConfig } from "./config.ts";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
NO_PROVIDER_ERROR,
|
|
27
|
+
classifyAutoTranscribe,
|
|
28
|
+
warnNoTranscriptionProvider,
|
|
29
|
+
} from "./auto-transcribe.ts";
|
|
26
30
|
import { invalidateUsageCache } from "./usage.ts";
|
|
27
31
|
|
|
28
32
|
function json(data: unknown, status = 200): Response {
|
|
@@ -265,7 +269,13 @@ async function handleUploadSpend(
|
|
|
265
269
|
// auto-transcribe toggle.
|
|
266
270
|
const explicitOptIn = ticket.transcribe === true;
|
|
267
271
|
const perVaultEnabled = readVaultConfig(vaultName)?.auto_transcribe?.enabled;
|
|
268
|
-
const
|
|
272
|
+
const autoDecision = explicitOptIn
|
|
273
|
+
? ({ kind: "transcribe" } as const)
|
|
274
|
+
: classifyAutoTranscribe(ticket.mimeType, { perVaultEnabled });
|
|
275
|
+
const autoOptIn = !explicitOptIn && autoDecision.kind === "transcribe";
|
|
276
|
+
// vault#643 — same honesty as the REST path: enabled-but-unconfigured is a
|
|
277
|
+
// misconfiguration, not a silent no-op.
|
|
278
|
+
const transcribeUnavailable = !explicitOptIn && autoDecision.kind === "unavailable";
|
|
269
279
|
const attMeta: Record<string, unknown> = {
|
|
270
280
|
original_name: ticket.filename,
|
|
271
281
|
size: buffer.length,
|
|
@@ -280,6 +290,15 @@ async function handleUploadSpend(
|
|
|
280
290
|
if (ticket.segmentIndex !== undefined) {
|
|
281
291
|
attMeta.segment_index = ticket.segmentIndex;
|
|
282
292
|
}
|
|
293
|
+
} else if (transcribeUnavailable) {
|
|
294
|
+
attMeta.transcribe_status = "failed";
|
|
295
|
+
attMeta.transcribe_error = NO_PROVIDER_ERROR;
|
|
296
|
+
attMeta.transcribe_requested_at = new Date().toISOString();
|
|
297
|
+
attMeta.transcribe_origin = "auto";
|
|
298
|
+
if (ticket.segmentIndex !== undefined) {
|
|
299
|
+
attMeta.segment_index = ticket.segmentIndex;
|
|
300
|
+
}
|
|
301
|
+
warnNoTranscriptionProvider();
|
|
283
302
|
}
|
|
284
303
|
|
|
285
304
|
const attachment = await store.addAttachment(ticket.noteId, relativePath, ticket.mimeType, attMeta);
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, test, expect } from "bun:test";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
_resetNoProviderWarnForTest,
|
|
11
|
+
classifyAutoTranscribe,
|
|
12
|
+
shouldAutoTranscribe,
|
|
13
|
+
warnNoTranscriptionProvider,
|
|
14
|
+
} from "./auto-transcribe.ts";
|
|
10
15
|
|
|
11
16
|
function readGlobalConfig(enabled: boolean | undefined) {
|
|
12
17
|
return () => ({
|
|
@@ -170,3 +175,109 @@ describe("shouldAutoTranscribe", () => {
|
|
|
170
175
|
});
|
|
171
176
|
});
|
|
172
177
|
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// vault#643 — "disabled" and "unavailable" are not the same answer.
|
|
181
|
+
//
|
|
182
|
+
// `shouldAutoTranscribe` collapsed both into `false`, so a box with
|
|
183
|
+
// auto-transcribe ON and no reachable provider accepted audio, transcribed
|
|
184
|
+
// nothing, wrote no marker, and logged nothing. The attachment was
|
|
185
|
+
// indistinguishable from a plain upload.
|
|
186
|
+
//
|
|
187
|
+
// That is the DEFAULT state of a fresh install: the provider resolves to
|
|
188
|
+
// `scribe-http`, nothing sets SCRIBE_URL, and voice memos silently never
|
|
189
|
+
// transcribe. The hosted door already marks a terminal state rather than
|
|
190
|
+
// skipping quietly (workers/vault/src/vault-do.ts — "voice not enabled for
|
|
191
|
+
// this plan" / "monthly voice limit reached"); this is the self-host twin.
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
describe("classifyAutoTranscribe (vault#643)", () => {
|
|
194
|
+
const audio = "audio/webm";
|
|
195
|
+
const withProvider = () => "http://127.0.0.1:1943";
|
|
196
|
+
const noProvider = () => undefined;
|
|
197
|
+
|
|
198
|
+
test("non-audio → not-audio, whatever else is true", () => {
|
|
199
|
+
expect(
|
|
200
|
+
classifyAutoTranscribe("image/png", { getCachedScribeUrlImpl: withProvider }).kind,
|
|
201
|
+
).toBe("not-audio");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("operator turned it off → disabled (silence is CORRECT here)", () => {
|
|
205
|
+
expect(
|
|
206
|
+
classifyAutoTranscribe(audio, {
|
|
207
|
+
perVaultEnabled: false,
|
|
208
|
+
getCachedScribeUrlImpl: withProvider,
|
|
209
|
+
}).kind,
|
|
210
|
+
).toBe("disabled");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("enabled + no provider → unavailable, NOT disabled — the whole point", () => {
|
|
214
|
+
expect(
|
|
215
|
+
classifyAutoTranscribe(audio, {
|
|
216
|
+
perVaultEnabled: true,
|
|
217
|
+
getCachedScribeUrlImpl: noProvider,
|
|
218
|
+
}).kind,
|
|
219
|
+
).toBe("unavailable");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a blank provider URL is unavailable, not a provider", () => {
|
|
223
|
+
expect(
|
|
224
|
+
classifyAutoTranscribe(audio, {
|
|
225
|
+
perVaultEnabled: true,
|
|
226
|
+
getCachedScribeUrlImpl: () => " ",
|
|
227
|
+
}).kind,
|
|
228
|
+
).toBe("unavailable");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("the default (no toggle set) with no provider is unavailable — the fresh-install case", () => {
|
|
232
|
+
expect(
|
|
233
|
+
classifyAutoTranscribe(audio, {
|
|
234
|
+
readGlobalConfigImpl: (() => ({})) as never,
|
|
235
|
+
getCachedScribeUrlImpl: noProvider,
|
|
236
|
+
}).kind,
|
|
237
|
+
).toBe("unavailable");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("enabled + provider → transcribe", () => {
|
|
241
|
+
expect(
|
|
242
|
+
classifyAutoTranscribe(audio, {
|
|
243
|
+
perVaultEnabled: true,
|
|
244
|
+
getCachedScribeUrlImpl: withProvider,
|
|
245
|
+
}).kind,
|
|
246
|
+
).toBe("transcribe");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("shouldAutoTranscribe stays a faithful boolean view of the classifier", () => {
|
|
250
|
+
for (const opts of [
|
|
251
|
+
{ perVaultEnabled: false, getCachedScribeUrlImpl: withProvider },
|
|
252
|
+
{ perVaultEnabled: true, getCachedScribeUrlImpl: noProvider },
|
|
253
|
+
{ perVaultEnabled: true, getCachedScribeUrlImpl: withProvider },
|
|
254
|
+
]) {
|
|
255
|
+
expect(shouldAutoTranscribe(audio, opts)).toBe(
|
|
256
|
+
classifyAutoTranscribe(audio, opts).kind === "transcribe",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("warnNoTranscriptionProvider throttle (vault#643)", () => {
|
|
263
|
+
test("warns once per window — a bulk import gets one line, not a hundred", () => {
|
|
264
|
+
_resetNoProviderWarnForTest();
|
|
265
|
+
const seen: string[] = [];
|
|
266
|
+
const orig = console.warn;
|
|
267
|
+
console.warn = (...args: unknown[]) => seen.push(String(args[0]));
|
|
268
|
+
try {
|
|
269
|
+
let now = 1_000_000;
|
|
270
|
+
const clock = () => now;
|
|
271
|
+
for (let i = 0; i < 50; i++) warnNoTranscriptionProvider(clock);
|
|
272
|
+
expect(seen.length).toBe(1);
|
|
273
|
+
expect(seen[0]).toMatch(/no transcription provider configured/i);
|
|
274
|
+
// Past the window → one more.
|
|
275
|
+
now += 61_000;
|
|
276
|
+
warnNoTranscriptionProvider(clock);
|
|
277
|
+
expect(seen.length).toBe(2);
|
|
278
|
+
} finally {
|
|
279
|
+
console.warn = orig;
|
|
280
|
+
_resetNoProviderWarnForTest();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
package/src/auto-transcribe.ts
CHANGED
|
@@ -59,12 +59,90 @@ export function shouldAutoTranscribe(
|
|
|
59
59
|
if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
|
|
60
60
|
return false;
|
|
61
61
|
}
|
|
62
|
+
return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Why an audio attachment is (or isn't) being transcribed. The three outcomes
|
|
67
|
+
* are NOT interchangeable, which is the whole point of this type:
|
|
68
|
+
*
|
|
69
|
+
* - `transcribe` — enqueue it.
|
|
70
|
+
* - `disabled` — the operator turned auto-transcribe off. Silence is
|
|
71
|
+
* correct: they asked for nothing to happen.
|
|
72
|
+
* - `unavailable` — auto-transcribe is ON, but no provider is reachable.
|
|
73
|
+
* This is a MISCONFIGURATION, and silence is wrong.
|
|
74
|
+
*
|
|
75
|
+
* vault#643: `shouldAutoTranscribe` collapsed the last two into `false`, so a
|
|
76
|
+
* box with transcription enabled and no reachable provider accepted audio,
|
|
77
|
+
* transcribed nothing, wrote no marker, logged nothing, and left an attachment
|
|
78
|
+
* indistinguishable from a plain upload. Observed on a fresh install: the
|
|
79
|
+
* provider resolves to `scribe-http` by default, nothing sets `SCRIBE_URL`, and
|
|
80
|
+
* voice memos silently never transcribe.
|
|
81
|
+
*
|
|
82
|
+
* The hosted door already gets this right — `workers/vault/src/vault-do.ts`
|
|
83
|
+
* marks a terminal state ("voice not enabled for this plan", "monthly voice
|
|
84
|
+
* limit reached") rather than skipping quietly, precisely so the operator never
|
|
85
|
+
* faces an eternal spinner. This brings self-host to the same posture.
|
|
86
|
+
*/
|
|
87
|
+
export type AutoTranscribeDecision =
|
|
88
|
+
| { kind: "transcribe" }
|
|
89
|
+
| { kind: "not-audio" }
|
|
90
|
+
| { kind: "disabled" }
|
|
91
|
+
| { kind: "unavailable" };
|
|
92
|
+
|
|
93
|
+
/** The full decision behind `shouldAutoTranscribe`. Same inputs, more answer. */
|
|
94
|
+
export function classifyAutoTranscribe(
|
|
95
|
+
mimeType: string,
|
|
96
|
+
opts: {
|
|
97
|
+
readGlobalConfigImpl?: typeof readGlobalConfig;
|
|
98
|
+
getCachedScribeUrlImpl?: () => string | undefined;
|
|
99
|
+
perVaultEnabled?: boolean;
|
|
100
|
+
enabledOverride?: boolean;
|
|
101
|
+
} = {},
|
|
102
|
+
): AutoTranscribeDecision {
|
|
103
|
+
if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
|
|
104
|
+
return { kind: "not-audio" };
|
|
105
|
+
}
|
|
62
106
|
const enabled = opts.enabledOverride
|
|
63
107
|
?? opts.perVaultEnabled
|
|
64
108
|
?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
|
|
65
109
|
?? true;
|
|
66
|
-
if (!enabled) return
|
|
110
|
+
if (!enabled) return { kind: "disabled" };
|
|
67
111
|
const url = (opts.getCachedScribeUrlImpl ?? getCachedScribeUrl)();
|
|
68
|
-
if (!url || !url.trim()) return
|
|
69
|
-
return
|
|
112
|
+
if (!url || !url.trim()) return { kind: "unavailable" };
|
|
113
|
+
return { kind: "transcribe" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `transcribe_error` written when auto-transcribe is on but no provider is
|
|
118
|
+
* reachable. Deliberately actionable — the operator needs to know which of the
|
|
119
|
+
* two things to do, not just that something went wrong.
|
|
120
|
+
*/
|
|
121
|
+
export const NO_PROVIDER_ERROR =
|
|
122
|
+
"no transcription provider configured — set TRANSCRIPTION_PROVIDER to a local " +
|
|
123
|
+
"provider (see `parachute-vault transcription install`), or point SCRIBE_URL at " +
|
|
124
|
+
"a transcription service";
|
|
125
|
+
|
|
126
|
+
/** Throttle for {@link warnNoTranscriptionProvider}. */
|
|
127
|
+
const NO_PROVIDER_WARN_INTERVAL_MS = 60_000;
|
|
128
|
+
let lastNoProviderWarnAt = 0;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Warn (at most once a minute) that audio is arriving with nowhere to send it.
|
|
132
|
+
*
|
|
133
|
+
* Throttled because it fires per-upload: a bulk import of a hundred voice
|
|
134
|
+
* memos should produce one actionable line, not a hundred. Silence was the old
|
|
135
|
+
* behaviour and it is what made this invisible — a box can accept audio for
|
|
136
|
+
* months and never say that transcription isn't wired up.
|
|
137
|
+
*/
|
|
138
|
+
export function warnNoTranscriptionProvider(now: () => number = Date.now): void {
|
|
139
|
+
const t = now();
|
|
140
|
+
if (t - lastNoProviderWarnAt < NO_PROVIDER_WARN_INTERVAL_MS) return;
|
|
141
|
+
lastNoProviderWarnAt = t;
|
|
142
|
+
console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Test seam: forget the throttle window. */
|
|
146
|
+
export function _resetNoProviderWarnForTest(): void {
|
|
147
|
+
lastNoProviderWarnAt = 0;
|
|
70
148
|
}
|
package/src/routes.ts
CHANGED
|
@@ -145,7 +145,12 @@ import {
|
|
|
145
145
|
import { join, extname, normalize } from "path";
|
|
146
146
|
import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
147
147
|
import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
|
|
148
|
-
import {
|
|
148
|
+
import {
|
|
149
|
+
NO_PROVIDER_ERROR,
|
|
150
|
+
classifyAutoTranscribe,
|
|
151
|
+
shouldAutoTranscribe,
|
|
152
|
+
warnNoTranscriptionProvider,
|
|
153
|
+
} from "./auto-transcribe.ts";
|
|
149
154
|
// usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
|
|
150
155
|
// of invalidateUsageCache does NOT form a cycle — routes.ts → usage.ts only.
|
|
151
156
|
import { invalidateUsageCache } from "./usage.ts";
|
|
@@ -2369,7 +2374,18 @@ async function handleNotesInner(
|
|
|
2369
2374
|
const perVaultEnabled = vault
|
|
2370
2375
|
? readVaultConfig(vault)?.auto_transcribe?.enabled
|
|
2371
2376
|
: undefined;
|
|
2372
|
-
|
|
2377
|
+
// vault#643: classify rather than collapse to a boolean. "Operator
|
|
2378
|
+
// turned it off" and "nothing is configured to do it" both used to read
|
|
2379
|
+
// as `false`, so a misconfigured box silently accepted audio and
|
|
2380
|
+
// transcribed nothing — no marker, no status, no log.
|
|
2381
|
+
const autoDecision = explicitOptIn
|
|
2382
|
+
? ({ kind: "transcribe" } as const)
|
|
2383
|
+
: classifyAutoTranscribe(body.mimeType, { perVaultEnabled });
|
|
2384
|
+
const autoOptIn = !explicitOptIn && autoDecision.kind === "transcribe";
|
|
2385
|
+
// Enabled, audio, but no reachable provider. Record it on the attachment
|
|
2386
|
+
// so the state is visible in the API and the admin SPA instead of the
|
|
2387
|
+
// upload looking like an ordinary file.
|
|
2388
|
+
const transcribeUnavailable = !explicitOptIn && autoDecision.kind === "unavailable";
|
|
2373
2389
|
// Per-segment slots (voice W2, cloud twin: workers/vault/src/rest/notes.ts
|
|
2374
2390
|
// ~791-800): an optional `segment_index` (integer >= 0) lets one recording
|
|
2375
2391
|
// split across several attachments on ONE note, each resolving into its
|
|
@@ -2385,7 +2401,16 @@ async function handleNotesInner(
|
|
|
2385
2401
|
transcribe_origin: (explicitOptIn ? "legacy" : "auto") as "legacy" | "auto",
|
|
2386
2402
|
...(validSegment ? { segment_index: segIdx } : {}),
|
|
2387
2403
|
}
|
|
2388
|
-
:
|
|
2404
|
+
: transcribeUnavailable
|
|
2405
|
+
? {
|
|
2406
|
+
transcribe_status: "failed" as const,
|
|
2407
|
+
transcribe_error: NO_PROVIDER_ERROR,
|
|
2408
|
+
transcribe_requested_at: new Date().toISOString(),
|
|
2409
|
+
transcribe_origin: "auto" as const,
|
|
2410
|
+
...(validSegment ? { segment_index: segIdx } : {}),
|
|
2411
|
+
}
|
|
2412
|
+
: undefined;
|
|
2413
|
+
if (transcribeUnavailable) warnNoTranscriptionProvider();
|
|
2389
2414
|
|
|
2390
2415
|
const attachment = await store.addAttachment(note.id, body.path, body.mimeType, attMeta);
|
|
2391
2416
|
|
package/src/server.ts
CHANGED
|
@@ -279,7 +279,20 @@ if (providerName === "whisper-cpp") {
|
|
|
279
279
|
wireTranscriptionWorker(transcriptionWorker);
|
|
280
280
|
console.log(`[transcribe] worker started → ${scribeUrl}`);
|
|
281
281
|
} else {
|
|
282
|
-
console.log
|
|
282
|
+
// vault#643: was a console.log reading "worker disabled", which states a
|
|
283
|
+
// fact about the worker and says nothing about the CONSEQUENCE. On a fresh
|
|
284
|
+
// box the provider resolves to `scribe-http` by default and nothing sets
|
|
285
|
+
// SCRIBE_URL, so this is the DEFAULT state — and audio silently never
|
|
286
|
+
// transcribes. Name the consequence, and warn rather than log, because the
|
|
287
|
+
// box is misconfigured for a feature that is on by default.
|
|
288
|
+
console.warn(
|
|
289
|
+
"[transcribe] NO transcription provider is reachable — audio attachments " +
|
|
290
|
+
"will be accepted but never transcribed. " +
|
|
291
|
+
`Provider resolved to "${providerName}". Fix with either: ` +
|
|
292
|
+
"`parachute-vault transcription install` + TRANSCRIPTION_PROVIDER=<local provider>, " +
|
|
293
|
+
"or point SCRIBE_URL at a transcription service. " +
|
|
294
|
+
"Set `auto_transcribe.enabled: false` to silence this if you don't want transcription.",
|
|
295
|
+
);
|
|
283
296
|
}
|
|
284
297
|
}
|
|
285
298
|
|
package/src/vault.test.ts
CHANGED
|
@@ -2877,7 +2877,12 @@ describe("HTTP /notes", async () => {
|
|
|
2877
2877
|
expect((note!.metadata as any)?.transcribe_stub).toBe(true);
|
|
2878
2878
|
});
|
|
2879
2879
|
|
|
2880
|
-
|
|
2880
|
+
// vault#643: audio with no explicit `transcribe` flag takes the AUTO path.
|
|
2881
|
+
// In this suite no provider is reachable, so the attachment is no longer
|
|
2882
|
+
// left looking like an ordinary upload — it records WHY nothing happened.
|
|
2883
|
+
// It is still not `pending` (nothing was enqueued) and the note is still
|
|
2884
|
+
// untouched (no stub, since the caller never asked for one).
|
|
2885
|
+
test("audio with no flag + no provider records the failure; note untouched", async () => {
|
|
2881
2886
|
await store.createNote("note body", { id: "v2" });
|
|
2882
2887
|
const res = await handleNotes(
|
|
2883
2888
|
mkReq("POST", "/notes/v2/attachments", {
|
|
@@ -2889,12 +2894,29 @@ describe("HTTP /notes", async () => {
|
|
|
2889
2894
|
);
|
|
2890
2895
|
expect(res.status).toBe(201);
|
|
2891
2896
|
const att = await res.json() as any;
|
|
2892
|
-
expect(att.metadata?.transcribe_status).
|
|
2897
|
+
expect(att.metadata?.transcribe_status).toBe("failed");
|
|
2898
|
+
expect(att.metadata?.transcribe_error).toMatch(/no transcription provider configured/i);
|
|
2893
2899
|
|
|
2894
2900
|
const note = await store.getNote("v2");
|
|
2895
2901
|
expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
2896
2902
|
});
|
|
2897
2903
|
|
|
2904
|
+
test("NON-audio with no flag still leaves metadata completely empty", async () => {
|
|
2905
|
+
await store.createNote("note body", { id: "v2b" });
|
|
2906
|
+
const res = await handleNotes(
|
|
2907
|
+
mkReq("POST", "/notes/v2b/attachments", {
|
|
2908
|
+
path: "docs/spec.pdf",
|
|
2909
|
+
mimeType: "application/pdf",
|
|
2910
|
+
}),
|
|
2911
|
+
store,
|
|
2912
|
+
"/v2b/attachments",
|
|
2913
|
+
);
|
|
2914
|
+
expect(res.status).toBe(201);
|
|
2915
|
+
const att = await res.json() as any;
|
|
2916
|
+
expect(att.metadata?.transcribe_status).toBeUndefined();
|
|
2917
|
+
expect(att.metadata?.transcribe_error).toBeUndefined();
|
|
2918
|
+
});
|
|
2919
|
+
|
|
2898
2920
|
test("transcribe: true preserves other note metadata", async () => {
|
|
2899
2921
|
await store.createNote("body", { id: "v3", metadata: { summary: "keep me" } });
|
|
2900
2922
|
await handleNotes(
|