Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
61 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
186d331
fix(notion-fetch): preserve single-page cache-backed paths
luandro Mar 29, 2026
a222cc9
fix(notion-fetch): serialize callout children correctly
luandro Mar 29, 2026
91dd71f
feat(notion-translate): support local-only single-page outputs
luandro Mar 29, 2026
90df5d1
fix(scripts): suppress dotenv tip messages from stdout
luandro Mar 30, 2026
29ec361
chore: remove accidental files from PR branch
Mar 30, 2026
d11328a
test(notion-fetch): add resolveCanonicalDocsRelativePath test suite
luandro Apr 2, 2026
68fea2e
feat(notion-fetch): route data: URLs through image processing pipeline
luandro Apr 2, 2026
26db354
feat(notion-fetch): extend retry loop to resolve data: image references
luandro Apr 2, 2026
e4578e4
fix(notion-fetch): remove batch-level timeout and improve failure log…
luandro Apr 2, 2026
156aecb
feat(notion-translate): structured completeness errors with diagnostics
luandro Apr 2, 2026
113e07b
feat(constants): add custom API chunk sizing constants
luandro Apr 2, 2026
8ee46bc
fix(scripts): improve single-page flow path resolution robustness
luandro Apr 2, 2026
23573dd
fix(notion-translate): restore placeholder image handling
luandro Apr 2, 2026
609da18
fix(scripts): accept image placeholders in single-page flow
luandro Apr 2, 2026
dfd77d2
test(notion-translate): remove stale image pipeline mocks
luandro Apr 2, 2026
6af1da7
test(notion-translate): restore edge-case URL coverage
luandro Apr 2, 2026
3d6086a
fix(scripts): make notion helper CLIs exit cleanly
luandro Apr 2, 2026
0902d7f
fix(scripts): stop single-page flow failing on review warnings
luandro Apr 2, 2026
1e69270
chore(i18n): refresh translated code.json strings
luandro Apr 2, 2026
6b13fd4
chore(notion-fetch): clarify data URL diagnostics
luandro Apr 2, 2026
18121a1
refactor(translate): deduplicate regex constants and use placeholder-…
Apr 3, 2026
68f9b47
fix(notion-translate): preserve fallback image references
luandro Apr 3, 2026
01b41eb
fix(scripts): normalize single-page fallback docs path
luandro Apr 3, 2026
d554adf
fix(notion-translate): escape backticks and add JSX constraint to prompt
luandro Apr 3, 2026
4d390a1
feat(scripts): polish single-page flow summary output
luandro Apr 3, 2026
1e9070b
test(eval): add JSX preservation integration test with real DeepSeek …
luandro Apr 3, 2026
08cbf01
test(notion-translate): add edge cases for body-level Video blocks
luandro Apr 3, 2026
e9bd1b1
fix(notion-translate): detect S3 URLs behind remote placeholders
luandro Apr 3, 2026
a715a18
fix(notion-translate): decode locale image placeholders before writin…
luandro Apr 4, 2026
c3a9619
fix(tests): fix failing translation tests and gate live integration t…
Apr 4, 2026
e58c362
fix(translate): make splitByLines lossless and add low-budget regress…
Apr 4, 2026
3434d87
fix(types): replace any with proper types in remark plugin and test m…
luandro Apr 4, 2026
3297cea
fix(translate): log when canonical English markdown is missing in docs/
luandro Apr 4, 2026
e8b976a
chore: add MIT license
luandro Apr 4, 2026
249d819
chore(tsconfig): exclude scripts/eval from tsc compilation
luandro Apr 4, 2026
0c4f373
fix(translate): preserve distinct LLM-translated sidebar/pagination l…
luandro Apr 4, 2026
8cdc2ab
fix(translate): use text placeholders for images in Notion-converted …
luandro Apr 4, 2026
c44e3d5
revert(i18n): exclude i18n locale changes from PR 169
Apr 4, 2026
981e4d7
fix(notion-translate): avoid url fallback in image placeholders
luandro Apr 4, 2026
156021d
fix(translate): reject heading recovery when restored heading has no …
luandro Apr 5, 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