@avocadostudio-ai/orchestrator-core 0.3.2 → 0.4.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/dist/chat/anthropic-planner.d.ts +8 -0
- package/dist/chat/anthropic-planner.js +166 -12
- package/dist/chat/chat-pipeline-translation.d.ts +13 -0
- package/dist/chat/chat-pipeline-translation.js +109 -45
- package/dist/chat/chat-pipeline.d.ts +1 -1
- package/dist/chat/chat-pipeline.js +312 -54
- package/dist/chat/gemini-planner.d.ts +2 -0
- package/dist/chat/gemini-planner.js +2 -1
- package/dist/chat/planner-types.d.ts +15 -0
- package/dist/chat/planner-types.js +2 -2
- package/dist/chat/planner.d.ts +12 -0
- package/dist/chat/planner.js +16 -2
- package/dist/chat/prompts.d.ts +5 -0
- package/dist/chat/prompts.js +92 -9
- package/dist/chat/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +42 -0
- package/dist/checks/field-walk.js +198 -0
- package/dist/checks/index.d.ts +5 -0
- package/dist/checks/index.js +4 -0
- package/dist/checks/page-weight.d.ts +22 -0
- package/dist/checks/page-weight.js +200 -0
- package/dist/checks/rules-draft.d.ts +2 -0
- package/dist/checks/rules-draft.js +439 -0
- package/dist/checks/run-checks.d.ts +42 -0
- package/dist/checks/run-checks.js +159 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +99 -0
- package/dist/checks/types.d.ts +109 -0
- package/dist/checks/types.js +1 -0
- package/dist/cms/adapter.d.ts +74 -1
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/index.d.ts +1 -1
- package/dist/cms/index.js +1 -1
- package/dist/cms/media-sources.d.ts +29 -1
- package/dist/cms/media-sources.js +188 -7
- package/dist/durable/durable-store-singleton.d.ts +37 -0
- package/dist/durable/durable-store-singleton.js +179 -0
- package/dist/durable/finding-impact.d.ts +30 -0
- package/dist/durable/finding-impact.js +53 -0
- package/dist/durable/in-memory-durable-store.d.ts +203 -0
- package/dist/durable/in-memory-durable-store.js +363 -0
- package/dist/durable/index.d.ts +5 -0
- package/dist/durable/index.js +4 -0
- package/dist/durable/pending-plan-store.d.ts +28 -0
- package/dist/durable/pending-plan-store.js +156 -0
- package/dist/durable/sqlite-durable-store.d.ts +71 -0
- package/dist/durable/sqlite-durable-store.js +631 -0
- package/dist/durable/types.d.ts +265 -0
- package/dist/durable/types.js +1 -0
- package/dist/handler/create-orchestrator.d.ts +4 -0
- package/dist/handler/create-orchestrator.js +283 -32
- package/dist/http/audio-actions.d.ts +1 -1
- package/dist/http/checks-actions.d.ts +39 -0
- package/dist/http/checks-actions.js +122 -0
- package/dist/http/history-actions.d.ts +44 -1
- package/dist/http/history-actions.js +122 -0
- package/dist/http/image-generate-actions.d.ts +2 -2
- package/dist/http/ops-actions.d.ts +2 -2
- package/dist/http/publish-actions.d.ts +15 -4
- package/dist/http/publish-actions.js +3 -3
- package/dist/http/restore-actions.d.ts +3 -3
- package/dist/http/screenshot-actions.d.ts +2 -2
- package/dist/http/session-actions.d.ts +1 -1
- package/dist/http/telemetry-feedback-actions.d.ts +2 -2
- package/dist/http/unsplash-actions.d.ts +2 -2
- package/dist/http/variations-actions.d.ts +2 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +28 -1
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/intent-detection.d.ts +16 -0
- package/dist/nlp/intent-detection.js +15 -1
- package/dist/nlp/plan-normalizer.js +66 -32
- package/dist/ops/destructive-action-gate.js +7 -2
- package/dist/ops/ops-engine.d.ts +12 -1
- package/dist/ops/ops-engine.js +41 -14
- package/dist/publish/publish-helpers.d.ts +12 -2
- package/dist/publish/publish-helpers.js +10 -3
- package/dist/publish/publish-selection.d.ts +84 -0
- package/dist/publish/publish-selection.js +113 -0
- package/dist/publish/publish-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/publish/targets/git.js +2 -2
- package/dist/state/session-state.js +8 -1
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- package/package.json +3 -3
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { buildFullPageTranslationChecklist } from "./chat-pipeline-translation.js";
|
|
2
|
+
import { ZERO_USAGE } from "../telemetry/usage.js";
|
|
3
|
+
/**
|
|
4
|
+
* Full-page translation, split into independent per-block-group requests.
|
|
5
|
+
*
|
|
6
|
+
* One request for the whole page is the wrong shape for this job. A translation
|
|
7
|
+
* re-emits every string on the page, so its *output* length scales with the page
|
|
8
|
+
* while a single request's output is strictly serial: an 8 KB page cost 9,139
|
|
9
|
+
* output tokens and 75 seconds of streaming, and a page twice that size costs
|
|
10
|
+
* twice as long. Worse, the whole thing is one all-or-nothing unit — overshoot
|
|
11
|
+
* the token ceiling on the last block and the other six are discarded too.
|
|
12
|
+
*
|
|
13
|
+
* The blocks are independent, though. Nothing in translating the FAQ depends on
|
|
14
|
+
* how the hero was translated, so the work fans out: N smaller requests in
|
|
15
|
+
* parallel, each carrying only its own blocks, merged back into one plan. Wall
|
|
16
|
+
* clock becomes the slowest chunk rather than the sum, each chunk is far from
|
|
17
|
+
* any token ceiling, and a chunk that does fail costs only its own blocks.
|
|
18
|
+
*
|
|
19
|
+
* The tradeoff is input tokens — the schema contract and site context repeat per
|
|
20
|
+
* chunk — so each chunk carries only its own slice of the page outline, and the
|
|
21
|
+
* whole path is gated on the page being big enough to be worth it.
|
|
22
|
+
*/
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Configuration
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
function envFlagOff(raw) {
|
|
27
|
+
return /^(0|false|no|off)$/i.test((raw ?? "").trim());
|
|
28
|
+
}
|
|
29
|
+
function envInt(raw, fallback) {
|
|
30
|
+
const parsed = Number((raw ?? "").trim());
|
|
31
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
32
|
+
}
|
|
33
|
+
export function translationChunkingConfig() {
|
|
34
|
+
return {
|
|
35
|
+
enabled: !envFlagOff(process.env.CHAT_TRANSLATION_CHUNKING),
|
|
36
|
+
/**
|
|
37
|
+
* Translatable bytes to aim for per chunk.
|
|
38
|
+
*
|
|
39
|
+
* Chunk wall clock is roughly a fixed time-to-first-token plus streaming time
|
|
40
|
+
* proportional to this. Measured on Sonnet 5: ~5s to first token, then ~130
|
|
41
|
+
* output tokens/second, and a chunk's output runs a little over one token per
|
|
42
|
+
* source byte. ~900 bytes puts streaming (~8s) in the same range as the fixed
|
|
43
|
+
* cost, which is where splitting further stops buying much and starts paying
|
|
44
|
+
* another chunk's input tokens for nothing.
|
|
45
|
+
*/
|
|
46
|
+
targetBytes: envInt(process.env.CHAT_TRANSLATION_CHUNK_BYTES, 900),
|
|
47
|
+
/** Upper bound on parallel planner calls — one wave, not a stampede. */
|
|
48
|
+
maxChunks: envInt(process.env.CHAT_TRANSLATION_MAX_CHUNKS, 6),
|
|
49
|
+
/** Below these, a single request is already fast and chunking only adds input cost. */
|
|
50
|
+
minBlocks: envInt(process.env.CHAT_TRANSLATION_CHUNK_MIN_BLOCKS, 3),
|
|
51
|
+
minBytes: envInt(process.env.CHAT_TRANSLATION_CHUNK_MIN_BYTES, 1800)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Weigh each block by the `update_props` patch a translation has to emit for it.
|
|
56
|
+
*
|
|
57
|
+
* Two wrong answers were tried first. Block JSON size over-weights a Gallery of
|
|
58
|
+
* twenty image URLs that has nothing to translate. Translatable string length —
|
|
59
|
+
* the obvious fix — under-weights list blocks badly: a live run gave one 769-byte
|
|
60
|
+
* CardGrid the same weight as a 1030-byte prose block and then watched it take
|
|
61
|
+
* twice as long, because a list patch re-sends *every row* (a shorter array
|
|
62
|
+
* truncates the list) and pays JSON structure for each one.
|
|
63
|
+
*
|
|
64
|
+
* So the weight is the thing being predicted: build the minimal patch we ask the
|
|
65
|
+
* model for and measure it.
|
|
66
|
+
*/
|
|
67
|
+
function weighBlocks(page) {
|
|
68
|
+
const checklist = buildFullPageTranslationChecklist(page);
|
|
69
|
+
return checklist.map((entry) => {
|
|
70
|
+
const block = page.blocks.find((b) => b.id === entry.blockId);
|
|
71
|
+
const props = (block?.props ?? {});
|
|
72
|
+
const patch = {};
|
|
73
|
+
for (const field of entry.fields) {
|
|
74
|
+
const listMatch = field.match(/^([^[]+)\[(\d+)\]\.(.+)$/);
|
|
75
|
+
if (!listMatch) {
|
|
76
|
+
if (typeof props[field] === "string")
|
|
77
|
+
patch[field] = props[field];
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const [, listKey, rawIndex, itemField] = listMatch;
|
|
81
|
+
const source = props[listKey];
|
|
82
|
+
if (!Array.isArray(source))
|
|
83
|
+
continue;
|
|
84
|
+
const rows = patch[listKey]
|
|
85
|
+
?? source.map(() => ({}));
|
|
86
|
+
patch[listKey] = rows;
|
|
87
|
+
const value = source[Number(rawIndex)]?.[itemField];
|
|
88
|
+
if (typeof value === "string")
|
|
89
|
+
rows[Number(rawIndex)][itemField] = value;
|
|
90
|
+
}
|
|
91
|
+
return { blockId: entry.blockId, bytes: JSON.stringify(patch).length };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Partition the page's translatable blocks into balanced chunks.
|
|
96
|
+
*
|
|
97
|
+
* Wall clock is the *heaviest* chunk, not the average one, so balance is the
|
|
98
|
+
* whole game: a first live run split a page into 1604 / 1010 / 172 / 769 bytes
|
|
99
|
+
* and spent 17 of its 23 seconds waiting on the first bin while a 172-byte bin
|
|
100
|
+
* sat finished. Packing blocks in page order is what produces that — it has to
|
|
101
|
+
* close a chunk before it can see what comes next.
|
|
102
|
+
*
|
|
103
|
+
* So blocks are packed heaviest-first into whichever chunk is currently lightest
|
|
104
|
+
* (longest-processing-time-first), which bounds the heaviest chunk far better,
|
|
105
|
+
* and page order is restored afterwards so the change log still reads top to
|
|
106
|
+
* bottom. Blocks are never split: an `update_props` op carries a whole block.
|
|
107
|
+
*
|
|
108
|
+
* Returns a single chunk when the page is too small to be worth splitting; the
|
|
109
|
+
* caller treats that as "don't chunk".
|
|
110
|
+
*/
|
|
111
|
+
export function planTranslationChunks(args) {
|
|
112
|
+
const config = translationChunkingConfig();
|
|
113
|
+
const targetBytes = args.targetBytes ?? config.targetBytes;
|
|
114
|
+
const maxChunks = args.maxChunks ?? config.maxChunks;
|
|
115
|
+
const weighted = weighBlocks(args.page);
|
|
116
|
+
if (weighted.length === 0)
|
|
117
|
+
return [];
|
|
118
|
+
const totalBytes = weighted.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
119
|
+
const chunkCount = Math.min(weighted.length, maxChunks, Math.max(1, Math.ceil(totalBytes / targetBytes)));
|
|
120
|
+
if (chunkCount <= 1) {
|
|
121
|
+
return [{ blockIds: weighted.map((entry) => entry.blockId), bytes: totalBytes }];
|
|
122
|
+
}
|
|
123
|
+
const pageOrder = new Map(weighted.map((entry, index) => [entry.blockId, index]));
|
|
124
|
+
const bins = Array.from({ length: chunkCount }, () => ({ blockIds: [], bytes: 0 }));
|
|
125
|
+
// Heaviest first into the lightest bin. The first `chunkCount` blocks land in
|
|
126
|
+
// distinct empty bins, so no bin is ever left empty.
|
|
127
|
+
for (const entry of [...weighted].sort((a, b) => b.bytes - a.bytes)) {
|
|
128
|
+
const lightest = bins.reduce((min, bin) => (bin.bytes < min.bytes ? bin : min));
|
|
129
|
+
lightest.blockIds.push(entry.blockId);
|
|
130
|
+
lightest.bytes += entry.bytes;
|
|
131
|
+
}
|
|
132
|
+
for (const bin of bins)
|
|
133
|
+
bin.blockIds.sort((a, b) => pageOrder.get(a) - pageOrder.get(b));
|
|
134
|
+
bins.sort((a, b) => pageOrder.get(a.blockIds[0]) - pageOrder.get(b.blockIds[0]));
|
|
135
|
+
return bins;
|
|
136
|
+
}
|
|
137
|
+
/** Whether this page is worth fanning out, per the configured thresholds. */
|
|
138
|
+
export function shouldChunkTranslation(page, chunks) {
|
|
139
|
+
const config = translationChunkingConfig();
|
|
140
|
+
if (!config.enabled)
|
|
141
|
+
return false;
|
|
142
|
+
if (chunks.length < 2)
|
|
143
|
+
return false;
|
|
144
|
+
const translatableBlocks = chunks.reduce((sum, chunk) => sum + chunk.blockIds.length, 0);
|
|
145
|
+
const translatableBytes = chunks.reduce((sum, chunk) => sum + chunk.bytes, 0);
|
|
146
|
+
return translatableBlocks >= config.minBlocks && translatableBytes >= config.minBytes;
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Per-chunk request shaping
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
/**
|
|
152
|
+
* The page as this chunk should see it: same id/slug/meta, only its own blocks.
|
|
153
|
+
*
|
|
154
|
+
* Everything downstream in the planner derives from `currentPage` — the output
|
|
155
|
+
* token budget, and the enumerated translation checklist handed to the model —
|
|
156
|
+
* so narrowing the page is what makes a chunk a chunk. No extra plumbing.
|
|
157
|
+
*/
|
|
158
|
+
export function subsetPageForChunk(page, blockIds) {
|
|
159
|
+
return { ...page, blocks: page.blocks.filter((block) => blockIds.has(block.id)) };
|
|
160
|
+
}
|
|
161
|
+
/** The context pack narrowed to the chunk's blocks, so input cost doesn't repeat the whole page N times. */
|
|
162
|
+
export function subsetContextPackForChunk(pack, blockIds) {
|
|
163
|
+
const outline = pack.pageOutline.filter((entry) => blockIds.has(entry.id));
|
|
164
|
+
return {
|
|
165
|
+
...pack,
|
|
166
|
+
blockCount: outline.length,
|
|
167
|
+
pageOutline: outline,
|
|
168
|
+
// The selection and its neighbours belong to the whole-page view; a chunk is
|
|
169
|
+
// scoped by its checklist, and a stale "selected block" would pull the model
|
|
170
|
+
// toward a block this chunk may not even carry.
|
|
171
|
+
selected: { ...pack.selected, blockId: null, blockType: null, editablePath: null, block: null, imageUrlForVision: null },
|
|
172
|
+
neighbors: { previous: null, next: null }
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Page-level ops a translation may legitimately emit, but only once. Handing the
|
|
177
|
+
* same site config to six chunks invites six conflicting `update_site_config`
|
|
178
|
+
* ops, so only the first chunk is allowed to produce them.
|
|
179
|
+
*/
|
|
180
|
+
const PAGE_LEVEL_OPS = new Set(["update_page_meta", "update_site_config"]);
|
|
181
|
+
/** Ops that address a block, and so must address one of *this* chunk's blocks. */
|
|
182
|
+
const BLOCK_SCOPED_OPS = new Set(["update_props", "update_item", "add_item", "remove_item", "move_item", "reorder_items"]);
|
|
183
|
+
/**
|
|
184
|
+
* Keep only what this chunk was asked for.
|
|
185
|
+
*
|
|
186
|
+
* A chunk sees a slice of the page, but nothing stops a model from volunteering
|
|
187
|
+
* an op for a block it half-remembers from the site context, or from restructuring
|
|
188
|
+
* a page it was asked to translate. Ops outside the chunk's blocks are dropped
|
|
189
|
+
* rather than merged: a translation that also deletes a section is not a
|
|
190
|
+
* translation, and two chunks editing the same block would silently race.
|
|
191
|
+
*/
|
|
192
|
+
export function filterChunkOps(args) {
|
|
193
|
+
const kept = args.ops.filter((op) => {
|
|
194
|
+
if (BLOCK_SCOPED_OPS.has(op.op)) {
|
|
195
|
+
return "blockId" in op && typeof op.blockId === "string" && args.blockIds.has(op.blockId);
|
|
196
|
+
}
|
|
197
|
+
if (PAGE_LEVEL_OPS.has(op.op))
|
|
198
|
+
return args.isFirstChunk;
|
|
199
|
+
return false;
|
|
200
|
+
});
|
|
201
|
+
return { kept, droppedCount: args.ops.length - kept.length };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Run every chunk in parallel and merge the results into one plan.
|
|
205
|
+
*
|
|
206
|
+
* A chunk that dies on a token ceiling is retried once on its own, with double
|
|
207
|
+
* the budget — retrying one chunk is cheap where retrying the page is not. A
|
|
208
|
+
* chunk that still fails is left out: the merged plan is short those blocks, and
|
|
209
|
+
* the pipeline's translation coverage gate is what notices and repairs that. The
|
|
210
|
+
* whole call only fails when every chunk failed, so the caller's own retry loop
|
|
211
|
+
* sees a normal planner failure.
|
|
212
|
+
*/
|
|
213
|
+
export async function generateChunkedTranslationPlan(args) {
|
|
214
|
+
const { plannerArgs, chunks, generate } = args;
|
|
215
|
+
// Ops stream to the UI (and to the streamed-per-op apply) as they arrive from
|
|
216
|
+
// whichever chunk produced them. Order across chunks is not meaningful — each
|
|
217
|
+
// op patches a different block — but the index has to stay unique and rising,
|
|
218
|
+
// so it is handed out centrally rather than per chunk.
|
|
219
|
+
let streamedOpIndex = 0;
|
|
220
|
+
const runChunk = async (chunk, index) => {
|
|
221
|
+
const blockIds = new Set(chunk.blockIds);
|
|
222
|
+
const isFirstChunk = index === 0;
|
|
223
|
+
const startedAt = Date.now();
|
|
224
|
+
let attempts = 0;
|
|
225
|
+
let lastError;
|
|
226
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
227
|
+
attempts = attempt;
|
|
228
|
+
try {
|
|
229
|
+
const chunkPage = subsetPageForChunk(plannerArgs.currentPage, blockIds);
|
|
230
|
+
const result = await generate({
|
|
231
|
+
...plannerArgs,
|
|
232
|
+
currentPage: chunkPage,
|
|
233
|
+
contextPack: subsetContextPackForChunk(plannerArgs.contextPack, blockIds),
|
|
234
|
+
outputTokenScale: (plannerArgs.outputTokenScale ?? 1) * attempt,
|
|
235
|
+
// A page-wide translation normally ships every block's schema contract,
|
|
236
|
+
// because it edits every block. A chunk edits two — and repeating ~9 KB
|
|
237
|
+
// of contracts per chunk would hand back in input tokens most of what
|
|
238
|
+
// the fan-out buys.
|
|
239
|
+
contractBlockTypeAllowlist: [...new Set(chunkPage.blocks.map((block) => String(block.type)))],
|
|
240
|
+
// Chunks are self-contained: no tools to call, no prior turns to
|
|
241
|
+
// resolve against. Dropping both keeps the repeated input small.
|
|
242
|
+
history: [],
|
|
243
|
+
toolRuntime: undefined,
|
|
244
|
+
toolCallContext: undefined,
|
|
245
|
+
onToolExecution: undefined,
|
|
246
|
+
// Prose callbacks are single-sourced. Six chunks streaming their own
|
|
247
|
+
// summary into one message box would interleave into nonsense; ops and
|
|
248
|
+
// change-log lines are independent and stream from every chunk.
|
|
249
|
+
onToken: isFirstChunk ? plannerArgs.onToken : undefined,
|
|
250
|
+
onSummaryChunk: isFirstChunk ? plannerArgs.onSummaryChunk : undefined,
|
|
251
|
+
onThinking: isFirstChunk ? plannerArgs.onThinking : undefined,
|
|
252
|
+
onStatusUpdate: isFirstChunk ? plannerArgs.onStatusUpdate : undefined,
|
|
253
|
+
onPlannedOp: plannerArgs.onPlannedOp
|
|
254
|
+
? (op) => {
|
|
255
|
+
streamedOpIndex += 1;
|
|
256
|
+
plannerArgs.onPlannedOp(op, streamedOpIndex);
|
|
257
|
+
}
|
|
258
|
+
: undefined
|
|
259
|
+
});
|
|
260
|
+
const { kept, droppedCount } = filterChunkOps({ ops: result.plan.ops, blockIds, isFirstChunk });
|
|
261
|
+
if (droppedCount > 0) {
|
|
262
|
+
args.log?.warn({ event: "translation_chunk_ops_dropped", chunkIndex: index, droppedCount, blockIds: chunk.blockIds }, "Translation chunk returned ops outside its own blocks — dropped");
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
outcome: {
|
|
266
|
+
index,
|
|
267
|
+
blockIds: chunk.blockIds,
|
|
268
|
+
ok: true,
|
|
269
|
+
opCount: kept.length,
|
|
270
|
+
droppedOpCount: droppedCount,
|
|
271
|
+
attempts,
|
|
272
|
+
durationMs: Date.now() - startedAt,
|
|
273
|
+
bytes: chunk.bytes,
|
|
274
|
+
outputTokens: result.usage?.outputTokens ?? 0
|
|
275
|
+
},
|
|
276
|
+
result: { ...result, plan: { ...result.plan, ops: kept } }
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
lastError = error;
|
|
281
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
282
|
+
// Only a budget failure is worth an immediate identical retry; anything
|
|
283
|
+
// else (cancellation, an API error) should surface to the caller now.
|
|
284
|
+
if (attempt === 2 || !/truncated \(max_tokens/i.test(message))
|
|
285
|
+
break;
|
|
286
|
+
args.log?.warn({ event: "translation_chunk_retry", chunkIndex: index, blockIds: chunk.blockIds, reason: message.slice(0, 200) }, "Translation chunk truncated — retrying with a larger budget");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
outcome: {
|
|
291
|
+
index,
|
|
292
|
+
blockIds: chunk.blockIds,
|
|
293
|
+
ok: false,
|
|
294
|
+
opCount: 0,
|
|
295
|
+
droppedOpCount: 0,
|
|
296
|
+
attempts,
|
|
297
|
+
durationMs: Date.now() - startedAt,
|
|
298
|
+
bytes: chunk.bytes,
|
|
299
|
+
outputTokens: 0,
|
|
300
|
+
reason: lastError instanceof Error ? lastError.message : String(lastError)
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
const settled = await Promise.all(chunks.map((chunk, index) => runChunk(chunk, index)));
|
|
305
|
+
const outcomes = settled.map((entry) => entry.outcome);
|
|
306
|
+
args.onChunksSettled?.(outcomes);
|
|
307
|
+
const succeeded = settled.filter((entry) => entry.result !== undefined);
|
|
308
|
+
if (succeeded.length === 0) {
|
|
309
|
+
const firstReason = outcomes.find((outcome) => outcome.reason)?.reason ?? "translation chunks produced no plan";
|
|
310
|
+
throw new Error(firstReason);
|
|
311
|
+
}
|
|
312
|
+
return mergeChunkResults(succeeded.map((entry) => entry.result), plannerArgs.currentPage.blocks.map((block) => block.id));
|
|
313
|
+
}
|
|
314
|
+
/** Chunk usage is real spend on the same request — the totals have to include all of it. */
|
|
315
|
+
function addUsage(a, b) {
|
|
316
|
+
const cacheCreation = (a.cacheCreationInputTokens ?? 0) + (b.cacheCreationInputTokens ?? 0);
|
|
317
|
+
const cacheRead = (a.cacheReadInputTokens ?? 0) + (b.cacheReadInputTokens ?? 0);
|
|
318
|
+
return {
|
|
319
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
320
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
321
|
+
totalTokens: a.totalTokens + b.totalTokens,
|
|
322
|
+
...(cacheCreation > 0 ? { cacheCreationInputTokens: cacheCreation } : {}),
|
|
323
|
+
...(cacheRead > 0 ? { cacheReadInputTokens: cacheRead } : {})
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Fold the chunk plans back into one.
|
|
328
|
+
*
|
|
329
|
+
* Chunks are balanced by weight rather than page position, so their ops come
|
|
330
|
+
* back shuffled; `blockOrder` puts them back the way the page reads, which is
|
|
331
|
+
* the order the change log and the plan preview are reviewed in.
|
|
332
|
+
*
|
|
333
|
+
* The user-facing summary is not concatenated: every chunk was given the user's
|
|
334
|
+
* original message, so every chunk wrote a summary of the same request
|
|
335
|
+
* ("Translated the page into Russian"), and stacking six of those reads like a
|
|
336
|
+
* stutter. The first one stands for all.
|
|
337
|
+
*/
|
|
338
|
+
export function mergeChunkResults(results, blockOrder) {
|
|
339
|
+
const ops = [];
|
|
340
|
+
const changeLog = [];
|
|
341
|
+
const seenBlockOps = new Set();
|
|
342
|
+
for (const result of results) {
|
|
343
|
+
for (const op of result.plan.ops) {
|
|
344
|
+
// Chunks are disjoint by construction, but a duplicate here would mean two
|
|
345
|
+
// ops racing to patch one block, and the loser's translation vanishing.
|
|
346
|
+
const key = "blockId" in op && typeof op.blockId === "string" ? `${op.op}:${op.blockId}` : null;
|
|
347
|
+
if (key) {
|
|
348
|
+
if (seenBlockOps.has(key))
|
|
349
|
+
continue;
|
|
350
|
+
seenBlockOps.add(key);
|
|
351
|
+
}
|
|
352
|
+
ops.push(op);
|
|
353
|
+
}
|
|
354
|
+
for (const entry of result.plan.change_log ?? [])
|
|
355
|
+
changeLog.push(entry);
|
|
356
|
+
}
|
|
357
|
+
if (blockOrder) {
|
|
358
|
+
const rank = new Map(blockOrder.map((id, index) => [id, index]));
|
|
359
|
+
// Page-level ops carry no block, and belong at the top where the model put them.
|
|
360
|
+
const rankOf = (op) => "blockId" in op && typeof op.blockId === "string" ? rank.get(op.blockId) ?? Number.MAX_SAFE_INTEGER : -1;
|
|
361
|
+
ops.sort((a, b) => rankOf(a) - rankOf(b));
|
|
362
|
+
}
|
|
363
|
+
const summary = results.map((result) => result.plan.summary_for_user).find((text) => typeof text === "string" && text.trim().length > 0)
|
|
364
|
+
?? "Translated the page.";
|
|
365
|
+
return {
|
|
366
|
+
plan: { intent: "edit_plan", summary_for_user: summary, change_log: changeLog, ops },
|
|
367
|
+
usage: results.reduce((total, result) => addUsage(total, result.usage), { ...ZERO_USAGE }),
|
|
368
|
+
schemaContext: results[0].schemaContext,
|
|
369
|
+
deferredNativeImageCalls: results.flatMap((result) => result.deferredNativeImageCalls ?? [])
|
|
370
|
+
};
|
|
371
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { BlockManifest, PageDoc } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { FieldEntry, LinkEntry } from "./types.ts";
|
|
3
|
+
export declare function walkPageFields(page: PageDoc, manifest: BlockManifest): FieldEntry[];
|
|
4
|
+
/**
|
|
5
|
+
* Readable text inside a field value.
|
|
6
|
+
*
|
|
7
|
+
* A `text` field is a string. A `richtext` field is a ProseMirror document, or
|
|
8
|
+
* markdown, or — for a custom block registered with a loose schema — something
|
|
9
|
+
* else entirely. Collecting every `text` string in the tree handles all three
|
|
10
|
+
* without the caller having to know which it got, and returns "" for a shape
|
|
11
|
+
* this does not recognise rather than guessing.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fieldText(value: unknown): string;
|
|
14
|
+
/**
|
|
15
|
+
* The page's fields, split per block, in block order.
|
|
16
|
+
*
|
|
17
|
+
* Paths and containers are *block-relative* — every block with an image has a
|
|
18
|
+
* field at `imageUrl`, every block with a list has a `cards[0]` — so any rule
|
|
19
|
+
* that indexes fields by path or container has to do it one block at a time. A
|
|
20
|
+
* page-wide index of either silently pairs one block's image with another
|
|
21
|
+
* block's alt text, which is a false negative on the page that has two heroes
|
|
22
|
+
* and a false positive with the wrong block in its evidence on the page that
|
|
23
|
+
* has two of anything else.
|
|
24
|
+
*/
|
|
25
|
+
export declare function groupByBlock(fields: FieldEntry[]): Map<string, FieldEntry[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Every link on the page, from the two places links live.
|
|
28
|
+
*
|
|
29
|
+
* The first is a `link`- or `url`-kind prop, which is what every link-aware
|
|
30
|
+
* rule already walked. The second is prose — a markdown or ProseMirror link
|
|
31
|
+
* inside a richtext body — which none of them did, and which on a content page
|
|
32
|
+
* is where most of the links are. A live site's Bistro section carries four
|
|
33
|
+
* menu-PDF links written exactly that way; not one was ever inspected, and the
|
|
34
|
+
* one pointing at a misspelled filename had been wrong for months with nothing
|
|
35
|
+
* able to see it.
|
|
36
|
+
*
|
|
37
|
+
* A prose link is attributed to the field that contains it. That is honest
|
|
38
|
+
* about what we can offer — the panel can open the body, but there is no
|
|
39
|
+
* editable path for a span inside it — and `inProse` lets a rule word its
|
|
40
|
+
* finding accordingly instead of pretending otherwise.
|
|
41
|
+
*/
|
|
42
|
+
export declare function walkPageLinks(page: PageDoc, manifest: BlockManifest): LinkEntry[];
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { linksInRichText } from "@avocadostudio-ai/shared";
|
|
2
|
+
/*
|
|
3
|
+
* Flatten a page's block props into located, kind-tagged fields.
|
|
4
|
+
*
|
|
5
|
+
* Every rule in this directory reads the manifest to find what it cares about
|
|
6
|
+
* — an image, its alt text, a heading level — rather than naming a block type.
|
|
7
|
+
* That is not tidiness. A rule containing `Hero` is a rule that silently exempts
|
|
8
|
+
* every custom block an integrator registered, which is most of the blocks on
|
|
9
|
+
* the sites where this checker would earn its keep.
|
|
10
|
+
*
|
|
11
|
+
* Paths use the editable-target grammar (`cards[0].imageAlt`) so a finding can
|
|
12
|
+
* point at a field the property panel and the preview overlay already address,
|
|
13
|
+
* and so `proposedOps` can be written against the same string.
|
|
14
|
+
*/
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function definitionFor(manifest, type) {
|
|
19
|
+
return manifest.blocks.find((b) => b.type === type);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Which field set applies to one item of a polymorphic list.
|
|
23
|
+
*
|
|
24
|
+
* A discriminated list renders different fields per branch; using the merged
|
|
25
|
+
* union for every item would invent fields an item does not have, and a rule
|
|
26
|
+
* would then report a missing alt text on an item that has no image.
|
|
27
|
+
*/
|
|
28
|
+
function itemFieldsFor(list, item) {
|
|
29
|
+
const merged = (list.itemFields ?? {});
|
|
30
|
+
if (!list.discriminator || !list.itemFieldsByType || !isRecord(item))
|
|
31
|
+
return merged;
|
|
32
|
+
const branch = item[list.discriminator];
|
|
33
|
+
if (typeof branch !== "string")
|
|
34
|
+
return merged;
|
|
35
|
+
return list.itemFieldsByType[branch] ?? merged;
|
|
36
|
+
}
|
|
37
|
+
/** Longer than this and the panel would be showing a paragraph, not a name. */
|
|
38
|
+
const BLOCK_LABEL_MAX = 48;
|
|
39
|
+
/**
|
|
40
|
+
* What a reader would call this block: its first non-empty plain-text prop.
|
|
41
|
+
*
|
|
42
|
+
* Manifest-driven like everything else here — the first `text` field in
|
|
43
|
+
* declaration order is a block's heading on every built-in block and on every
|
|
44
|
+
* custom one we have seen, and asking for `props.title` by name would find
|
|
45
|
+
* nothing on a block that calls it `headline`.
|
|
46
|
+
*
|
|
47
|
+
* `richtext` is deliberately excluded: a body is not a name, and the first 48
|
|
48
|
+
* characters of one reads as a truncated sentence rather than a label.
|
|
49
|
+
*/
|
|
50
|
+
function labelForBlock(definition, props) {
|
|
51
|
+
for (const [key, meta] of Object.entries(definition.fields ?? {})) {
|
|
52
|
+
if (meta.kind !== "text")
|
|
53
|
+
continue;
|
|
54
|
+
const value = props[key];
|
|
55
|
+
if (typeof value !== "string")
|
|
56
|
+
continue;
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
if (!trimmed)
|
|
59
|
+
continue;
|
|
60
|
+
return trimmed.length > BLOCK_LABEL_MAX ? `${trimmed.slice(0, BLOCK_LABEL_MAX - 1)}…` : trimmed;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
function entriesForBlock(block, definition) {
|
|
65
|
+
if (!definition)
|
|
66
|
+
return [];
|
|
67
|
+
const props = isRecord(block.props) ? block.props : {};
|
|
68
|
+
const out = [];
|
|
69
|
+
const blockLabel = labelForBlock(definition, props);
|
|
70
|
+
const located = blockLabel ? { blockLabel } : {};
|
|
71
|
+
for (const [key, meta] of Object.entries(definition.fields ?? {})) {
|
|
72
|
+
out.push({
|
|
73
|
+
blockId: block.id,
|
|
74
|
+
blockType: block.type,
|
|
75
|
+
...located,
|
|
76
|
+
path: key,
|
|
77
|
+
kind: meta.kind,
|
|
78
|
+
...(meta.label ? { label: meta.label } : {}),
|
|
79
|
+
value: props[key],
|
|
80
|
+
container: ""
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
for (const [key, list] of Object.entries(definition.listFields ?? {})) {
|
|
84
|
+
const items = props[key];
|
|
85
|
+
if (!Array.isArray(items))
|
|
86
|
+
continue;
|
|
87
|
+
items.forEach((item, index) => {
|
|
88
|
+
const container = `${key}[${index}]`;
|
|
89
|
+
for (const [itemKey, meta] of Object.entries(itemFieldsFor(list, item))) {
|
|
90
|
+
out.push({
|
|
91
|
+
blockId: block.id,
|
|
92
|
+
blockType: block.type,
|
|
93
|
+
...located,
|
|
94
|
+
path: `${container}.${itemKey}`,
|
|
95
|
+
kind: meta.kind,
|
|
96
|
+
...(meta.label ? { label: meta.label } : {}),
|
|
97
|
+
value: isRecord(item) ? item[itemKey] : undefined,
|
|
98
|
+
container
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
export function walkPageFields(page, manifest) {
|
|
106
|
+
return page.blocks.flatMap((block) => entriesForBlock(block, definitionFor(manifest, block.type)));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Readable text inside a field value.
|
|
110
|
+
*
|
|
111
|
+
* A `text` field is a string. A `richtext` field is a ProseMirror document, or
|
|
112
|
+
* markdown, or — for a custom block registered with a loose schema — something
|
|
113
|
+
* else entirely. Collecting every `text` string in the tree handles all three
|
|
114
|
+
* without the caller having to know which it got, and returns "" for a shape
|
|
115
|
+
* this does not recognise rather than guessing.
|
|
116
|
+
*/
|
|
117
|
+
export function fieldText(value) {
|
|
118
|
+
if (typeof value === "string")
|
|
119
|
+
return value;
|
|
120
|
+
if (Array.isArray(value))
|
|
121
|
+
return value.map(fieldText).filter(Boolean).join(" ");
|
|
122
|
+
if (isRecord(value)) {
|
|
123
|
+
const parts = [];
|
|
124
|
+
if (typeof value.text === "string")
|
|
125
|
+
parts.push(value.text);
|
|
126
|
+
if (Array.isArray(value.content))
|
|
127
|
+
parts.push(fieldText(value.content));
|
|
128
|
+
return parts.join(" ");
|
|
129
|
+
}
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The page's fields, split per block, in block order.
|
|
134
|
+
*
|
|
135
|
+
* Paths and containers are *block-relative* — every block with an image has a
|
|
136
|
+
* field at `imageUrl`, every block with a list has a `cards[0]` — so any rule
|
|
137
|
+
* that indexes fields by path or container has to do it one block at a time. A
|
|
138
|
+
* page-wide index of either silently pairs one block's image with another
|
|
139
|
+
* block's alt text, which is a false negative on the page that has two heroes
|
|
140
|
+
* and a false positive with the wrong block in its evidence on the page that
|
|
141
|
+
* has two of anything else.
|
|
142
|
+
*/
|
|
143
|
+
export function groupByBlock(fields) {
|
|
144
|
+
const byBlock = new Map();
|
|
145
|
+
for (const field of fields) {
|
|
146
|
+
const list = byBlock.get(field.blockId);
|
|
147
|
+
if (list)
|
|
148
|
+
list.push(field);
|
|
149
|
+
else
|
|
150
|
+
byBlock.set(field.blockId, [field]);
|
|
151
|
+
}
|
|
152
|
+
return byBlock;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Every link on the page, from the two places links live.
|
|
156
|
+
*
|
|
157
|
+
* The first is a `link`- or `url`-kind prop, which is what every link-aware
|
|
158
|
+
* rule already walked. The second is prose — a markdown or ProseMirror link
|
|
159
|
+
* inside a richtext body — which none of them did, and which on a content page
|
|
160
|
+
* is where most of the links are. A live site's Bistro section carries four
|
|
161
|
+
* menu-PDF links written exactly that way; not one was ever inspected, and the
|
|
162
|
+
* one pointing at a misspelled filename had been wrong for months with nothing
|
|
163
|
+
* able to see it.
|
|
164
|
+
*
|
|
165
|
+
* A prose link is attributed to the field that contains it. That is honest
|
|
166
|
+
* about what we can offer — the panel can open the body, but there is no
|
|
167
|
+
* editable path for a span inside it — and `inProse` lets a rule word its
|
|
168
|
+
* finding accordingly instead of pretending otherwise.
|
|
169
|
+
*/
|
|
170
|
+
export function walkPageLinks(page, manifest) {
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const field of walkPageFields(page, manifest)) {
|
|
173
|
+
const located = {
|
|
174
|
+
blockId: field.blockId,
|
|
175
|
+
blockType: field.blockType,
|
|
176
|
+
...(field.blockLabel ? { blockLabel: field.blockLabel } : {}),
|
|
177
|
+
path: field.path,
|
|
178
|
+
...(field.label ? { label: field.label } : {})
|
|
179
|
+
};
|
|
180
|
+
if (field.kind === "link" || field.kind === "url" || field.kind === "file") {
|
|
181
|
+
if (typeof field.value === "string" && field.value.trim() !== "") {
|
|
182
|
+
out.push({ ...located, value: field.value, inProse: false });
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
/*
|
|
187
|
+
* Only richtext. A `text` prop holding something that looks like markdown
|
|
188
|
+
* is a string that happens to contain brackets, and treating it as prose
|
|
189
|
+
* would invent links out of product copy.
|
|
190
|
+
*/
|
|
191
|
+
if (field.kind !== "richtext")
|
|
192
|
+
continue;
|
|
193
|
+
for (const href of linksInRichText(field.value)) {
|
|
194
|
+
out.push({ ...located, value: href, inProse: true });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks } from "./session-runner.ts";
|
|
2
|
+
export { runDraftChecks, fingerprintFor, type RunChecksArgs } from "./run-checks.ts";
|
|
3
|
+
export { DRAFT_RULES } from "./rules-draft.ts";
|
|
4
|
+
export { walkPageFields, fieldText, groupByBlock } from "./field-walk.ts";
|
|
5
|
+
export type { CheckRule, CheckContext, RuleFinding, FieldEntry, SiteView } from "./types.ts";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks } from "./session-runner.js";
|
|
2
|
+
export { runDraftChecks, fingerprintFor } from "./run-checks.js";
|
|
3
|
+
export { DRAFT_RULES } from "./rules-draft.js";
|
|
4
|
+
export { walkPageFields, fieldText, groupByBlock } from "./field-walk.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type SiteConfig } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { FieldEntry, SiteView } from "./types.ts";
|
|
3
|
+
export type PageWeight = {
|
|
4
|
+
slug: string;
|
|
5
|
+
/** 0–1. Only meaningful as an ordering; the absolute value is not a quantity. */
|
|
6
|
+
weight: number;
|
|
7
|
+
/** Editorial inbound links, excluding site chrome and self-links. */
|
|
8
|
+
inbound: number;
|
|
9
|
+
/** Clicks from `/` over editorial links and named nav. Null when unreached. */
|
|
10
|
+
depth: number | null;
|
|
11
|
+
/** Named in `navLabels`/`navGroups`, or linked from nearly every page. */
|
|
12
|
+
named: boolean;
|
|
13
|
+
isHome: boolean;
|
|
14
|
+
/** Nothing on the site links here and nobody named it. See `named` below. */
|
|
15
|
+
isUnlinked: boolean;
|
|
16
|
+
};
|
|
17
|
+
export declare function computePageWeights(args: {
|
|
18
|
+
/** Every page on the site, not only the ones being scanned. */
|
|
19
|
+
pages: SiteView["pages"];
|
|
20
|
+
fieldsBySlug: Map<string, FieldEntry[]>;
|
|
21
|
+
config: SiteConfig;
|
|
22
|
+
}): Map<string, PageWeight>;
|