@ossclip/core 0.1.15 → 0.1.17
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/blooper.ts +13 -0
- package/src/producer/antigravity.ts +220 -0
- package/src/producer/index.ts +37 -9
- package/src/producer/provider.ts +1 -1
- package/src/retake.ts +90 -7
package/package.json
CHANGED
package/src/blooper.ts
CHANGED
|
@@ -92,12 +92,25 @@ function matchMarker(wordText: string, want: string): MarkerMatch | null {
|
|
|
92
92
|
if (!norm) return null;
|
|
93
93
|
if (norm === want) return { surface: norm, exact: true };
|
|
94
94
|
if (want.length < FUZZY_MIN_MARKER_LEN) return null;
|
|
95
|
+
// A plain English inflection of the marker is a REAL word the speaker can
|
|
96
|
+
// say on purpose — "it removes the bloopers", describing the feature, sits
|
|
97
|
+
// at distance 1 from a "blooper" marker and the fuzzy arm cut 7.08s of a
|
|
98
|
+
// good announce take back to its sentence start (FINDINGS §133). Fuzzy
|
|
99
|
+
// exists for ASR mishearings of the SPOKEN marker; an inflection is far
|
|
100
|
+
// more likely content, so it stays exact-only, in both directions.
|
|
101
|
+
if (isPluralPair(norm, want)) return null;
|
|
95
102
|
if (levenshtein(norm, want) <= FUZZY_MAX_DISTANCE) {
|
|
96
103
|
return { surface: norm, exact: false };
|
|
97
104
|
}
|
|
98
105
|
return null;
|
|
99
106
|
}
|
|
100
107
|
|
|
108
|
+
/** Whether one token is the plain s/es plural of the other. */
|
|
109
|
+
function isPluralPair(a: string, b: string): boolean {
|
|
110
|
+
const [short, long] = a.length <= b.length ? [a, b] : [b, a];
|
|
111
|
+
return long === `${short}s` || long === `${short}es`;
|
|
112
|
+
}
|
|
113
|
+
|
|
101
114
|
/**
|
|
102
115
|
* Spans the speaker marked as bloopers.
|
|
103
116
|
*
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import { run } from "../exec";
|
|
3
|
+
import type { LlmProvider } from "./provider";
|
|
4
|
+
import { estimateTokens, type LlmUsage } from "./usage";
|
|
5
|
+
// Shared fence-stripper for replies that wrap the JSON in prose/markdown.
|
|
6
|
+
// A formatting concern, not a Claude dependency (FINDINGS §132, antigravity
|
|
7
|
+
// provider).
|
|
8
|
+
import { extractJsonObject } from "./claude-cli";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* agy's own print timeout defaults to 5m, which a beat-sheet call on a long
|
|
12
|
+
* transcript can exceed. Core's `run()` has no timeout of its own, so this
|
|
13
|
+
* flag is the ONLY clock on the spawn — without it a stuck call is a hang,
|
|
14
|
+
* with it a timeout surfaces as retry-then-throw (FINDINGS §132, antigravity
|
|
15
|
+
* provider).
|
|
16
|
+
*/
|
|
17
|
+
export const AGY_PRINT_TIMEOUT = "10m";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* agy takes the prompt as an argv argument only — no stdin — and macOS caps
|
|
21
|
+
* ARG_MAX around 1MB. Refuse before the OS does: a pre-spawn check turns
|
|
22
|
+
* E2BIG into a directed error naming providers that can take the prompt
|
|
23
|
+
* (FINDINGS §132, antigravity provider).
|
|
24
|
+
*/
|
|
25
|
+
export const MAX_AGY_PROMPT_BYTES = 700_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The argv for one `agy` print-mode call. Pure so the flag set is testable
|
|
29
|
+
* without spawning anything. `--disable-slash-commands` because a transcript
|
|
30
|
+
* prompt that happens to start with `/` must not expand as a skill.
|
|
31
|
+
*/
|
|
32
|
+
export function buildAgyArgs(
|
|
33
|
+
prompt: string,
|
|
34
|
+
opts: { model?: string; schemaJson: string },
|
|
35
|
+
): string[] {
|
|
36
|
+
return [
|
|
37
|
+
"-p",
|
|
38
|
+
prompt,
|
|
39
|
+
"--output-format",
|
|
40
|
+
"json",
|
|
41
|
+
"--disable-slash-commands",
|
|
42
|
+
"--json-schema",
|
|
43
|
+
opts.schemaJson,
|
|
44
|
+
"--print-timeout",
|
|
45
|
+
AGY_PRINT_TIMEOUT,
|
|
46
|
+
...(opts.model ? ["--model", opts.model] : []),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The agy JSON envelope, tolerated rather than trusted: every field absent is
|
|
52
|
+
* a valid outcome — the caller falls back to estimates and says so — so this
|
|
53
|
+
* never throws on a shape it doesn't recognise (same contract as
|
|
54
|
+
* `parseCliEnvelope`).
|
|
55
|
+
*
|
|
56
|
+
* Token mapping into `LlmUsage` terms:
|
|
57
|
+
* - `outputTokens` folds `thinking_tokens` in: thinking is output-side
|
|
58
|
+
* spend and LlmUsage has no thinking field of its own.
|
|
59
|
+
* - Whether `input_tokens` already includes cache reads is not documented,
|
|
60
|
+
* so it is cross-checked against `total_tokens`: if
|
|
61
|
+
* input + output + thinking + cache_read exceeds the total, input is
|
|
62
|
+
* already cache-inclusive and stands alone; otherwise cache reads are
|
|
63
|
+
* added. Worst case is a conservative over-count — the safe direction
|
|
64
|
+
* for a number a user might budget against (FINDINGS §132, antigravity
|
|
65
|
+
* provider).
|
|
66
|
+
*/
|
|
67
|
+
export function parseAgyEnvelope(stdout: string): {
|
|
68
|
+
status?: string;
|
|
69
|
+
response?: string;
|
|
70
|
+
error?: string;
|
|
71
|
+
structuredOutput?: unknown;
|
|
72
|
+
inputTokens?: number;
|
|
73
|
+
outputTokens?: number;
|
|
74
|
+
cachedInputTokens?: number;
|
|
75
|
+
} {
|
|
76
|
+
let env: Record<string, unknown>;
|
|
77
|
+
try {
|
|
78
|
+
env = JSON.parse(stdout.trim()) as Record<string, unknown>;
|
|
79
|
+
} catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
if (!env || typeof env !== "object") return {};
|
|
83
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
|
|
84
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
85
|
+
const u = (env.usage ?? {}) as Record<string, unknown>;
|
|
86
|
+
const input = num(u.input_tokens);
|
|
87
|
+
const output = num(u.output_tokens);
|
|
88
|
+
const thinking = num(u.thinking_tokens) ?? 0;
|
|
89
|
+
const cacheRead = num(u.cache_read_tokens) ?? 0;
|
|
90
|
+
const total = num(u.total_tokens);
|
|
91
|
+
const cacheInclusive =
|
|
92
|
+
input !== undefined && total !== undefined
|
|
93
|
+
? input + (output ?? 0) + thinking + cacheRead > total
|
|
94
|
+
: false;
|
|
95
|
+
return {
|
|
96
|
+
status: str(env.status),
|
|
97
|
+
response: str(env.response),
|
|
98
|
+
error: str(env.error),
|
|
99
|
+
structuredOutput: env.structured_output,
|
|
100
|
+
inputTokens: input === undefined ? undefined : cacheInclusive ? input : input + cacheRead,
|
|
101
|
+
outputTokens: output === undefined ? undefined : output + thinking,
|
|
102
|
+
cachedInputTokens: cacheRead || undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Failures a retry cannot fix: missing auth and a bad model slug are
|
|
108
|
+
* deterministic, and each retry burns another ~24k-token baseline call (agy's
|
|
109
|
+
* own agent context) for nothing (FINDINGS §132, antigravity provider).
|
|
110
|
+
*/
|
|
111
|
+
export function isNonRetryableAgyFailure(message: string): boolean {
|
|
112
|
+
return /authentication|not logged in|login|unknown model|invalid model/i.test(message);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Google Antigravity via the locally installed `agy` CLI. Uses whatever auth
|
|
117
|
+
* the CLI holds — a logged-in subscription, so producing a video consumes
|
|
118
|
+
* plan usage rather than pay-per-token API credits (`billed: false`, and agy
|
|
119
|
+
* reports no cost of its own).
|
|
120
|
+
*
|
|
121
|
+
* The schema rides twice: `--json-schema` for server-side enforcement AND
|
|
122
|
+
* stated in the prompt — the prompt copy is what makes the self-repair retry
|
|
123
|
+
* meaningful, and server enforcement is not our contract, so the reply is
|
|
124
|
+
* still zod-validated here.
|
|
125
|
+
*
|
|
126
|
+
* Requirements: `agy` on PATH (or OSSCLIP_AGY_BIN) and a prior interactive
|
|
127
|
+
* sign-in. The envelope carries no model id, so usage records the requested
|
|
128
|
+
* model or "antigravity-default" (FINDINGS §132, antigravity provider).
|
|
129
|
+
*/
|
|
130
|
+
export class AntigravityProvider implements LlmProvider {
|
|
131
|
+
readonly name = "antigravity";
|
|
132
|
+
readonly usage: LlmUsage[] = [];
|
|
133
|
+
|
|
134
|
+
constructor(
|
|
135
|
+
private model?: string,
|
|
136
|
+
private bin: string = process.env.OSSCLIP_AGY_BIN ?? "agy",
|
|
137
|
+
) {}
|
|
138
|
+
|
|
139
|
+
async complete<T>(req: {
|
|
140
|
+
system: string;
|
|
141
|
+
user: string;
|
|
142
|
+
schema: z.ZodType<T>;
|
|
143
|
+
schemaName: string;
|
|
144
|
+
}): Promise<T> {
|
|
145
|
+
const schemaText = JSON.stringify(z.toJSONSchema(req.schema));
|
|
146
|
+
const base =
|
|
147
|
+
`${req.system}\n\n${req.user}\n\n` +
|
|
148
|
+
`Respond with ONLY a JSON object valid against this JSON Schema ("${req.schemaName}"). ` +
|
|
149
|
+
`No markdown fences, no commentary, no tool use — just the JSON:\n${schemaText}`;
|
|
150
|
+
|
|
151
|
+
let lastError = "";
|
|
152
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
153
|
+
const prompt =
|
|
154
|
+
attempt === 0
|
|
155
|
+
? base
|
|
156
|
+
: `${base}\n\nYour previous reply failed validation:\n${lastError}\nReturn ONLY the corrected JSON.`;
|
|
157
|
+
const promptBytes = Buffer.byteLength(prompt, "utf8");
|
|
158
|
+
if (promptBytes > MAX_AGY_PROMPT_BYTES) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`prompt is ${promptBytes.toLocaleString("en-US")} bytes, over the ${MAX_AGY_PROMPT_BYTES.toLocaleString("en-US")}-byte limit for agy — ` +
|
|
161
|
+
`it accepts the prompt only as a command-line argument. ` +
|
|
162
|
+
`Use --llm claude-cli or --llm gemini for a take this long.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const started = Date.now();
|
|
166
|
+
let stdout = "";
|
|
167
|
+
try {
|
|
168
|
+
({ stdout } = await run(
|
|
169
|
+
this.bin,
|
|
170
|
+
buildAgyArgs(prompt, { model: this.model, schemaJson: schemaText }),
|
|
171
|
+
));
|
|
172
|
+
} catch (err) {
|
|
173
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
174
|
+
// run() embeds the stderr tail in its rejection, so auth/bad-slug
|
|
175
|
+
// failures are matchable here and fail fast instead of re-spending.
|
|
176
|
+
if (isNonRetryableAgyFailure(lastError)) break;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Recorded per ATTEMPT, before validation: a reply that failed the
|
|
180
|
+
// schema still spent the tokens, and a retry is exactly the cost a user
|
|
181
|
+
// would want to see rather than have quietly absorbed.
|
|
182
|
+
const envelope = parseAgyEnvelope(stdout);
|
|
183
|
+
this.usage.push({
|
|
184
|
+
provider: this.name,
|
|
185
|
+
// The envelope names no model, so record what was asked for — or the
|
|
186
|
+
// honest placeholder, which the cost report declines to price.
|
|
187
|
+
model: this.model ?? "antigravity-default",
|
|
188
|
+
schemaName: req.schemaName,
|
|
189
|
+
inputTokens: envelope.inputTokens ?? estimateTokens(prompt),
|
|
190
|
+
outputTokens: envelope.outputTokens ?? estimateTokens(envelope.response ?? stdout),
|
|
191
|
+
cachedInputTokens: envelope.cachedInputTokens,
|
|
192
|
+
exact: envelope.inputTokens !== undefined,
|
|
193
|
+
// The whole point of this provider: agy's cached sign-in means the
|
|
194
|
+
// subscription pays, not a card, and agy reports no cost to forward.
|
|
195
|
+
billed: false,
|
|
196
|
+
ms: Date.now() - started,
|
|
197
|
+
});
|
|
198
|
+
if (envelope.status !== "SUCCESS") {
|
|
199
|
+
lastError =
|
|
200
|
+
envelope.error ?? `agy reported status ${envelope.status ?? "unknown"}: ${stdout.slice(0, 300)}`;
|
|
201
|
+
if (isNonRetryableAgyFailure(lastError)) break;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
// Prefer the server-parsed object, but still validate it — schema
|
|
206
|
+
// enforcement on their side is not a contract on ours.
|
|
207
|
+
if (envelope.structuredOutput !== undefined) {
|
|
208
|
+
return req.schema.parse(envelope.structuredOutput);
|
|
209
|
+
}
|
|
210
|
+
return req.schema.parse(JSON.parse(extractJsonObject(envelope.response ?? stdout)));
|
|
211
|
+
} catch (err) {
|
|
212
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
throw new Error(
|
|
216
|
+
`agy CLI ('${this.bin}') did not produce valid ${req.schemaName} JSON: ${lastError.slice(0, 400)}\n` +
|
|
217
|
+
`Is Antigravity installed and logged in? (https://antigravity.google — run 'agy' once interactively to sign in)`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/producer/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Transcript } from "../schema";
|
|
|
2
2
|
import type { Scene, SceneComponentId } from "../scene-schema";
|
|
3
3
|
import type { LlmProvider, ProviderName } from "./provider";
|
|
4
4
|
import { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
|
|
5
|
+
import { AntigravityProvider } from "./antigravity";
|
|
5
6
|
import { ClaudeCliProvider } from "./claude-cli";
|
|
6
7
|
import { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
|
|
7
8
|
import { MockProvider } from "./mock";
|
|
@@ -27,6 +28,7 @@ export * from "./beats";
|
|
|
27
28
|
export * from "./scene-props";
|
|
28
29
|
export * from "./repair";
|
|
29
30
|
export { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
|
|
31
|
+
export { AntigravityProvider } from "./antigravity";
|
|
30
32
|
export { ClaudeCliProvider } from "./claude-cli";
|
|
31
33
|
export { GeminiProvider, DEFAULT_GEMINI_MODEL } from "./gemini";
|
|
32
34
|
export { MockProvider } from "./mock";
|
|
@@ -41,6 +43,11 @@ export function createProvider(name: ProviderName, model?: string): LlmProvider
|
|
|
41
43
|
return new ClaudeCliProvider(model);
|
|
42
44
|
case "gemini":
|
|
43
45
|
return new GeminiProvider(model ?? DEFAULT_GEMINI_MODEL);
|
|
46
|
+
case "antigravity":
|
|
47
|
+
// Rides agy's cached subscription sign-in. No default model on purpose:
|
|
48
|
+
// the editorial tier runs whatever the user configured agy itself to
|
|
49
|
+
// use (FINDINGS §132, antigravity provider).
|
|
50
|
+
return new AntigravityProvider(model);
|
|
44
51
|
case "mock":
|
|
45
52
|
return new MockProvider();
|
|
46
53
|
}
|
|
@@ -57,6 +64,11 @@ export const DEFAULT_FAST_MODEL: Partial<Record<ProviderName, string>> = {
|
|
|
57
64
|
claude: "claude-haiku-4-5-20251001",
|
|
58
65
|
"claude-cli": "claude-haiku-4-5-20251001",
|
|
59
66
|
gemini: "gemini-3.5-flash-lite",
|
|
67
|
+
// Substring-matches the `gemini-3.6-flash` pricing family, so mechanical
|
|
68
|
+
// calls price from the existing table with no changes; the editorial tier
|
|
69
|
+
// (agy's own default, reported as "antigravity-default") matches nothing and
|
|
70
|
+
// reports "cost unknown" — the house honesty rule, not an oversight.
|
|
71
|
+
antigravity: "gemini-3.6-flash-low",
|
|
60
72
|
};
|
|
61
73
|
|
|
62
74
|
export interface TieringOptions {
|
|
@@ -87,17 +99,33 @@ export function createTieredProvider(
|
|
|
87
99
|
/**
|
|
88
100
|
* Default provider when --llm isn't given, in preference order.
|
|
89
101
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
102
|
+
* Subscription CLIs beat ambient env keys (2026-08 decision, FINDINGS §132,
|
|
103
|
+
* antigravity provider): a logged-in `agy` or `claude` is an explicit,
|
|
104
|
+
* already-paid choice the user made on this machine, while an API key in the
|
|
105
|
+
* environment may just be lying around — and picking the key spends real
|
|
106
|
+
* per-token money the subscription would have covered. agy carries the same
|
|
107
|
+
* ~24k-token harness baseline per call that claude-cli does, but it is
|
|
108
|
+
* subscription-covered, so the weight costs nothing.
|
|
96
109
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
110
|
+
* Among the keys, Gemini leads on measured evidence, not vendor preference: on
|
|
111
|
+
* the same clip it ran 3,540 input tokens against the Claude CLI's 83,378 —
|
|
112
|
+
* the CLI re-sends its whole harness prefix per invocation — for ~$0.05
|
|
113
|
+
* against ~$0.85 and 27s against 171s, with editorial output that held up.
|
|
114
|
+
* Both models recovered the mishearing that matters ("coach and" → "code
|
|
115
|
+
* churn"); Claude is stronger only at recovering a mangled PROPER NOUN, which
|
|
116
|
+
* `--speaker` addresses directly.
|
|
117
|
+
*
|
|
118
|
+
* Falling back to the Claude Code CLI last keeps the nothing-configured path
|
|
119
|
+
* failing with install guidance rather than silence. The `hasBin` default of
|
|
120
|
+
* `() => false` keeps this pure — callers that can see a filesystem (the CLI)
|
|
121
|
+
* inject a real checker; everyone else gets the key-order behavior unchanged.
|
|
99
122
|
*/
|
|
100
|
-
export function defaultProviderName(
|
|
123
|
+
export function defaultProviderName(
|
|
124
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
125
|
+
hasBin: (bin: string) => boolean = () => false,
|
|
126
|
+
): ProviderName {
|
|
127
|
+
if (hasBin(env.OSSCLIP_AGY_BIN ?? "agy")) return "antigravity";
|
|
128
|
+
if (hasBin(env.OSSCLIP_CLAUDE_BIN ?? "claude")) return "claude-cli";
|
|
101
129
|
if (env.GEMINI_API_KEY) return "gemini";
|
|
102
130
|
if (env.ANTHROPIC_API_KEY) return "claude";
|
|
103
131
|
return "claude-cli";
|
package/src/producer/provider.ts
CHANGED
package/src/retake.ts
CHANGED
|
@@ -223,13 +223,18 @@ function silenceFraction(silences: readonly Span[], start: number, end: number):
|
|
|
223
223
|
* sits earlier is never cut, so a spurious split ends at a report line,
|
|
224
224
|
* not a shear through live audio.
|
|
225
225
|
*/
|
|
226
|
+
/** An Instance plus whether the restart split fragmented its coarse sentence. */
|
|
227
|
+
interface SentenceInstance extends Instance {
|
|
228
|
+
wasSplit: boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
226
231
|
function buildInstances(
|
|
227
232
|
transcript: Transcript,
|
|
228
233
|
analysis: Pick<Analysis, "silences" | "fillers">,
|
|
229
234
|
transparentMarker?: string,
|
|
230
|
-
): Instance[] {
|
|
235
|
+
): { fragments: Instance[]; sentences: SentenceInstance[] } {
|
|
231
236
|
const words = transcript.words;
|
|
232
|
-
if (words.length === 0) return [];
|
|
237
|
+
if (words.length === 0) return { fragments: [], sentences: [] };
|
|
233
238
|
const fillerIndices = new Set(analysis.fillers.map((f) => f.wordIndex));
|
|
234
239
|
const marker = transparentMarker ? normalizeToken(transparentMarker) : undefined;
|
|
235
240
|
|
|
@@ -269,6 +274,7 @@ function buildInstances(
|
|
|
269
274
|
};
|
|
270
275
|
|
|
271
276
|
const instances: Instance[] = [];
|
|
277
|
+
const sentences: SentenceInstance[] = [];
|
|
272
278
|
for (const sent of coarse) {
|
|
273
279
|
// A sentence that is ALREADY silence-dominated end-to-end is the
|
|
274
280
|
// hallucination shape, not the restart shape: whisper sprinkles sparse
|
|
@@ -334,8 +340,17 @@ function buildInstances(
|
|
|
334
340
|
// runs out of words mid-sentence is a trailing abandoned partial, not a
|
|
335
341
|
// finished take, whatever fragment boundary it happens to land on.
|
|
336
342
|
instances.push(toInstance(fragStart, sent.end, isSentenceEnd(transcript, sent.end), true));
|
|
343
|
+
// The whole coarse sentence, rejoined across its restart splits, for the
|
|
344
|
+
// §135 sentence-level pass: VO-paced delivery pauses INSIDE a sentence,
|
|
345
|
+
// so both takes of a genuine retake fragment into pieces that never
|
|
346
|
+
// match each other. `wasSplit` records whether the split actually fired —
|
|
347
|
+
// the pass only acts where fragmentation could have hidden a match.
|
|
348
|
+
sentences.push({
|
|
349
|
+
...toInstance(sent.start, sent.end, isSentenceEnd(transcript, sent.end), true),
|
|
350
|
+
wasSplit: splitAfter.length > 0,
|
|
351
|
+
});
|
|
337
352
|
}
|
|
338
|
-
return instances;
|
|
353
|
+
return { fragments: instances, sentences };
|
|
339
354
|
}
|
|
340
355
|
|
|
341
356
|
// ---- matching -------------------------------------------------------------
|
|
@@ -427,11 +442,21 @@ function toPublic(i: Instance): RetakeInstance {
|
|
|
427
442
|
* a clause boundary, not an abandoned take: its own sentence continues
|
|
428
443
|
* without it, so it goes to `undecided` (report-only), never `cuts`.
|
|
429
444
|
*/
|
|
430
|
-
function buildGroup(
|
|
445
|
+
function buildGroup(
|
|
446
|
+
chain: readonly Instance[],
|
|
447
|
+
hallucinated: readonly Instance[],
|
|
448
|
+
// The fragment pass keeps its strict bar. The §135 sentence pass gates
|
|
449
|
+
// survivors at the HALLUCINATION bar instead: a whole sentence carrying
|
|
450
|
+
// deliberate mid-sentence pauses is ordinary read-aloud delivery — the
|
|
451
|
+
// field run's kept take sat at 36% dead air, over the fragment bar by one
|
|
452
|
+
// point, and a textbook retake went report-only for it. Below the
|
|
453
|
+
// hallucination bar, a sentence-level survivor is a real take.
|
|
454
|
+
survivorBar: number = RESTART_SPLIT_MIN_SIL,
|
|
455
|
+
): RetakeGroup {
|
|
431
456
|
const completes = chain.filter((i) => i.complete);
|
|
432
457
|
const lastComplete = completes[completes.length - 1];
|
|
433
458
|
const kept =
|
|
434
|
-
lastComplete !== undefined && lastComplete.silenceFrac <=
|
|
459
|
+
lastComplete !== undefined && lastComplete.silenceFrac <= survivorBar
|
|
435
460
|
? lastComplete
|
|
436
461
|
: undefined;
|
|
437
462
|
const hallu: RetakeHallucination[] = hallucinated.map((i) => ({
|
|
@@ -511,7 +536,65 @@ export function findRetakeGroups(
|
|
|
511
536
|
analysis: Pick<Analysis, "silences" | "fillers">,
|
|
512
537
|
opts: { transparentMarker?: string } = {},
|
|
513
538
|
): RetakeGroup[] {
|
|
514
|
-
const
|
|
539
|
+
const { fragments, sentences } = buildInstances(transcript, analysis, opts.transparentMarker);
|
|
540
|
+
const groups = chainGroups(fragments);
|
|
541
|
+
|
|
542
|
+
// §135 sentence-level pass — the VO field case. Deliberate mid-sentence
|
|
543
|
+
// pauses (read-aloud delivery) make the restart split shred BOTH takes of a
|
|
544
|
+
// genuine retake into fragments that never match each other, and the
|
|
545
|
+
// fragment pass above reports nothing ("takes half [3s] a second" vs
|
|
546
|
+
// "…half a millisecond" — whole-sentence similarity 0.875, zero fragment
|
|
547
|
+
// matches). This pass re-runs the SAME chain algorithm over whole coarse
|
|
548
|
+
// sentences, then keeps a group only when:
|
|
549
|
+
// (a) some member's sentence actually WAS split — unsplit sentences were
|
|
550
|
+
// already compared whole by the fragment pass, so acting on them here
|
|
551
|
+
// could only second-guess a decision that pass already made; and
|
|
552
|
+
// (b) no member's words are claimed by a fragment-pass group — including
|
|
553
|
+
// its `undecided` members, so a cut the abandonment/cut-validation
|
|
554
|
+
// rules (§128) deliberately DECLINED stays declined.
|
|
555
|
+
// C1-style parallel rhetoric is safe here by construction: those are
|
|
556
|
+
// different sentences, and whole-sentence similarity scores the divergence
|
|
557
|
+
// the fragment prefixes hid.
|
|
558
|
+
const claimed = new Set<number>();
|
|
559
|
+
for (const g of groups) {
|
|
560
|
+
const members = [g.kept, ...g.cuts, ...g.hallucinated, ...g.undecided];
|
|
561
|
+
for (const m of members) {
|
|
562
|
+
if (!m) continue;
|
|
563
|
+
for (let w = m.startWord; w <= m.endWord; w++) claimed.add(w);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const splitByStartWord = new Map(sentences.map((s) => [s.startWord, s.wasSplit]));
|
|
567
|
+
for (const g of chainGroups(sentences, HALLUCINATION_SILENCE_FRAC)) {
|
|
568
|
+
const members = [g.kept, ...g.cuts, ...g.hallucinated, ...g.undecided].filter(
|
|
569
|
+
(m): m is RetakeInstance => m !== null,
|
|
570
|
+
);
|
|
571
|
+
if (!members.some((m) => splitByStartWord.get(m.startWord))) continue;
|
|
572
|
+
let overlaps = false;
|
|
573
|
+
for (const m of members) {
|
|
574
|
+
for (let w = m.startWord; w <= m.endWord && !overlaps; w++) {
|
|
575
|
+
if (claimed.has(w)) overlaps = true;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (overlaps) continue;
|
|
579
|
+
groups.push(g);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Two passes can interleave in transcript order — the report reads in
|
|
583
|
+
// word order, whichever pass found the group.
|
|
584
|
+
groups.sort((a, b) => {
|
|
585
|
+
const first = (g: RetakeGroup): number =>
|
|
586
|
+
Math.min(
|
|
587
|
+
...[g.kept, ...g.cuts, ...g.hallucinated, ...g.undecided]
|
|
588
|
+
.filter((m): m is RetakeInstance => m !== null)
|
|
589
|
+
.map((m) => m.startWord),
|
|
590
|
+
);
|
|
591
|
+
return first(a) - first(b);
|
|
592
|
+
});
|
|
593
|
+
return groups;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** The chaining rule (doc block above), shared by both passes verbatim. */
|
|
597
|
+
function chainGroups(instances: readonly Instance[], survivorBar?: number): RetakeGroup[] {
|
|
515
598
|
const groups: RetakeGroup[] = [];
|
|
516
599
|
|
|
517
600
|
let anchor: Instance | null = null;
|
|
@@ -520,7 +603,7 @@ export function findRetakeGroups(
|
|
|
520
603
|
|
|
521
604
|
const finalize = (): void => {
|
|
522
605
|
if (chain.length >= 2 || (chain.length >= 1 && hallucinated.length > 0)) {
|
|
523
|
-
groups.push(buildGroup(chain, hallucinated));
|
|
606
|
+
groups.push(buildGroup(chain, hallucinated, survivorBar));
|
|
524
607
|
}
|
|
525
608
|
chain = [];
|
|
526
609
|
hallucinated = [];
|