Skip to content

Commit 76b0d2e

Browse files
committed
feat(ai): coordinate long-document translation
1 parent d6ef402 commit 76b0d2e

13 files changed

Lines changed: 1127 additions & 29 deletions

apps/core/src/modules/ai/ai-translation/engine/translation-agent.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
import type { PipelineMetrics } from '../translation-strategy.interface'
1414
import type { TranslationUnit } from '../translation-unit.types'
1515
import { flatIdsOf, unitsToSourceMap } from '../translation-unit.types'
16+
import { shouldUseChunkedTranslation } from './translation-chunk-planner'
1617
import { createTranslationConversation } from './translation-context'
18+
import { runChunkedTranslationAgent } from './translation-coordinator'
1719
import { createTranslationTools, TRANSLATION_FILE } from './translation-tools'
1820

1921
export const AGENT_MAX_STEPS = 12
@@ -51,6 +53,25 @@ export async function runTranslationAgent(opts: {
5153
throw new TypeError('runtime does not implement streamMessage')
5254
}
5355

56+
if (shouldUseChunkedTranslation(units)) {
57+
logger.log(
58+
`Long document detected: units=${units.length}; using translation coordinator`,
59+
)
60+
return runChunkedTranslationAgent({
61+
targetLang,
62+
units,
63+
documentContext,
64+
styleHints,
65+
runtime,
66+
reviewerRuntime,
67+
signal,
68+
onToken,
69+
onCost,
70+
onSegments,
71+
metrics,
72+
})
73+
}
74+
5475
const vfs = new VirtualFs()
5576
let reviewer: SubAgentSpec | undefined
5677
if (reviewerRuntime) {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { TranslationUnit } from '../translation-unit.types'
2+
import { flatIdsOf } from '../translation-unit.types'
3+
4+
export const DIRECT_TRANSLATION_MAX_SOURCE_CHARS = 12_000
5+
export const DIRECT_TRANSLATION_MAX_SEGMENTS = 240
6+
export const TRANSLATION_CHUNK_MAX_SOURCE_CHARS = 6_000
7+
export const TRANSLATION_CHUNK_MAX_SEGMENTS = 120
8+
9+
export interface TranslationChunk {
10+
id: string
11+
units: TranslationUnit[]
12+
sourceChars: number
13+
segmentCount: number
14+
}
15+
16+
export interface TranslationLoad {
17+
sourceChars: number
18+
segmentCount: number
19+
}
20+
21+
export const translationUnitSourceChars = (unit: TranslationUnit): number => {
22+
if (typeof unit.payload === 'string') return unit.payload.length
23+
return unit.payload.segments.reduce(
24+
(total, segment) => total + segment.text.length,
25+
0,
26+
)
27+
}
28+
29+
export const estimateTranslationLoad = (
30+
units: readonly TranslationUnit[],
31+
): TranslationLoad => ({
32+
sourceChars: units.reduce(
33+
(total, unit) => total + translationUnitSourceChars(unit),
34+
0,
35+
),
36+
segmentCount: flatIdsOf([...units]).length,
37+
})
38+
39+
export const shouldUseChunkedTranslation = (
40+
units: readonly TranslationUnit[],
41+
): boolean => {
42+
const load = estimateTranslationLoad(units)
43+
return (
44+
load.sourceChars > DIRECT_TRANSLATION_MAX_SOURCE_CHARS ||
45+
load.segmentCount > DIRECT_TRANSLATION_MAX_SEGMENTS
46+
)
47+
}
48+
49+
export const planTranslationChunks = (
50+
units: readonly TranslationUnit[],
51+
): TranslationChunk[] => {
52+
const chunks: TranslationChunk[] = []
53+
let pending: TranslationUnit[] = []
54+
let sourceChars = 0
55+
let segmentCount = 0
56+
57+
const flush = () => {
58+
if (pending.length === 0) return
59+
chunks.push({
60+
id: `chunk-${chunks.length + 1}`,
61+
units: pending,
62+
sourceChars,
63+
segmentCount,
64+
})
65+
pending = []
66+
sourceChars = 0
67+
segmentCount = 0
68+
}
69+
70+
for (const unit of units) {
71+
const unitSourceChars = translationUnitSourceChars(unit)
72+
const unitSegmentCount = unit.memberIds?.length ?? 1
73+
const exceedsBudget =
74+
pending.length > 0 &&
75+
(sourceChars + unitSourceChars > TRANSLATION_CHUNK_MAX_SOURCE_CHARS ||
76+
segmentCount + unitSegmentCount > TRANSLATION_CHUNK_MAX_SEGMENTS)
77+
if (exceedsBudget) flush()
78+
pending.push(unit)
79+
sourceChars += unitSourceChars
80+
segmentCount += unitSegmentCount
81+
}
82+
flush()
83+
return chunks
84+
}

apps/core/src/modules/ai/ai-translation/engine/translation-context.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AI_PROMPTS } from '../../ai.prompts'
22
import { Conversation } from '../../message-engine/conversation/conversation'
33
import type { TranslationUnit } from '../translation-unit.types'
44
import { unitsToEntries, unitsToMeta } from '../translation-unit.types'
5+
import type { TranslationChunk } from './translation-chunk-planner'
56

67
export function createTranslationConversation(opts: {
78
targetLang: string
@@ -24,3 +25,31 @@ export function createTranslationConversation(opts: {
2425
conversation.appendUser(prompt)
2526
return conversation
2627
}
28+
29+
export function createTranslationCoordinatorConversation(opts: {
30+
targetLang: string
31+
chunks: readonly TranslationChunk[]
32+
reviewEnabled: boolean
33+
}): Conversation {
34+
const { targetLang, chunks, reviewEnabled } = opts
35+
const reviewWorkflow = reviewEnabled
36+
? 'After every chunk is complete, call request_review. If it reports issues, read only the cited ids with read_translation_segments, patch them, and request review again. Finish after a clean review or when the review budget is exhausted.'
37+
: 'After every chunk is complete, finish with a short confirmation.'
38+
const conversation =
39+
new Conversation(`You coordinate a long-document translation into ${targetLang}.
40+
41+
Do not translate prose yourself. The source text is intentionally hidden from this conversation. Delegate work through translate_chunks, which runs isolated translation sub-agents with bounded concurrency.
42+
43+
Call translate_chunks with all pending chunk ids. If any chunk fails, retry only the failed ids. Use translation_status when coverage is uncertain. Never invent chunk ids.
44+
45+
${reviewWorkflow}`)
46+
conversation.appendUser(`Translate every chunk in this manifest:
47+
${JSON.stringify(
48+
chunks.map((chunk) => ({
49+
id: chunk.id,
50+
sourceChars: chunk.sourceChars,
51+
segmentCount: chunk.segmentCount,
52+
})),
53+
)}`)
54+
return conversation
55+
}

0 commit comments

Comments
 (0)