fix(docs): reject translated heading loss

This commit is contained in:
Peter Steinberger
2026-07-11 19:20:39 -04:00
parent 453c049ac8
commit 8fea7f5011
5 changed files with 145 additions and 6 deletions

View File

@@ -26,8 +26,9 @@ var docsProtocolTokens = []string{
}
type docChunkStructure struct {
fenceCount int
tagCounts map[string]int
fenceCount int
tagCounts map[string]int
headingLevels []int
}
type docChunkSplitPlan struct {
@@ -204,6 +205,9 @@ func validateDocChunkTranslation(source, translated string) error {
if sourceStructure.fenceCount != translatedStructure.fenceCount {
return fmt.Errorf("code fence mismatch: source=%d translated=%d", sourceStructure.fenceCount, translatedStructure.fenceCount)
}
if !slices.Equal(sourceStructure.headingLevels, translatedStructure.headingLevels) {
return fmt.Errorf("heading structure mismatch: source=%v translated=%v", sourceStructure.headingLevels, translatedStructure.headingLevels)
}
if !slices.Equal(sortedKeys(sourceStructure.tagCounts), sortedKeys(translatedStructure.tagCounts)) {
return fmt.Errorf("component tag set mismatch")
}
@@ -335,8 +339,9 @@ func summarizeDocChunkStructure(text string) docChunkStructure {
}
}
return docChunkStructure{
fenceCount: counts["__fence_toggle__"],
tagCounts: countsWithoutFence(counts),
fenceCount: counts["__fence_toggle__"],
tagCounts: countsWithoutFence(counts),
headingLevels: extractMarkdownHeadingLevels(text),
}
}

View File

@@ -475,6 +475,108 @@ func TestValidateDocChunkTranslationRejectsInventedI18NPlaceholder(t *testing.T)
}
}
func TestValidateDocChunkTranslationRejectsHeadingLoss(t *testing.T) {
t.Parallel()
source := "## Detailed behavior and rationale\n\nExplanation.\n"
translated := "详细说明。\n"
err := validateDocChunkTranslation(source, translated)
if err == nil {
t.Fatal("expected heading loss to be rejected")
}
if !strings.Contains(err.Error(), "heading structure mismatch: source=[2] translated=[]") {
t.Fatalf("expected heading structure error, got %v", err)
}
}
func TestValidateDocChunkTranslationAcceptsTranslatedHeadingText(t *testing.T) {
t.Parallel()
source := "## Detailed behavior and rationale\n\nExplanation.\n"
translated := "## 详细行为与设计理由\n\n说明。\n"
if err := validateDocChunkTranslation(source, translated); err != nil {
t.Fatalf("expected translated heading text with the same level to pass, got %v", err)
}
}
func TestValidateDocChunkTranslationRejectsSetextHeadingLoss(t *testing.T) {
t.Parallel()
source := "Detailed behavior and rationale\n---------------------------------\n\nExplanation.\n"
translated := "详细说明。\n"
err := validateDocChunkTranslation(source, translated)
if err == nil {
t.Fatal("expected Setext heading loss to be rejected")
}
if !strings.Contains(err.Error(), "heading structure mismatch: source=[2] translated=[]") {
t.Fatalf("expected Setext heading structure error, got %v", err)
}
}
func TestValidateDocChunkTranslationAcceptsTranslatedSetextHeading(t *testing.T) {
t.Parallel()
source := "Detailed behavior and rationale\n---------------------------------\n\nExplanation.\n"
translated := "详细行为与设计理由\n------------------\n\n说明。\n"
if err := validateDocChunkTranslation(source, translated); err != nil {
t.Fatalf("expected translated Setext heading with the same level to pass, got %v", err)
}
}
func TestValidateDocChunkTranslationDoesNotTreatThematicBreakAsSetextHeading(t *testing.T) {
t.Parallel()
source := "- First item\n\n---\n\nParagraph.\n"
translated := "- 第一项\n\n---\n\n段落。\n"
if err := validateDocChunkTranslation(source, translated); err != nil {
t.Fatalf("expected thematic break to remain distinct from a Setext heading, got %v", err)
}
}
func TestValidateDocChunkTranslationRejectsNestedHeadingLoss(t *testing.T) {
t.Parallel()
source := "> ## Warning\n>\n> Keep this setting enabled.\n\n- ### Step\n Run the command.\n"
translated := "> 警告\n>\n> 保持此设置启用。\n\n- 步骤\n 运行命令。\n"
err := validateDocChunkTranslation(source, translated)
if err == nil {
t.Fatal("expected nested heading loss to be rejected")
}
if !strings.Contains(err.Error(), "heading structure mismatch: source=[2 3] translated=[]") {
t.Fatalf("expected nested heading structure error, got %v", err)
}
}
func TestValidateDocChunkTranslationRejectsComponentNestedHeadingLoss(t *testing.T) {
t.Parallel()
source := "<Note>\n## Important\n\nKeep this setting enabled.\n</Note>\n"
translated := "<Note>\n重要\n\n保持此设置启用。\n</Note>\n"
err := validateDocChunkTranslation(source, translated)
if err == nil {
t.Fatal("expected component-nested heading loss to be rejected")
}
if !strings.Contains(err.Error(), "heading structure mismatch: source=[2] translated=[]") {
t.Fatalf("expected component-nested heading structure error, got %v", err)
}
}
func TestHeadingExtractionIgnoresComponentExamplesInsideCodeFences(t *testing.T) {
t.Parallel()
text := "```mdx\n<Note>\n## Example only\n</Note>\n```\n"
if levels := extractMarkdownHeadingLevels(text); len(levels) != 0 {
t.Fatalf("expected no headings from fenced component example, got %v", levels)
}
}
func TestValidateDocChunkTranslationRejectsTranscriptArtifact(t *testing.T) {
t.Parallel()

View File

@@ -94,6 +94,38 @@ func extractSegments(body, relPath string) ([]Segment, error) {
return filtered, nil
}
func extractMarkdownHeadingLevels(body string) []int {
source := []byte(stripDocComponentTagsForHeadingParse(body))
doc := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(source))
levels := []int{}
_ = ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
heading, ok := node.(*ast.Heading)
if ok {
levels = append(levels, heading.Level)
}
return ast.WalkContinue, nil
})
return levels
}
func stripDocComponentTagsForHeadingParse(body string) string {
lines := strings.Split(body, "\n")
fenceDelimiter := ""
for index, line := range lines {
wasInFence := fenceDelimiter != ""
var toggled bool
fenceDelimiter, toggled = updateFenceDelimiter(fenceDelimiter, line)
if wasInFence || toggled || fenceDelimiter != "" {
continue
}
lines[index] = docsComponentTagRE.ReplaceAllString(line, "")
}
return strings.Join(lines, "\n")
}
func blockParent(n ast.Node) ast.Node {
for node := n.Parent(); node != nil; node = node.Parent() {
if isTranslatableBlock(node) {

View File

@@ -12,7 +12,7 @@ import (
const (
workflowVersion = 16
promptVersion = 7
promptVersion = 8
docsI18nEngineName = "codex"
envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER"
envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL"

View File

@@ -8,7 +8,7 @@ import (
func TestCacheNamespaceIncludesPromptVersion(t *testing.T) {
t.Parallel()
if want := "prompt=7"; !strings.Contains(cacheNamespace(), want) {
if want := "prompt=8"; !strings.Contains(cacheNamespace(), want) {
t.Fatalf("expected cache namespace to contain %q, got %q", want, cacheNamespace())
}
}