fix: harden docs i18n prompt echoes

This commit is contained in:
Peter Steinberger
2026-04-28 09:11:28 +01:00
parent 725d557de6
commit bce6c10290
7 changed files with 76 additions and 4 deletions

View File

@@ -144,6 +144,7 @@ func TestValidateNoTranslationTranscriptArtifacts(t *testing.T) {
tests := []string{
`表情回应 analysis to=functions.read {"path":"/home/runner/work/docs/docs/source/.agents/skills/openclaw-qa-testing/SKILL.md"} code`,
"<openclaw_docs_i18n_input>\nTranslated\n</openclaw_docs_i18n_input>",
`กำลังทำงานกับ reactions to=functions.read commentary  ̄第四色json 皇平台`,
`คุณต้องการแผนที่เอกสาร analysis to=final code omitted`,
`Potrzebujesz listy funkcji TUI force_parallel: false} code`,

View File

@@ -17,6 +17,8 @@ func prettyLanguageLabel(lang string) string {
return "Simplified Chinese"
case strings.EqualFold(trimmed, "ja-JP"):
return "Japanese"
case strings.EqualFold(trimmed, "de"):
return "German"
case strings.EqualFold(trimmed, "th"):
return "Thai"
case strings.EqualFold(trimmed, "uk"):
@@ -38,7 +40,16 @@ func translationPrompt(srcLang, tgtLang string, glossary []GlossaryEntry) string
case strings.EqualFold(tgtLang, "ja-JP"):
return strings.TrimSpace(fmt.Sprintf(jaJPPromptTemplate, srcLabel, tgtLabel, glossaryBlock))
default:
return strings.TrimSpace(fmt.Sprintf(genericPromptTemplate, srcLabel, tgtLabel, glossaryBlock))
return strings.TrimSpace(fmt.Sprintf(genericPromptTemplate, srcLabel, tgtLabel, localePromptRules(tgtLang), glossaryBlock))
}
}
func localePromptRules(tgtLang string) string {
switch {
case strings.EqualFold(tgtLang, "de"):
return "- For German docs, use formal address consistently: “Sie/Ihr/Ihnen”. Avoid informal “du/dein/dir”.\n- Use established technical German; keep “Provider” where it is clearer than “Anbieter”, and avoid awkward mixed compounds."
default:
return ""
}
}
@@ -135,6 +146,7 @@ Rules:
- Do not remove, reorder, or summarize content.
- Use fluent, idiomatic technical language in the target language; avoid slang or jokes.
- Use neutral documentation tone.
%s
- Glossary terms are mandatory. When a source term matches a glossary entry, use
the glossary target exactly, including headings, link labels, and short
UI-style labels.

View File

@@ -0,0 +1,22 @@
package main
import (
"strings"
"testing"
)
func TestTranslationPromptAddsGermanStyleRules(t *testing.T) {
t.Parallel()
prompt := translationPrompt("en", "de", nil)
for _, want := range []string{
"Translate from English to German.",
"Sie/Ihr/Ihnen",
"Avoid informal “du/dein/dir”",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected %q in German prompt:\n%s", want, prompt)
}
}
}

View File

@@ -110,7 +110,7 @@ func (t *CodexTranslator) translateMasked(ctx context.Context, core string) (str
if err != nil {
return "", err
}
translated := strings.TrimSpace(resText)
translated := stripCodexI18nInputWrappers(strings.TrimSpace(resText))
if translated == "" {
return "", errEmptyTranslation
}
@@ -125,13 +125,21 @@ func (t *CodexTranslator) translateRaw(ctx context.Context, core string) (string
if err != nil {
return "", err
}
translated := strings.TrimSpace(resText)
translated := stripCodexI18nInputWrappers(strings.TrimSpace(resText))
if translated == "" {
return "", errEmptyTranslation
}
return translated, nil
}
func stripCodexI18nInputWrappers(text string) string {
replacer := strings.NewReplacer(
"<openclaw_docs_i18n_input>", "",
"</openclaw_docs_i18n_input>", "",
)
return strings.TrimSpace(replacer.Replace(text))
}
func (t *CodexTranslator) prompt(ctx context.Context, message string) (string, error) {
if t.runPrompt == nil {
return "", errors.New("codex prompt runner unavailable")

View File

@@ -119,6 +119,26 @@ func TestCodexTranslatorRetriesTransientFailure(t *testing.T) {
}
}
func TestCodexTranslatorStripsInputWrapperEcho(t *testing.T) {
t.Parallel()
translator := &CodexTranslator{
systemPrompt: "Translate from English to German.",
thinking: "high",
runPrompt: func(context.Context, codexPromptRequest) (string, error) {
return "<openclaw_docs_i18n_input>\nÜbersetzt\n</openclaw_docs_i18n_input>", nil
},
}
got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "de")
if err != nil {
t.Fatalf("TranslateRaw returned error: %v", err)
}
if got != "Übersetzt" {
t.Fatalf("unexpected translation %q", got)
}
}
func TestBuildCodexTranslationPromptIncludesGuardrailsAndInput(t *testing.T) {
prompt := buildCodexTranslationPrompt("System prompt.", "Hello\nworld")

View File

@@ -11,7 +11,7 @@ import (
)
const (
workflowVersion = 15
workflowVersion = 16
docsI18nEngineName = "codex"
envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER"
envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL"
@@ -101,6 +101,11 @@ func isWhitespace(b byte) bool {
func validateNoTranslationTranscriptArtifacts(source, translated string) error {
sourceLower := strings.ToLower(source)
for _, token := range []string{"<openclaw_docs_i18n_input>", "</openclaw_docs_i18n_input>"} {
if strings.Contains(strings.ToLower(translated), token) && !strings.Contains(sourceLower, token) {
return fmt.Errorf("agent transcript artifact leaked into translation: %q", token)
}
}
for _, match := range translationTranscriptArtifactRE.FindAllString(translated, -1) {
match = strings.TrimSpace(match)
if match == "" {