@gentbajko/slopify 1.5.0 → 2.0.0
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/README.md +34 -8
- package/dist/adapters/alignment/cache.js +67 -7
- package/dist/edge/cli.js +10 -2
- package/dist/edge/docker-launch.js +77 -0
- package/dist/edge/docker-projects/activation.js +35 -0
- package/dist/edge/docker-projects/claims.js +85 -0
- package/dist/edge/docker-projects/committed.js +40 -0
- package/dist/edge/docker-projects/engine.js +384 -0
- package/dist/edge/docker-projects/install.js +246 -0
- package/dist/edge/docker-projects/recover.js +100 -0
- package/dist/edge/docker-projects/state.js +221 -0
- package/dist/edge/docker-projects/tree.js +170 -0
- package/dist/edge/docker-projects/volume.js +95 -0
- package/dist/edge/docker.js +6 -0
- package/dist/edge/http/actions.js +64 -15
- package/dist/edge/http/app.js +6 -0
- package/dist/edge/http/folder-location-schema.js +11 -0
- package/dist/edge/http/folder-location.js +65 -0
- package/dist/edge/http/open-folder.js +2 -14
- package/dist/edge/http/projects.js +2 -0
- package/dist/edge/http/revision-files.js +2 -18
- package/dist/kernel/db/migrations/0012-project-recovery.sql +17 -0
- package/dist/kernel/lock.js +20 -6
- package/dist/main.js +11 -1
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/admission/schema.js +3 -1
- package/dist/slices/article/plain.js +16 -9
- package/dist/slices/control/revision-control.js +2 -2
- package/dist/slices/narration/pronunciation-chunks.js +105 -0
- package/dist/slices/narration/pronunciation.js +126 -0
- package/dist/slices/narration/steering.js +82 -24
- package/dist/slices/play-drafts/convert.js +3 -1
- package/dist/slices/play-drafts/schema.js +3 -1
- package/dist/slices/rebuild/admission-repo.js +7 -4
- package/dist/slices/rebuild/preview-plan.js +1 -1
- package/dist/slices/rebuild/preview-retained.js +16 -3
- package/dist/slices/rebuild/recipe-audio-parts.js +70 -14
- package/dist/slices/rebuild/recipe-audio.js +61 -40
- package/dist/slices/rebuild/recipe-model.js +5 -3
- package/dist/slices/rebuild/recipe-preparation.js +6 -4
- package/dist/slices/rebuild/recipe-text.js +11 -3
- package/dist/slices/rebuild/recovery-conflict.js +30 -0
- package/dist/slices/rebuild/recovery-model.js +44 -0
- package/dist/slices/rebuild/recovery-repo.js +84 -0
- package/dist/slices/rebuild/recovery-selection.js +68 -0
- package/dist/slices/rebuild/recovery.js +257 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +3 -2
- package/dist/slices/rebuild/runtime-narration-publication.js +30 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +20 -2
- package/dist/slices/rebuild/runtime-plan.js +8 -2
- package/dist/slices/rebuild/runtime-publication.js +9 -4
- package/dist/slices/rebuild/service.js +44 -22
- package/dist/slices/rebuild/transition-repo.js +1 -1
- package/dist/slices/revisions/mutation-assets.js +6 -1
- package/dist/slices/revisions/mutation-request.js +4 -0
- package/dist/slices/revisions/mutations.js +10 -5
- package/dist/slices/revisions/rules.js +43 -0
- package/dist/slices/revisions/schema.js +12 -0
- package/dist/slices/settings/repo.js +4 -2
- package/dist/web/assets/index-CZNiHm8P.css +1 -0
- package/dist/web/assets/index-DPgGLX5m.js +137 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/scripts/docker-run.sh +1 -74
- package/dist/web/assets/index-CbGJtCio.js +0 -137
- package/dist/web/assets/index-DX1_chKs.css +0 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { remark } from "remark";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
// Inworld requires English IPA, not every symbol in Unicode's IPA Extensions block.
|
|
4
|
+
// https://docs.inworld.ai/tts/capabilities/custom-pronunciation
|
|
5
|
+
const ipaAtom = "[ˈˌ]?[abdefghijklmnoprstuvwxzæðŋθɑɒɔəɚɛɜɝɡɪɹʃʊʌʒʔɫɾ][\\u0303\\u031a\\u0325\\u0329\\u032a\\u032c\\u032f\\u035c\\u0361ʰʲʷ]*[ːˑ]?";
|
|
6
|
+
const ipaSymbols = new RegExp(`^(?:${ipaAtom})+(?:\\.(?:${ipaAtom})+)*$`, "u");
|
|
7
|
+
function textOf(node) {
|
|
8
|
+
if (node.type === "break")
|
|
9
|
+
return "\n";
|
|
10
|
+
return node.value ?? node.children?.map(textOf).join("") ?? "";
|
|
11
|
+
}
|
|
12
|
+
function rowsOf(node) {
|
|
13
|
+
if (node.type === "heading") {
|
|
14
|
+
const text = textOf(node).trim();
|
|
15
|
+
return /^pronunciation glossary:?\s*$/iu.test(text) ? [] : [text];
|
|
16
|
+
}
|
|
17
|
+
if (node.type === "table") {
|
|
18
|
+
const rows = node.children ?? [];
|
|
19
|
+
return rows.slice(1).map((row) => {
|
|
20
|
+
const cells = row.children ?? [];
|
|
21
|
+
return cells.length >= 2 ? cells.slice(0, 2).map(textOf).join(": ") : textOf(row);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (node.type === "paragraph")
|
|
25
|
+
return textOf(node).split(/\r?\n/u);
|
|
26
|
+
if (node.type === "root" || node.type === "list" || node.type === "listItem")
|
|
27
|
+
return (node.children ?? []).flatMap(rowsOf);
|
|
28
|
+
return ["(unsupported glossary block)"];
|
|
29
|
+
}
|
|
30
|
+
function refused(row, reason) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
reason: "Pronunciation Glossary entry " +
|
|
34
|
+
row +
|
|
35
|
+
": " +
|
|
36
|
+
reason +
|
|
37
|
+
". Edit the glossary or turn off Use Pronunciation Glossary.",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function termIdentity(term) {
|
|
41
|
+
return Array.from(term, (character) => {
|
|
42
|
+
const lower = character.toLowerCase();
|
|
43
|
+
const folded = lower.toUpperCase().toLowerCase();
|
|
44
|
+
if (folded === character)
|
|
45
|
+
return character;
|
|
46
|
+
// Full case conversion can expand letters; only /iu-equivalent forms may share a key.
|
|
47
|
+
const match = new RegExp(`^${character.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&")}$`, "iu");
|
|
48
|
+
return match.test(folded) ? folded : match.test(lower) ? lower : character;
|
|
49
|
+
}).join("");
|
|
50
|
+
}
|
|
51
|
+
export function parsePronunciationGlossary(markdown) {
|
|
52
|
+
const rows = rowsOf(remark().use(remarkGfm).parse(markdown));
|
|
53
|
+
const entries = new Map();
|
|
54
|
+
for (const [index, row] of rows.entries()) {
|
|
55
|
+
const trimmed = row.trim();
|
|
56
|
+
if (trimmed === "" || /^pronunciation glossary:?\s*$/iu.test(trimmed))
|
|
57
|
+
continue;
|
|
58
|
+
const pair = /^([^:]+):\s*(.+)$/u.exec(trimmed);
|
|
59
|
+
const term = pair?.[1]?.trim().replace(/\s+/gu, " ");
|
|
60
|
+
const pronunciation = pair?.[2];
|
|
61
|
+
if (term === undefined ||
|
|
62
|
+
pronunciation === undefined ||
|
|
63
|
+
!/[\p{L}\p{N}]/u.test(term) ||
|
|
64
|
+
/[/[\]<>\p{Cc}]/u.test(term))
|
|
65
|
+
return refused(index + 1, "use Term: /IPA/ or a Term | IPA table");
|
|
66
|
+
const notation = /^(\/[^/]+\/(?:\s+\/[^/]+\/)*)(?:\s+[^/]+)?$/u.exec(pronunciation);
|
|
67
|
+
if (notation?.[1] === undefined)
|
|
68
|
+
return refused(index + 1, "use slash-delimited standard-English IPA");
|
|
69
|
+
const ipa = Array.from(notation[1].matchAll(/\/([^/]+)\//gu)).flatMap((match) => (match[1] ?? "").trim().split(/\s+/u));
|
|
70
|
+
if (ipa.length === 0 || ipa.some((word) => !ipaSymbols.test(word)))
|
|
71
|
+
return refused(index + 1, "use standard-English IPA, not ARPAbet or delivery tags");
|
|
72
|
+
if (term.split(" ").length !== ipa.length)
|
|
73
|
+
return refused(index + 1, "supply one IPA word for each written word");
|
|
74
|
+
const key = termIdentity(term);
|
|
75
|
+
const previous = entries.get(key);
|
|
76
|
+
if (previous !== undefined && previous.ipa.join(" ") !== ipa.join(" "))
|
|
77
|
+
return refused(index + 1, "conflicting pronunciations were supplied for the same term");
|
|
78
|
+
if (previous === undefined)
|
|
79
|
+
entries.set(key, { term, ipa });
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, entries: [...entries.values()] };
|
|
82
|
+
}
|
|
83
|
+
export function pronunciationMatches(source, entries) {
|
|
84
|
+
if (entries.length === 0)
|
|
85
|
+
return [];
|
|
86
|
+
const ordered = [...entries].sort((a, b) => b.term.length - a.term.length);
|
|
87
|
+
const patterns = ordered.map((entry) => entry.term
|
|
88
|
+
.split(" ")
|
|
89
|
+
.map((word) => word.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&"))
|
|
90
|
+
.join("\\s+"));
|
|
91
|
+
const boundary = "[\\p{L}\\p{M}\\p{N}_\\-\u00ad\u2010\u2011\ufe63\uff0d]";
|
|
92
|
+
const expression = new RegExp("(?<!" +
|
|
93
|
+
boundary +
|
|
94
|
+
"['’]?)(?:" +
|
|
95
|
+
patterns.map((pattern) => `(${pattern})`).join("|") +
|
|
96
|
+
")(?!['’]?" +
|
|
97
|
+
boundary +
|
|
98
|
+
")", "giu");
|
|
99
|
+
const matches = [];
|
|
100
|
+
for (const match of source.matchAll(expression)) {
|
|
101
|
+
const end = match.index + match[0].length;
|
|
102
|
+
const before = source[match.index - 1];
|
|
103
|
+
const after = source[end];
|
|
104
|
+
if ((after === "'" && before !== "'") || (after === "’" && before !== "‘"))
|
|
105
|
+
continue;
|
|
106
|
+
const entry = ordered.find((_, index) => match[index + 1] !== undefined);
|
|
107
|
+
if (entry === undefined)
|
|
108
|
+
throw new Error("Matched glossary term has no mapping.");
|
|
109
|
+
matches.push({ start: match.index, end, entry });
|
|
110
|
+
}
|
|
111
|
+
return matches;
|
|
112
|
+
}
|
|
113
|
+
export function pronunciationSpans(source, entries) {
|
|
114
|
+
const spans = [];
|
|
115
|
+
for (const match of pronunciationMatches(source, entries)) {
|
|
116
|
+
const words = source.slice(match.start, match.end).matchAll(/\S+/gu);
|
|
117
|
+
for (const [index, word] of Array.from(words).entries()) {
|
|
118
|
+
const ipa = match.entry.ipa[index];
|
|
119
|
+
if (ipa === undefined)
|
|
120
|
+
throw new Error("Validated glossary term has no IPA word.");
|
|
121
|
+
const start = match.start + word.index;
|
|
122
|
+
spans.push({ start, end: start + word[0].length, text: `/${ipa}/` });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return spans;
|
|
126
|
+
}
|
|
@@ -1,9 +1,68 @@
|
|
|
1
1
|
import { sourceSentences } from "./preparation.js";
|
|
2
2
|
const cannotFit = {
|
|
3
3
|
ok: false,
|
|
4
|
-
reason: "Delivery cues or whitespace leave no room for narration at this model's character limit.",
|
|
4
|
+
reason: "Delivery cues, an indivisible IPA token or whitespace leave no room for narration at this model's character limit.",
|
|
5
5
|
};
|
|
6
|
-
|
|
6
|
+
function atoms(source, from, to, spans) {
|
|
7
|
+
const result = [];
|
|
8
|
+
for (let at = from; at < to;) {
|
|
9
|
+
const span = spans.get(at);
|
|
10
|
+
if (span !== undefined) {
|
|
11
|
+
if (span.end > to || span.end <= at)
|
|
12
|
+
throw new Error("Pronunciation span crosses a word.");
|
|
13
|
+
result.push({ text: span.text, spokenText: source.slice(at, span.end) });
|
|
14
|
+
at = span.end;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
const point = source.codePointAt(at);
|
|
18
|
+
if (point === undefined)
|
|
19
|
+
throw new Error("Narration offset is outside the source.");
|
|
20
|
+
const text = String.fromCodePoint(point);
|
|
21
|
+
result.push({ text, spokenText: text });
|
|
22
|
+
at += text.length;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
function checkedSpans(source, pronunciation) {
|
|
28
|
+
const spans = new Map();
|
|
29
|
+
let end = 0;
|
|
30
|
+
for (const span of [...pronunciation].sort((a, b) => a.start - b.start)) {
|
|
31
|
+
if (!Number.isInteger(span.start) ||
|
|
32
|
+
!Number.isInteger(span.end) ||
|
|
33
|
+
span.start < end ||
|
|
34
|
+
span.end <= span.start ||
|
|
35
|
+
span.end > source.length ||
|
|
36
|
+
!/^[^\s\uD800-\uDFFF]+$/u.test(source.slice(span.start, span.end)))
|
|
37
|
+
throw new Error("Pronunciation spans must cover disjoint whole source characters in a word.");
|
|
38
|
+
spans.set(span.start, span);
|
|
39
|
+
end = span.end;
|
|
40
|
+
}
|
|
41
|
+
return spans;
|
|
42
|
+
}
|
|
43
|
+
function anchoredParts(source, cues, spans) {
|
|
44
|
+
const bySentence = new Map();
|
|
45
|
+
for (const cue of cues) {
|
|
46
|
+
const group = bySentence.get(cue.sentence) ?? [];
|
|
47
|
+
group.push(cue);
|
|
48
|
+
bySentence.set(cue.sentence, group);
|
|
49
|
+
}
|
|
50
|
+
const anchors = new Map();
|
|
51
|
+
let offset = 0;
|
|
52
|
+
for (const sentence of sourceSentences(source)) {
|
|
53
|
+
const start = spans.find((span) => span.start < offset && offset < span.end)?.start ?? offset;
|
|
54
|
+
const events = anchors.get(start) ?? [];
|
|
55
|
+
events.push(...(bySentence.get(sentence.sentence) ?? []));
|
|
56
|
+
anchors.set(start, events);
|
|
57
|
+
offset += sentence.text.length;
|
|
58
|
+
}
|
|
59
|
+
return [...anchors].map(([start, events], index, boundaries) => ({
|
|
60
|
+
start,
|
|
61
|
+
text: source.slice(start, boundaries[index + 1]?.[0] ?? source.length),
|
|
62
|
+
events,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
export function prepareRequests(source, cues, maxCharacters, pronunciation = []) {
|
|
7
66
|
if (!Number.isInteger(maxCharacters) || maxCharacters < 2)
|
|
8
67
|
return {
|
|
9
68
|
ok: false,
|
|
@@ -11,13 +70,8 @@ export function prepareRequests(source, cues, maxCharacters) {
|
|
|
11
70
|
};
|
|
12
71
|
if (source.trim() === "")
|
|
13
72
|
return { ok: true, requests: [] };
|
|
73
|
+
const spans = checkedSpans(source, pronunciation);
|
|
14
74
|
const requests = [];
|
|
15
|
-
const bySentence = new Map();
|
|
16
|
-
for (const cue of cues) {
|
|
17
|
-
const group = bySentence.get(cue.sentence) ?? [];
|
|
18
|
-
group.push(cue);
|
|
19
|
-
bySentence.set(cue.sentence, group);
|
|
20
|
-
}
|
|
21
75
|
let active = null;
|
|
22
76
|
let text = "";
|
|
23
77
|
let spokenText = "";
|
|
@@ -29,17 +83,19 @@ export function prepareRequests(source, cues, maxCharacters) {
|
|
|
29
83
|
spokenText = "";
|
|
30
84
|
return true;
|
|
31
85
|
};
|
|
32
|
-
for (const
|
|
33
|
-
const events =
|
|
34
|
-
const direction =
|
|
86
|
+
for (const part of anchoredParts(source, cues, pronunciation)) {
|
|
87
|
+
const events = part.events;
|
|
88
|
+
const direction = pronunciation.length === 0
|
|
89
|
+
? events.find((cue) => cue.kind !== "sound")
|
|
90
|
+
: events.findLast((cue) => cue.kind !== "sound");
|
|
35
91
|
const tags = events.map(cueTag).join(" ");
|
|
36
92
|
const prefix = tags === "" ? "" : `${tags} `;
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
93
|
+
const words = Array.from(part.text.matchAll(/\S+\s*|\s+/gu)).map((match) => atoms(source, part.start + match.index, part.start + match.index + match[0].length, spans));
|
|
94
|
+
const first = words[0]?.[0]?.text ?? "";
|
|
95
|
+
const firstWordLength = words[0]?.reduce((length, atom) => length + atom.text.length, 0) ?? 0;
|
|
40
96
|
const freshPrefix = direction === undefined ? carryTag(active) : "";
|
|
41
|
-
const minimum =
|
|
42
|
-
?
|
|
97
|
+
const minimum = firstWordLength + prefix.length + freshPrefix.length <= maxCharacters
|
|
98
|
+
? firstWordLength
|
|
43
99
|
: first.length;
|
|
44
100
|
if (spokenText.trim() !== "" && text.length + prefix.length + minimum > maxCharacters) {
|
|
45
101
|
if (!flush())
|
|
@@ -52,24 +108,26 @@ export function prepareRequests(source, cues, maxCharacters) {
|
|
|
52
108
|
text += prefix;
|
|
53
109
|
if (direction !== undefined)
|
|
54
110
|
active = direction.kind === "instruction" ? direction.text : null;
|
|
55
|
-
for (const word of words) {
|
|
56
|
-
|
|
57
|
-
|
|
111
|
+
for (const [index, word] of words.entries()) {
|
|
112
|
+
const length = word.reduce((sum, atom) => sum + atom.text.length, 0);
|
|
113
|
+
if ((index > 0 || prefix === "" || spans.size === 0) &&
|
|
114
|
+
length + carryTag(active).length <= maxCharacters &&
|
|
115
|
+
text.length + length > maxCharacters &&
|
|
58
116
|
spokenText.trim() !== "") {
|
|
59
117
|
if (!flush())
|
|
60
118
|
return cannotFit;
|
|
61
119
|
text = carryTag(active);
|
|
62
120
|
}
|
|
63
|
-
for (const
|
|
64
|
-
if (text.length +
|
|
121
|
+
for (const atom of word) {
|
|
122
|
+
if (text.length + atom.text.length > maxCharacters) {
|
|
65
123
|
if (!flush())
|
|
66
124
|
return cannotFit;
|
|
67
125
|
text = carryTag(active);
|
|
68
126
|
}
|
|
69
|
-
if (text.length +
|
|
127
|
+
if (text.length + atom.text.length > maxCharacters)
|
|
70
128
|
return cannotFit;
|
|
71
|
-
text +=
|
|
72
|
-
spokenText +=
|
|
129
|
+
text += atom.text;
|
|
130
|
+
spokenText += atom.spokenText;
|
|
73
131
|
}
|
|
74
132
|
}
|
|
75
133
|
}
|
|
@@ -59,7 +59,9 @@ export function toAdmissionDraft(input) {
|
|
|
59
59
|
format: form.format,
|
|
60
60
|
sources,
|
|
61
61
|
llm: form.llm,
|
|
62
|
-
audio: sources.audio === "generate"
|
|
62
|
+
audio: sources.audio === "generate" || form.audio.usePronunciationGlossary !== undefined
|
|
63
|
+
? form.audio
|
|
64
|
+
: undefined,
|
|
63
65
|
images: form.images,
|
|
64
66
|
articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
|
|
65
67
|
...(sources.audio === "generate" && form.narrationPrompt?.trim()
|
|
@@ -31,7 +31,9 @@ export const playDraftFormSchema = z
|
|
|
31
31
|
.strict()
|
|
32
32
|
.readonly(),
|
|
33
33
|
llm: provider.readonly(),
|
|
34
|
-
audio: provider
|
|
34
|
+
audio: provider
|
|
35
|
+
.extend({ voice: text, usePronunciationGlossary: z.boolean().optional() })
|
|
36
|
+
.readonly(),
|
|
35
37
|
images: provider.readonly(),
|
|
36
38
|
articlePrompt: text,
|
|
37
39
|
narrationPrompt: text.optional(),
|
|
@@ -8,6 +8,7 @@ import { executionSnapshotSchema, planPreview, reviewStillCovers } from "./previ
|
|
|
8
8
|
import { previewById } from "./repo.js";
|
|
9
9
|
import { insertInvocation, reservationKey } from "./runtime-admission.js";
|
|
10
10
|
import { bindNarrationReuse } from "./runtime-narration-reuse.js";
|
|
11
|
+
import { executionCatalogue } from "./runtime-plan.js";
|
|
11
12
|
import { projectStandings } from "./runtime-store.js";
|
|
12
13
|
export function admissionReceipt(db, projectId, idempotencyKey, requestHash) {
|
|
13
14
|
const row = db
|
|
@@ -24,8 +25,9 @@ export function admissionReceipt(db, projectId, idempotencyKey, requestHash) {
|
|
|
24
25
|
},
|
|
25
26
|
};
|
|
26
27
|
if (db
|
|
27
|
-
.prepare("SELECT 1 FROM revision_mutations WHERE project_id=? AND idempotency_key=? UNION ALL SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=?")
|
|
28
|
-
.get(projectId, idempotencyKey, projectId, idempotencyKey) !==
|
|
28
|
+
.prepare("SELECT 1 FROM revision_mutations WHERE project_id=? AND idempotency_key=? UNION ALL SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=? UNION ALL SELECT 1 FROM project_recovery_requests WHERE project_id=? AND idempotency_key=?")
|
|
29
|
+
.get(projectId, idempotencyKey, projectId, idempotencyKey, projectId, idempotencyKey) !==
|
|
30
|
+
undefined)
|
|
29
31
|
return { ok: false, reason: "conflict" };
|
|
30
32
|
return undefined;
|
|
31
33
|
}
|
|
@@ -49,9 +51,10 @@ export function admitPreview(deps, input) {
|
|
|
49
51
|
const view = getRevisionView(deps, preview.projectId, preview.baseRevisionId);
|
|
50
52
|
if (view === undefined)
|
|
51
53
|
return { ok: false, reason: "no-project" };
|
|
52
|
-
const fresh = planPreview(deps, view,
|
|
54
|
+
const fresh = planPreview(deps, view, input.planningCatalogue, preview.selection, preview.id);
|
|
53
55
|
if (!fresh.ok || !reviewStillCovers(preview, snapshot, fresh.value))
|
|
54
56
|
return { ok: false, reason: "stale-preview" };
|
|
57
|
+
const catalogue = executionCatalogue(input.planningCatalogue, view.revision.config);
|
|
55
58
|
const admissionId = deps.ids.next();
|
|
56
59
|
const workIds = [];
|
|
57
60
|
for (const recipe of snapshot.recipes) {
|
|
@@ -97,7 +100,7 @@ export function admitPreview(deps, input) {
|
|
|
97
100
|
const fingerprint = snapshot.anchors[key];
|
|
98
101
|
if (fingerprint === undefined)
|
|
99
102
|
throw new Error("Selected work has no reviewed desired anchor.");
|
|
100
|
-
const work = insertInvocation(deps, view, recipe,
|
|
103
|
+
const work = insertInvocation(deps, view, recipe, catalogue, { key, fingerprint }, false, view.revision.id, admissionId);
|
|
101
104
|
workIds.push(work.workId);
|
|
102
105
|
}
|
|
103
106
|
const receipt = {
|
|
@@ -129,7 +129,7 @@ export function planPreview(deps, view, catalogue, selection, id) {
|
|
|
129
129
|
return {
|
|
130
130
|
key: row.key,
|
|
131
131
|
submit,
|
|
132
|
-
uncertain: submit && hasSubmittedRequest(deps, view.revision.id, row.key
|
|
132
|
+
uncertain: submit && hasSubmittedRequest(deps, view.revision.id, row.key),
|
|
133
133
|
};
|
|
134
134
|
});
|
|
135
135
|
const execution = {
|
|
@@ -28,6 +28,19 @@ export function retainedPreviewPlan(deps, view, catalogue) {
|
|
|
28
28
|
const recipe = original.recipes.find((one) => one.key === piece.key);
|
|
29
29
|
if (recipe === undefined)
|
|
30
30
|
continue;
|
|
31
|
+
// Pre-1.5 requests embedded research in messages. Review a fresh document-based
|
|
32
|
+
// request instead of re-admitting an input the current scheduler cannot match.
|
|
33
|
+
// Running calls, accepted jobs and cached answers must retain their exact input.
|
|
34
|
+
if ((piece.key === "research:notes" || piece.key === "article:body") &&
|
|
35
|
+
piece.input.kind === "llm" &&
|
|
36
|
+
(piece.input.documents?.length ?? 0) === 0 &&
|
|
37
|
+
recipe.input.kind === "llm" &&
|
|
38
|
+
(recipe.input.documents?.length ?? 0) > 0 &&
|
|
39
|
+
row.state !== "running" &&
|
|
40
|
+
!deps.db
|
|
41
|
+
.prepare("SELECT 1 FROM revision_work_pieces WHERE work_id=? AND (state IN ('running','done') OR continuation IS NOT NULL OR result_json IS NOT NULL) LIMIT 1")
|
|
42
|
+
.get(piece.workId))
|
|
43
|
+
continue;
|
|
31
44
|
replacements.set(piece.key, {
|
|
32
45
|
...recipe,
|
|
33
46
|
input: piece.input,
|
|
@@ -121,8 +134,8 @@ function cachedArticleComplete(deps, revisionId, workId) {
|
|
|
121
134
|
}
|
|
122
135
|
return false;
|
|
123
136
|
}
|
|
124
|
-
export function hasSubmittedRequest(deps, revisionId, key
|
|
137
|
+
export function hasSubmittedRequest(deps, revisionId, key) {
|
|
125
138
|
return (deps.db
|
|
126
|
-
.prepare(`SELECT 1 FROM revision_work_reservations r JOIN revision_work_pieces p ON p.id=r.piece_id WHERE r.revision_id=? AND r.work_key=? AND
|
|
127
|
-
.get(revisionId, key
|
|
139
|
+
.prepare(`SELECT 1 FROM revision_work_reservations r JOIN revision_work_pieces p ON p.id=r.piece_id WHERE r.revision_id=? AND r.work_key=? AND p.submitted_at IS NOT NULL AND p.continuation IS NULL AND p.state!='done'`)
|
|
140
|
+
.get(revisionId, key) !== undefined);
|
|
128
141
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { usesNarrationPreparation } from "../admission/rules.js";
|
|
1
|
+
import { usesNarrationPreparation, usesPronunciationGlossary } from "../admission/rules.js";
|
|
2
2
|
import { narrationRegenerationToken, normalizeNarrationText, planNarration, } from "../narration/plan.js";
|
|
3
|
+
import { pronunciationSpans } from "../narration/pronunciation.js";
|
|
4
|
+
import { prepareRequests } from "../narration/steering.js";
|
|
3
5
|
import { recipe } from "./recipe-model.js";
|
|
4
6
|
import { preparationForGroup } from "./recipe-preparation.js";
|
|
5
|
-
export function narrationParts(context, logicalKey, originalText, segment, dependsOn, preparations) {
|
|
7
|
+
export function narrationParts(context, logicalKey, originalText, segment, dependsOn, preparations, glossary) {
|
|
6
8
|
const override = context.content.narrationOverrides[logicalKey];
|
|
7
9
|
if (override?.kind === "asset")
|
|
8
10
|
return [
|
|
@@ -13,25 +15,26 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
|
|
|
13
15
|
semantic: [normalizeNarrationText(originalText), voiceValues(context)],
|
|
14
16
|
}, dependsOn, { tokenKey: logicalKey }),
|
|
15
17
|
];
|
|
18
|
+
if (glossary === null)
|
|
19
|
+
return [];
|
|
20
|
+
if (!glossary.ok)
|
|
21
|
+
return [refusedPart(context, logicalKey, dependsOn, glossary.reason)];
|
|
16
22
|
const logicalText = normalizeNarrationText(override?.kind === "text" ? override.text : originalText);
|
|
23
|
+
const spans = pronunciationSpans(logicalText, glossary.entries);
|
|
17
24
|
const wholeRequest = segment !== "body" || (context.config.chunking?.mode ?? "whole") === "whole";
|
|
18
25
|
const choice = context.config.audio;
|
|
19
26
|
const model = context.catalogue?.tts.find((row) => row.provider === choice?.provider &&
|
|
20
27
|
row.id === choice.model &&
|
|
21
28
|
row.enabled &&
|
|
22
29
|
!row.deprecated);
|
|
30
|
+
const logicalLimit = Math.max(2, logicalText.length +
|
|
31
|
+
spans.reduce((sum, span) => sum + Math.max(0, span.text.length - (span.end - span.start)), 0));
|
|
32
|
+
const maxCharacters = model?.tts.maxCharacters ?? logicalLimit;
|
|
23
33
|
if (usesNarrationPreparation(context.config)) {
|
|
24
|
-
const prepared = preparationForGroup(context, logicalKey, logicalText, segment, dependsOn,
|
|
34
|
+
const prepared = preparationForGroup(context, logicalKey, logicalText, segment, dependsOn, maxCharacters, spans);
|
|
25
35
|
preparations.push(prepared.preparation);
|
|
26
36
|
if (prepared.refusal !== null)
|
|
27
|
-
return [
|
|
28
|
-
recipe(context, `${logicalKey}:1`, "audio", {
|
|
29
|
-
kind: "deferred",
|
|
30
|
-
version: 1,
|
|
31
|
-
operation: "resolve-revision-recipe",
|
|
32
|
-
template: prepared.refusal,
|
|
33
|
-
}, [prepared.preparation.key], { unresolved: true, refusal: prepared.refusal }),
|
|
34
|
-
];
|
|
37
|
+
return [refusedPart(context, logicalKey, [prepared.preparation.key], prepared.refusal)];
|
|
35
38
|
return (prepared.requests ?? []).map(({ text, spokenText }, index) => recipe(context, `${logicalKey}:${index + 1}`, "audio", {
|
|
36
39
|
kind: "tts",
|
|
37
40
|
version: 1,
|
|
@@ -47,6 +50,26 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
|
|
|
47
50
|
wholeRequest,
|
|
48
51
|
}, [prepared.preparation.key], { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
|
|
49
52
|
}
|
|
53
|
+
if (spans.length > 0) {
|
|
54
|
+
const prepared = prepareRequests(logicalText, [], maxCharacters, spans);
|
|
55
|
+
if (!prepared.ok)
|
|
56
|
+
return [refusedPart(context, logicalKey, dependsOn, prepared.reason)];
|
|
57
|
+
return prepared.requests.map(({ text, spokenText }, index) => recipe(context, `${logicalKey}:${index + 1}`, "audio", {
|
|
58
|
+
kind: "tts",
|
|
59
|
+
version: 1,
|
|
60
|
+
provider: choice?.provider ?? "",
|
|
61
|
+
model: choice?.model ?? "",
|
|
62
|
+
voice: choice?.voice ?? "",
|
|
63
|
+
text,
|
|
64
|
+
spokenText,
|
|
65
|
+
logicalKey,
|
|
66
|
+
logicalText,
|
|
67
|
+
segment,
|
|
68
|
+
pronunciation: null,
|
|
69
|
+
wholeRequest,
|
|
70
|
+
}, dependsOn, { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
|
|
71
|
+
}
|
|
72
|
+
// Preserve the pre-feature splitter, shape and identities when no term matches.
|
|
50
73
|
const requests = planNarration({
|
|
51
74
|
groups: [
|
|
52
75
|
{
|
|
@@ -60,9 +83,7 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
|
|
|
60
83
|
provider: choice?.provider ?? "",
|
|
61
84
|
model: choice?.model ?? "",
|
|
62
85
|
voice: choice?.voice ?? "",
|
|
63
|
-
maxCharacters
|
|
64
|
-
? Math.max(2, logicalText.length)
|
|
65
|
-
: (model?.tts.maxCharacters ?? Math.max(2, logicalText.length)),
|
|
86
|
+
maxCharacters,
|
|
66
87
|
retained: [],
|
|
67
88
|
});
|
|
68
89
|
return requests.map(({ text, key }) => recipe(context, key, "audio", {
|
|
@@ -79,6 +100,41 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
|
|
|
79
100
|
wholeRequest,
|
|
80
101
|
}, dependsOn, { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
|
|
81
102
|
}
|
|
103
|
+
function refusedPart(context, logicalKey, dependsOn, reason) {
|
|
104
|
+
return recipe(context, `${logicalKey}:1`, "audio", { kind: "deferred", version: 1, operation: "resolve-revision-recipe", template: reason }, dependsOn, { unresolved: true, refusal: reason });
|
|
105
|
+
}
|
|
106
|
+
export function pronunciationFutureValues(context, glossary, article, logicalKey, source) {
|
|
107
|
+
if (!usesPronunciationGlossary(context.config))
|
|
108
|
+
return [];
|
|
109
|
+
const override = context.content.narrationOverrides[logicalKey];
|
|
110
|
+
if (override?.kind === "asset")
|
|
111
|
+
return [];
|
|
112
|
+
if (glossary === null)
|
|
113
|
+
return [["pronunciation-glossary-v1", "pending-article", article.fingerprint]];
|
|
114
|
+
if (!glossary.ok)
|
|
115
|
+
return [["pronunciation-glossary-v1", "invalid", glossary.reason]];
|
|
116
|
+
// Unknown generated entry text may match any supplied term.
|
|
117
|
+
if (source === null)
|
|
118
|
+
return glossary.entries.length === 0
|
|
119
|
+
? []
|
|
120
|
+
: [
|
|
121
|
+
[
|
|
122
|
+
"pronunciation-glossary-v1",
|
|
123
|
+
glossary.entries.map((entry) => [entry.term, [...entry.ipa]]),
|
|
124
|
+
],
|
|
125
|
+
];
|
|
126
|
+
const clean = normalizeNarrationText(override?.kind === "text" ? override.text : source);
|
|
127
|
+
const spans = pronunciationSpans(clean, glossary.entries);
|
|
128
|
+
return spans.length === 0
|
|
129
|
+
? []
|
|
130
|
+
: [
|
|
131
|
+
[
|
|
132
|
+
"pronunciation-glossary-v1",
|
|
133
|
+
logicalKey,
|
|
134
|
+
spans.map((span) => [span.start, span.end, span.text]),
|
|
135
|
+
],
|
|
136
|
+
];
|
|
137
|
+
}
|
|
82
138
|
export function voiceValues(context) {
|
|
83
139
|
return [
|
|
84
140
|
context.config.audio?.provider ?? null,
|