Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
33c1581
fix(translate): prevent content loss in long-form translation (#166)
luandro Mar 19, 2026
fd3b4d2
fix(translate): address Codex review — reachable 8k floor and correct…
luandro Mar 19, 2026
c36dfe9
fix(translate): count setext headings in structure metrics
luandro Mar 19, 2026
4459a15
fix(translate): restrict setext heading detection to H1 (===) only
luandro Mar 19, 2026
ecdf122
fix(translate): add setext H2 and admonition tracking to completeness…
luandro Mar 19, 2026
c67c047
fix(translate): strip fenced content before metrics and fix table det…
luandro Mar 19, 2026
ece8b76
fix(scripts): resolve typescript compilation and markdown parsing bugs
luandro Mar 19, 2026
0c418f1
fix(translate): exclude YAML frontmatter from structure metrics
luandro Mar 20, 2026
d1b5ff2
fix(translate): tolerate one missing heading and restore unclosed fen…
luandro Mar 20, 2026
a5db86f
docs: add initial CHANGELOG.md file
luandro Mar 20, 2026
b040df4
fix(translate): flag any heading loss as incomplete translation
luandro Mar 25, 2026
014bd81
fix(translate): detect finish_reason:length as token_overflow
luandro Mar 25, 2026
7168988
fix(notion-translate): harden translation integrity checks
luandro Mar 26, 2026
3252c66
fix(translate): handle indented fenced code blocks
luandro Mar 26, 2026
93ba455
fix(i18n): restore translation strings and changelog formatting
luandro Mar 26, 2026
2a5bb87
revert(i18n): remove locale files from issue-166
luandro Mar 26, 2026
23e0754
fix(translate): track CommonMark fence length to prevent nested-fence…
luandro Mar 26, 2026
d15da43
fix(translate): wire frontmatter integrity failures into chunk-halvin…
luandro Mar 26, 2026
58e0a00
fix(translate): force chunked retries after incomplete responses
luandro Mar 27, 2026
1d624e0
test(translate): add efficiency eval coverage
luandro Mar 27, 2026
8638989
perf(translate): bound custom backend output budgets
luandro Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions scripts/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,22 @@ export const ENGLISH_DIR_SAVE_ERROR =
// Translation retry configuration
export const TRANSLATION_MAX_RETRIES = 3;
export const TRANSLATION_RETRY_BASE_DELAY_MS = 750;
/** Max characters per translation chunk.
* Targets ~143K tokens (500K chars / 3.5 chars per token).
* Leaves generous buffer within OpenAI's 272K structured-output limit. */
export const TRANSLATION_CHUNK_MAX_CHARS = 500_000;
/**
* Reliability-oriented cap for proactive markdown translation chunking.
* This keeps long-form docs away from the model's theoretical context ceiling,
* even when the model advertises a much larger maximum context window.
*/
export const TRANSLATION_CHUNK_MAX_CHARS = 120_000;
/** Smallest total-budget chunk size used when retrying incomplete translations. */
export const TRANSLATION_MIN_CHUNK_MAX_CHARS = 8_000;
/**
* Maximum times to retry with smaller chunks after completeness checks fail.
* Each retry halves the chunk limit. Starting from 120 K chars:
* 120k → 60k → 30k → 15k → 8k (floor)
* Four halvings are needed to descend from the default cap to the 8k floor,
* so this must be at least 4.
*/
export const TRANSLATION_COMPLETENESS_MAX_RETRIES = 4;

// URL handling
export const INVALID_URL_PLACEHOLDER =
Expand Down
272 changes: 256 additions & 16 deletions scripts/notion-translate/translateFrontMatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,58 @@ import {
} from "./test-openai-mock";
import { installTestNotionEnv } from "../test-utils";

type MockOpenAIRequest = {
messages?: Array<{ role: string; content: string }>;
};

function extractPromptMarkdown(request: MockOpenAIRequest): {
title: string;
markdown: string;
} {
const userPrompt =
request.messages?.find((message) => message.role === "user")?.content ?? "";
const titleMatch = userPrompt.match(/^title:\s*(.*)$/m);
const markdownMarker = "\nmarkdown: ";
const markdownIndex = userPrompt.indexOf(markdownMarker);

return {
title: titleMatch?.[1] ?? "",
markdown:
markdownIndex >= 0
? userPrompt.slice(markdownIndex + markdownMarker.length)
: "",
};
}

function installStructuredTranslationMock(
mapResponse?: (payload: { title: string; markdown: string }) => {
title: string;
markdown: string;
}
) {
mockOpenAIChatCompletionCreate.mockImplementation(
async (request: MockOpenAIRequest) => {
const payload = extractPromptMarkdown(request);
const translated = mapResponse
? mapResponse(payload)
: {
title: payload.title ? `Translated ${payload.title}` : "",
markdown: payload.markdown,
};

return {
choices: [
{
message: {
content: JSON.stringify(translated),
},
},
],
};
}
);
}

describe("notion-translate translateFrontMatter", () => {
let restoreEnv: () => void;

Expand Down Expand Up @@ -55,7 +107,190 @@ describe("notion-translate translateFrontMatter", () => {
);
});

it("classifies token overflow errors as non-critical token_overflow code", async () => {
it("chunks long-form content proactively below model-derived maximums", async () => {
const { translateText } = await import("./translateFrontMatter");
installStructuredTranslationMock();

const largeContent =
"# Section One\n\n" +
"word ".repeat(14_000) +
"\n# Section Two\n\n" +
"word ".repeat(14_000);

const result = await translateText(largeContent, "Large Page", "pt-BR");

expect(mockOpenAIChatCompletionCreate.mock.calls.length).toBeGreaterThan(1);
expect(result.markdown).toContain("# Section Two");
});

it("retries with smaller chunks when a valid response omits a section", async () => {
const { translateText } = await import("./translateFrontMatter");

const source =
"# Section One\n\n" +
"Alpha paragraph.\n\n" +
"# Section Two\n\n" +
"Beta paragraph.\n\n" +
"# Section Three\n\n" +
"Gamma paragraph.";

mockOpenAIChatCompletionCreate
.mockResolvedValueOnce({
choices: [
{
message: {
content: JSON.stringify({
markdown:
"# Seção Um\n\nParágrafo alfa.\n\n# Seção Três\n\nParágrafo gama.",
title: "Título Traduzido",
}),
},
},
],
})
.mockResolvedValue({
choices: [
{
message: {
content: JSON.stringify({
markdown:
"# Seção Um\n\nParágrafo alfa.\n\n# Seção Dois\n\nParágrafo beta.\n\n# Seção Três\n\nParágrafo gama.",
title: "Título Traduzido",
}),
},
},
],
});

const result = await translateText(source, "Original Title", "pt-BR", {
chunkLimit: 8_500,
});

expect(mockOpenAIChatCompletionCreate).toHaveBeenCalledTimes(2);
expect(result.markdown).toContain("# Seção Dois");
expect(result.title).toBe("Título Traduzido");
});

it("fails when repeated completeness retries still return incomplete content", async () => {
const { translateText } = await import("./translateFrontMatter");

const source =
"# Section One\n\n" +
"Alpha paragraph.\n\n" +
"# Section Two\n\n" +
"Beta paragraph.\n\n" +
"# Section Three\n\n" +
"Gamma paragraph.";

mockOpenAIChatCompletionCreate.mockImplementation(async () => ({
choices: [
{
message: {
content: JSON.stringify({
markdown:
"# Seção Um\n\nParágrafo alfa.\n\n# Seção Três\n\nParágrafo gama.",
title: "Título Traduzido",
}),
},
},
],
}));

await expect(
translateText(source, "Original Title", "pt-BR", {
chunkLimit: 8_500,
})
).rejects.toEqual(
expect.objectContaining({
code: "unexpected_error",
isCritical: false,
})
);
expect(mockOpenAIChatCompletionCreate.mock.calls.length).toBeGreaterThan(1);
});

it("treats heavy structural shrinkage as incomplete long-form translation", async () => {
const { translateText } = await import("./translateFrontMatter");

const source =
"# Long Section\n\n" +
Array.from(
{ length: 160 },
(_, index) => `Paragraph ${index} with repeated explanatory content.`
).join("\n\n");

mockOpenAIChatCompletionCreate
.mockResolvedValueOnce({
choices: [
{
message: {
content: JSON.stringify({
markdown: "# Seção Longa\n\nResumo curto.",
title: "Título Traduzido",
}),
},
},
],
})
.mockImplementation(async (request: MockOpenAIRequest) => {
const payload = extractPromptMarkdown(request);
return {
choices: [
{
message: {
content: JSON.stringify({
markdown: payload.markdown.replace(/Paragraph/g, "Parágrafo"),
title: "Título Traduzido",
}),
},
},
],
};
});

const result = await translateText(source, "Original Title", "pt-BR", {
chunkLimit: 25_000,
});

expect(mockOpenAIChatCompletionCreate).toHaveBeenCalledTimes(2);
expect(result.markdown.length).toBeGreaterThan(4_000);
});

it("preserves complete heading structures when chunking by sections", async () => {
const { translateText } = await import("./translateFrontMatter");
installStructuredTranslationMock(({ title, markdown }) => ({
title: title ? `Translated ${title}` : "",
markdown: markdown
.replace("# Section One", "# Seção Um")
.replace("# Section Two", "# Seção Dois")
.replace("# Section Three", "# Seção Três")
.replace(/Alpha/g, "Alfa")
.replace(/Gamma/g, "Gama"),
}));

const source =
"# Section One\n\n" +
"Alpha ".repeat(60) +
"\n\n# Section Two\n\n" +
"Beta ".repeat(60) +
"\n\n# Section Three\n\n" +
"Gamma ".repeat(60);

// chunkLimit is the *total* request budget (prompt overhead + markdown).
// Prompt overhead is ~2.6 K chars; a 3_200 limit leaves ~587 chars of
// markdown per chunk, which fits one 375-char section but not two — so
// the three sections produce exactly three API calls.
const result = await translateText(source, "Original Title", "pt-BR", {
chunkLimit: 3_200,
});

expect(mockOpenAIChatCompletionCreate).toHaveBeenCalledTimes(3);
expect(result.markdown).toContain("# Seção Um");
expect(result.markdown).toContain("# Seção Dois");
expect(result.markdown).toContain("# Seção Três");
});

it("continues to classify token overflow errors as non-critical token_overflow code", async () => {
const { translateText } = await import("./translateFrontMatter");

mockOpenAIChatCompletionCreate.mockRejectedValue({
Expand Down Expand Up @@ -91,6 +326,7 @@ describe("notion-translate translateFrontMatter", () => {

it("takes the single-call fast path for small content", async () => {
const { translateText } = await import("./translateFrontMatter");
installStructuredTranslationMock();

const result = await translateText(
"# Small page\n\nJust a paragraph.",
Expand All @@ -99,14 +335,15 @@ describe("notion-translate translateFrontMatter", () => {
);

expect(mockOpenAIChatCompletionCreate).toHaveBeenCalledTimes(1);
expect(result.title).toBe("Mock Title");
expect(result.markdown).toBe("# translated\n\nMock content");
expect(result.title).toBe("Translated Small");
expect(result.markdown).toBe("# Small page\n\nJust a paragraph.");
});

it("chunks large content and calls the API once per chunk", async () => {
const { translateText, splitMarkdownIntoChunks } = await import(
"./translateFrontMatter"
);
installStructuredTranslationMock();

// Build content that is larger than the chunk threshold
const bigSection1 = "# Section One\n\n" + "word ".repeat(100_000);
Expand All @@ -123,8 +360,8 @@ describe("notion-translate translateFrontMatter", () => {
expect(
mockOpenAIChatCompletionCreate.mock.calls.length
).toBeGreaterThanOrEqual(2);
expect(result.title).toBe("Mock Title"); // taken from first chunk
expect(typeof result.markdown).toBe("string");
expect(result.title).toBe("Translated Big Page");
expect(result.markdown).toContain("# Section Two");
expect(result.markdown.length).toBeGreaterThan(0);
});

Expand All @@ -137,17 +374,20 @@ describe("notion-translate translateFrontMatter", () => {
message:
"This model's maximum context length is 131072 tokens. However, you requested 211603 tokens (211603 in the messages, 0 in the completion).",
})
.mockResolvedValue({
choices: [
{
message: {
content: JSON.stringify({
markdown: "translated chunk",
title: "Translated Title",
}),
.mockImplementation(async (request: MockOpenAIRequest) => {
const payload = extractPromptMarkdown(request);
return {
choices: [
{
message: {
content: JSON.stringify({
markdown: payload.markdown,
title: "Translated Title",
}),
},
},
},
],
],
};
});

const result = await translateText(
Expand All @@ -158,7 +398,7 @@ describe("notion-translate translateFrontMatter", () => {

expect(mockOpenAIChatCompletionCreate.mock.calls.length).toBeGreaterThan(1);
expect(result.title).toBe("Translated Title");
expect(result.markdown.length).toBeGreaterThan(0);
expect(result.markdown).toContain("Just a paragraph.");
});

it("masks and restores data URL images during translation", async () => {
Expand Down
Loading
Loading