@avocadostudio-ai/orchestrator-core 0.3.2 → 0.3.3
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 +296 -53
- 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/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +25 -0
- package/dist/checks/field-walk.js +152 -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 +375 -0
- package/dist/checks/run-checks.d.ts +32 -0
- package/dist/checks/run-checks.js +152 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +95 -0
- package/dist/checks/types.d.ts +65 -0
- package/dist/checks/types.js +1 -0
- 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 +67 -4
- 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 +1 -1
- 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 +4 -4
- 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 +7 -0
- package/dist/index.js +27 -0
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/plan-normalizer.js +54 -6
- 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-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/state/session-state.js +8 -1
- package/package.json +3 -3
|
@@ -29,7 +29,7 @@ const openAIPlanner = {
|
|
|
29
29
|
},
|
|
30
30
|
async generatePlan(args) {
|
|
31
31
|
// OpenAI doesn't yet support Anthropic-style thinking events — drop them.
|
|
32
|
-
const { onStatusUpdate: _s, onImageProgress: _i, log: _l, onThinking: _t, thinking: _th, ...rest } = args;
|
|
32
|
+
const { onStatusUpdate: _s, onImageProgress: _i, log: _l, onThinking: _t, thinking: _th, outputTokenScale: _o, ...rest } = args;
|
|
33
33
|
return generatePlanWithOpenAI(rest);
|
|
34
34
|
},
|
|
35
35
|
};
|
|
@@ -44,7 +44,7 @@ const geminiPlanner = {
|
|
|
44
44
|
supportsNativeTools: true,
|
|
45
45
|
parseIntent: parseIntentWithGemini,
|
|
46
46
|
async generatePlan(args) {
|
|
47
|
-
const { onThinking: _t, thinking: _th, ...rest } = args;
|
|
47
|
+
const { onThinking: _t, thinking: _th, outputTokenScale: _o, ...rest } = args;
|
|
48
48
|
return generatePlanWithGemini(rest);
|
|
49
49
|
},
|
|
50
50
|
};
|
package/dist/chat/planner.d.ts
CHANGED
|
@@ -45,6 +45,16 @@ export declare function buildPlannerSchemaContext(args: {
|
|
|
45
45
|
forceFullContracts?: boolean;
|
|
46
46
|
componentsManifest?: BlockManifest;
|
|
47
47
|
effectiveBlockTypes?: string[];
|
|
48
|
+
/**
|
|
49
|
+
* Hard ceiling on which block contracts may be sent, whatever mode is chosen.
|
|
50
|
+
*
|
|
51
|
+
* A page-wide translation asks for `full` contracts because it edits every
|
|
52
|
+
* block on the page — but a *chunk* of one edits only the blocks it holds, and
|
|
53
|
+
* shipping all eighteen contracts to each of six chunks repeats ~9 KB six times
|
|
54
|
+
* for no gain. Set only where the caller can guarantee the model cannot
|
|
55
|
+
* legitimately reference a type outside the list.
|
|
56
|
+
*/
|
|
57
|
+
contractBlockTypeAllowlist?: string[];
|
|
48
58
|
}): {
|
|
49
59
|
payload: PlannerSchemaContextPayload;
|
|
50
60
|
meta: PlannerSchemaContextMeta;
|
|
@@ -136,6 +146,8 @@ export declare function generatePlanWithOpenAI(args: {
|
|
|
136
146
|
client?: PlannerOpenAIClient;
|
|
137
147
|
siteContextBlock?: string | null;
|
|
138
148
|
forceFullSchemaContracts?: boolean;
|
|
149
|
+
/** Restrict block-schema contracts to these types. See CommonGeneratePlanArgs. */
|
|
150
|
+
contractBlockTypeAllowlist?: string[];
|
|
139
151
|
componentsManifest?: BlockManifest;
|
|
140
152
|
lightweight?: boolean;
|
|
141
153
|
signal?: AbortSignal;
|
package/dist/chat/planner.js
CHANGED
|
@@ -191,13 +191,26 @@ function buildContractsForMode(args) {
|
|
|
191
191
|
payload.pageMetaContract = pageMetaContractSummary();
|
|
192
192
|
return payload;
|
|
193
193
|
}
|
|
194
|
+
/** Narrow the contract set to an allowlist, when the caller supplied a non-empty one. */
|
|
195
|
+
function restrictContracts(allContracts, allowlist) {
|
|
196
|
+
if (!allowlist || allowlist.length === 0)
|
|
197
|
+
return allContracts;
|
|
198
|
+
const restricted = {};
|
|
199
|
+
for (const type of allowlist) {
|
|
200
|
+
if (type in allContracts)
|
|
201
|
+
restricted[type] = allContracts[type];
|
|
202
|
+
}
|
|
203
|
+
// An allowlist that matches nothing is a caller bug, not an instruction to
|
|
204
|
+
// send the model a page it has no schema for — fall back to everything.
|
|
205
|
+
return Object.keys(restricted).length > 0 ? restricted : allContracts;
|
|
206
|
+
}
|
|
194
207
|
export function buildPlannerSchemaContext(args) {
|
|
195
208
|
const strictJsonEnabled = isStrictJsonResponseEnabled();
|
|
196
209
|
const targetBlockTypes = pickTargetBlockTypes({ message: args.message, contextPack: args.contextPack });
|
|
197
210
|
const includePageMetaContract = /\b(seo|meta|metadata|og\s*image|open\s*graph|description|structured\s*data|schema\.org)\b/i.test(args.message) || /\d{2,3}\s*char/i.test(args.message);
|
|
198
211
|
const knownTypes = args.effectiveBlockTypes
|
|
199
212
|
?? (args.componentsManifest ? args.componentsManifest.blocks.map(c => c.type) : Object.keys(blockSchemas));
|
|
200
|
-
const allContracts = blockContractsSummary(args.componentsManifest);
|
|
213
|
+
const allContracts = restrictContracts(blockContractsSummary(args.componentsManifest), args.contractBlockTypeAllowlist);
|
|
201
214
|
if (!isAdaptiveSchemaContextEnabled()) {
|
|
202
215
|
const payload = args.legacyIncludeContracts
|
|
203
216
|
? {
|
|
@@ -1162,7 +1175,8 @@ export async function generatePlanWithOpenAI(args) {
|
|
|
1162
1175
|
pageWideTranslation,
|
|
1163
1176
|
legacyIncludeContracts: includeContracts,
|
|
1164
1177
|
forceFullContracts: args.forceFullSchemaContracts,
|
|
1165
|
-
componentsManifest: args.componentsManifest
|
|
1178
|
+
componentsManifest: args.componentsManifest,
|
|
1179
|
+
contractBlockTypeAllowlist: args.contractBlockTypeAllowlist
|
|
1166
1180
|
});
|
|
1167
1181
|
const user = {
|
|
1168
1182
|
request: args.message,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { EditPlan, PageDoc } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { plannerContextPack } from "../nlp/deterministic-planner.ts";
|
|
3
|
+
import type { CommonGeneratePlanArgs, GeneratePlanResult } from "./planner-types.ts";
|
|
4
|
+
export declare function translationChunkingConfig(): {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Translatable bytes to aim for per chunk.
|
|
8
|
+
*
|
|
9
|
+
* Chunk wall clock is roughly a fixed time-to-first-token plus streaming time
|
|
10
|
+
* proportional to this. Measured on Sonnet 5: ~5s to first token, then ~130
|
|
11
|
+
* output tokens/second, and a chunk's output runs a little over one token per
|
|
12
|
+
* source byte. ~900 bytes puts streaming (~8s) in the same range as the fixed
|
|
13
|
+
* cost, which is where splitting further stops buying much and starts paying
|
|
14
|
+
* another chunk's input tokens for nothing.
|
|
15
|
+
*/
|
|
16
|
+
targetBytes: number;
|
|
17
|
+
/** Upper bound on parallel planner calls — one wave, not a stampede. */
|
|
18
|
+
maxChunks: number;
|
|
19
|
+
/** Below these, a single request is already fast and chunking only adds input cost. */
|
|
20
|
+
minBlocks: number;
|
|
21
|
+
minBytes: number;
|
|
22
|
+
};
|
|
23
|
+
export type TranslationChunk = {
|
|
24
|
+
blockIds: string[];
|
|
25
|
+
/** Translatable bytes carried by this chunk — the driver of its output length. */
|
|
26
|
+
bytes: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Partition the page's translatable blocks into balanced chunks.
|
|
30
|
+
*
|
|
31
|
+
* Wall clock is the *heaviest* chunk, not the average one, so balance is the
|
|
32
|
+
* whole game: a first live run split a page into 1604 / 1010 / 172 / 769 bytes
|
|
33
|
+
* and spent 17 of its 23 seconds waiting on the first bin while a 172-byte bin
|
|
34
|
+
* sat finished. Packing blocks in page order is what produces that — it has to
|
|
35
|
+
* close a chunk before it can see what comes next.
|
|
36
|
+
*
|
|
37
|
+
* So blocks are packed heaviest-first into whichever chunk is currently lightest
|
|
38
|
+
* (longest-processing-time-first), which bounds the heaviest chunk far better,
|
|
39
|
+
* and page order is restored afterwards so the change log still reads top to
|
|
40
|
+
* bottom. Blocks are never split: an `update_props` op carries a whole block.
|
|
41
|
+
*
|
|
42
|
+
* Returns a single chunk when the page is too small to be worth splitting; the
|
|
43
|
+
* caller treats that as "don't chunk".
|
|
44
|
+
*/
|
|
45
|
+
export declare function planTranslationChunks(args: {
|
|
46
|
+
page: PageDoc;
|
|
47
|
+
targetBytes?: number;
|
|
48
|
+
maxChunks?: number;
|
|
49
|
+
}): TranslationChunk[];
|
|
50
|
+
/** Whether this page is worth fanning out, per the configured thresholds. */
|
|
51
|
+
export declare function shouldChunkTranslation(page: PageDoc, chunks: TranslationChunk[]): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The page as this chunk should see it: same id/slug/meta, only its own blocks.
|
|
54
|
+
*
|
|
55
|
+
* Everything downstream in the planner derives from `currentPage` — the output
|
|
56
|
+
* token budget, and the enumerated translation checklist handed to the model —
|
|
57
|
+
* so narrowing the page is what makes a chunk a chunk. No extra plumbing.
|
|
58
|
+
*/
|
|
59
|
+
export declare function subsetPageForChunk(page: PageDoc, blockIds: Set<string>): PageDoc;
|
|
60
|
+
/** The context pack narrowed to the chunk's blocks, so input cost doesn't repeat the whole page N times. */
|
|
61
|
+
export declare function subsetContextPackForChunk(pack: ReturnType<typeof plannerContextPack>, blockIds: Set<string>): ReturnType<typeof plannerContextPack>;
|
|
62
|
+
/**
|
|
63
|
+
* Keep only what this chunk was asked for.
|
|
64
|
+
*
|
|
65
|
+
* A chunk sees a slice of the page, but nothing stops a model from volunteering
|
|
66
|
+
* an op for a block it half-remembers from the site context, or from restructuring
|
|
67
|
+
* a page it was asked to translate. Ops outside the chunk's blocks are dropped
|
|
68
|
+
* rather than merged: a translation that also deletes a section is not a
|
|
69
|
+
* translation, and two chunks editing the same block would silently race.
|
|
70
|
+
*/
|
|
71
|
+
export declare function filterChunkOps(args: {
|
|
72
|
+
ops: EditPlan["ops"];
|
|
73
|
+
blockIds: Set<string>;
|
|
74
|
+
isFirstChunk: boolean;
|
|
75
|
+
}): {
|
|
76
|
+
kept: EditPlan["ops"];
|
|
77
|
+
droppedCount: number;
|
|
78
|
+
};
|
|
79
|
+
export type ChunkOutcome = {
|
|
80
|
+
index: number;
|
|
81
|
+
blockIds: string[];
|
|
82
|
+
ok: boolean;
|
|
83
|
+
opCount: number;
|
|
84
|
+
droppedOpCount: number;
|
|
85
|
+
attempts: number;
|
|
86
|
+
/** Wall clock for this chunk. The slowest one is the request's latency — the number to tune against. */
|
|
87
|
+
durationMs: number;
|
|
88
|
+
bytes: number;
|
|
89
|
+
outputTokens: number;
|
|
90
|
+
reason?: string;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Run every chunk in parallel and merge the results into one plan.
|
|
94
|
+
*
|
|
95
|
+
* A chunk that dies on a token ceiling is retried once on its own, with double
|
|
96
|
+
* the budget — retrying one chunk is cheap where retrying the page is not. A
|
|
97
|
+
* chunk that still fails is left out: the merged plan is short those blocks, and
|
|
98
|
+
* the pipeline's translation coverage gate is what notices and repairs that. The
|
|
99
|
+
* whole call only fails when every chunk failed, so the caller's own retry loop
|
|
100
|
+
* sees a normal planner failure.
|
|
101
|
+
*/
|
|
102
|
+
export declare function generateChunkedTranslationPlan(args: {
|
|
103
|
+
plannerArgs: CommonGeneratePlanArgs;
|
|
104
|
+
chunks: TranslationChunk[];
|
|
105
|
+
generate: (plannerArgs: CommonGeneratePlanArgs) => Promise<GeneratePlanResult>;
|
|
106
|
+
log?: {
|
|
107
|
+
warn: (obj: Record<string, unknown>, msg: string) => void;
|
|
108
|
+
info?: (obj: Record<string, unknown>, msg: string) => void;
|
|
109
|
+
};
|
|
110
|
+
onChunksSettled?: (outcomes: ChunkOutcome[]) => void;
|
|
111
|
+
}): Promise<GeneratePlanResult>;
|
|
112
|
+
/**
|
|
113
|
+
* Fold the chunk plans back into one.
|
|
114
|
+
*
|
|
115
|
+
* Chunks are balanced by weight rather than page position, so their ops come
|
|
116
|
+
* back shuffled; `blockOrder` puts them back the way the page reads, which is
|
|
117
|
+
* the order the change log and the plan preview are reviewed in.
|
|
118
|
+
*
|
|
119
|
+
* The user-facing summary is not concatenated: every chunk was given the user's
|
|
120
|
+
* original message, so every chunk wrote a summary of the same request
|
|
121
|
+
* ("Translated the page into Russian"), and stacking six of those reads like a
|
|
122
|
+
* stutter. The first one stands for all.
|
|
123
|
+
*/
|
|
124
|
+
export declare function mergeChunkResults(results: GeneratePlanResult[], blockOrder?: string[]): GeneratePlanResult;
|
|
@@ -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,25 @@
|
|
|
1
|
+
import type { BlockManifest, PageDoc } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { FieldEntry } 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[]>;
|