fix: use codex for docs i18n

This commit is contained in:
Peter Steinberger 2026-04-28 04:32:28 +01:00
parent b2d102109b
commit 3945193257
No known key found for this signature in database
14 changed files with 337 additions and 1062 deletions

View file

@ -187,6 +187,9 @@ func validateDocChunkTranslation(source, translated string) error {
if hasUnexpectedTopLevelProtocolWrapper(source, translated) {
return fmt.Errorf("protocol token leaked: top-level wrapper")
}
if err := validateNoTranslationTranscriptArtifacts(source, translated); err != nil {
return err
}
sourceLower := strings.ToLower(source)
translatedLower := strings.ToLower(translated)
for _, token := range docsProtocolTokens {

View file

@ -460,6 +460,21 @@ func TestValidateDocChunkTranslationRejectsProtocolTokenLeakage(t *testing.T) {
}
}
func TestValidateDocChunkTranslationRejectsTranscriptArtifact(t *testing.T) {
t.Parallel()
source := "Regular paragraph.\n\n"
translated := `Regular paragraph. assistant to=functions.read commentary {"path":"/home/runner/work/docs/docs/source/AGENTS.md"} code`
err := validateDocChunkTranslation(source, translated)
if err == nil {
t.Fatal("expected transcript artifact to be rejected")
}
if !strings.Contains(err.Error(), "agent transcript artifact") {
t.Fatalf("expected transcript artifact error, got %v", err)
}
}
func TestValidateDocChunkTranslationRejectsTopLevelBodyWrapperLeakEvenWhenSourceMentionsBodyTag(t *testing.T) {
t.Parallel()

View file

@ -1,10 +1,9 @@
module github.com/openclaw/openclaw/scripts/docs-i18n
go 1.24.0
go 1.25.0
require (
github.com/joshp123/pi-golang v0.0.4
github.com/yuin/goldmark v1.7.8
golang.org/x/net v0.50.0
github.com/yuin/goldmark v1.8.2
golang.org/x/net v0.53.0
gopkg.in/yaml.v3 v3.0.1
)

View file

@ -1,9 +1,7 @@
github.com/joshp123/pi-golang v0.0.4 h1:82HISyKNN8bIl2lvAd65462LVCQIsjhaUFQxyQgg5Xk=
github.com/joshp123/pi-golang v0.0.4/go.mod h1:9mHEQkeJELYzubXU3b86/T8yedI/iAOKx0Tz0c41qes=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -67,7 +67,7 @@ func main() {
maxFiles: *maxFiles,
parallel: *parallel,
}, files, func(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (docsTranslator, error) {
return NewPiTranslator(srcLang, tgtLang, glossary, thinking)
return NewCodexTranslator(srcLang, tgtLang, glossary, thinking)
}); err != nil {
fatal(err)
}

View file

@ -37,6 +37,18 @@ func (invalidFrontmatterTranslator) TranslateRaw(_ context.Context, text, _, _ s
func (invalidFrontmatterTranslator) Close() {}
type transcriptFrontmatterTranslator struct{}
func (transcriptFrontmatterTranslator) Translate(_ context.Context, text, _, _ string) (string, error) {
return text + ` analysis to=functions.read {"path":"/home/runner/work/docs/docs/source/.agents/skills/openclaw-pr-maintainer/SKILL.md"} code`, nil
}
func (transcriptFrontmatterTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) {
return text, nil
}
func (transcriptFrontmatterTranslator) Close() {}
func TestRunDocsI18NRewritesFinalLocalizedPageLinks(t *testing.T) {
t.Parallel()
@ -106,3 +118,45 @@ func TestTranslateSnippetDoesNotCacheFallbackToSource(t *testing.T) {
t.Fatalf("expected fallback translation not to be cached")
}
}
func TestTranslateSnippetRejectsTranscriptArtifact(t *testing.T) {
t.Parallel()
tm := &TranslationMemory{entries: map[string]TMEntry{}}
source := "Working with reactions across channels"
translated, err := translateSnippet(context.Background(), transcriptFrontmatterTranslator{}, tm, "tools/reactions.md:frontmatter:read_when:0", source, "en", "th")
if err != nil {
t.Fatalf("translateSnippet returned error: %v", err)
}
if translated != source {
t.Fatalf("expected fallback to source text, got %q", translated)
}
cacheKey := cacheKey(cacheNamespace(), "en", "th", "tools/reactions.md:frontmatter:read_when:0", hashText(source))
if _, ok := tm.Get(cacheKey); ok {
t.Fatalf("expected fallback translation not to be cached")
}
}
func TestValidateNoTranslationTranscriptArtifacts(t *testing.T) {
t.Parallel()
tests := []string{
`表情回应 analysis to=functions.read {"path":"/home/runner/work/docs/docs/source/.agents/skills/openclaw-qa-testing/SKILL.md"} code`,
`กำลังทำงานกับ reactions to=functions.read commentary  ̄第四色json 皇平台`,
`คุณต้องการแผนที่เอกสาร analysis to=final code omitted`,
`Potrzebujesz listy funkcji TUI force_parallel: false} code`,
`กำลังตัดสินใจว่าจะกำหนดค่าผู้ให้บริการสื่อรายใด 全民彩票 casino`,
}
for _, translated := range tests {
if err := validateNoTranslationTranscriptArtifacts("Working with reactions across channels", translated); err == nil {
t.Fatalf("expected artifact to be rejected: %q", translated)
}
}
source := "Document `functions.read` examples exactly."
if err := validateNoTranslationTranscriptArtifacts(source, "Document `functions.read` examples exactly."); err != nil {
t.Fatalf("expected source-owned token to be allowed: %v", err)
}
}

View file

@ -1,130 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
const (
envDocsPiExecutable = "OPENCLAW_DOCS_I18N_PI_EXECUTABLE"
envDocsPiArgs = "OPENCLAW_DOCS_I18N_PI_ARGS"
envDocsPiPackageVersion = "OPENCLAW_DOCS_I18N_PI_PACKAGE_VERSION"
envDocsPiOmitProvider = "OPENCLAW_DOCS_I18N_PI_OMIT_PROVIDER"
defaultPiPackageVersion = "0.58.3"
)
type docsPiCommand struct {
Executable string
Args []string
}
var (
materializedPiRuntimeMu sync.Mutex
materializedPiRuntimeCommand docsPiCommand
materializedPiRuntimeErr error
)
func resolveDocsPiCommand(ctx context.Context) (docsPiCommand, error) {
if executable := strings.TrimSpace(os.Getenv(envDocsPiExecutable)); executable != "" {
return docsPiCommand{
Executable: executable,
Args: strings.Fields(os.Getenv(envDocsPiArgs)),
}, nil
}
piPath, err := exec.LookPath("pi")
if err == nil && !shouldMaterializePiRuntime(piPath) {
return docsPiCommand{Executable: piPath}, nil
}
return ensureMaterializedPiRuntime(ctx)
}
func shouldMaterializePiRuntime(piPath string) bool {
realPath, err := filepath.EvalSymlinks(piPath)
if err != nil {
realPath = piPath
}
return strings.Contains(filepath.ToSlash(realPath), "/Projects/pi-mono/")
}
func ensureMaterializedPiRuntime(ctx context.Context) (docsPiCommand, error) {
materializedPiRuntimeMu.Lock()
defer materializedPiRuntimeMu.Unlock()
if materializedPiRuntimeErr == nil && materializedPiRuntimeCommand.Executable != "" {
return materializedPiRuntimeCommand, nil
}
runtimeDir, err := getMaterializedPiRuntimeDir()
if err != nil {
materializedPiRuntimeErr = err
return docsPiCommand{}, err
}
cliPath := filepath.Join(runtimeDir, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js")
if _, err := os.Stat(cliPath); errors.Is(err, os.ErrNotExist) {
installCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := os.MkdirAll(runtimeDir, 0o755); err != nil {
materializedPiRuntimeErr = err
return docsPiCommand{}, err
}
packageVersion := getMaterializedPiPackageVersion()
install := exec.CommandContext(
installCtx,
"npm",
"install",
"--silent",
"--no-audit",
"--no-fund",
fmt.Sprintf("@mariozechner/pi-coding-agent@%s", packageVersion),
)
install.Dir = runtimeDir
install.Env = os.Environ()
output, err := install.CombinedOutput()
if err != nil {
materializedPiRuntimeErr = fmt.Errorf("materialize pi runtime: %w (%s)", err, strings.TrimSpace(string(output)))
return docsPiCommand{}, materializedPiRuntimeErr
}
}
materializedPiRuntimeCommand = docsPiCommand{
Executable: "node",
Args: []string{cliPath},
}
materializedPiRuntimeErr = nil
return materializedPiRuntimeCommand, nil
}
func getMaterializedPiRuntimeDir() (string, error) {
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = os.TempDir()
}
return filepath.Join(cacheDir, "openclaw", "docs-i18n", "pi-runtime", getMaterializedPiPackageVersion()), nil
}
func getMaterializedPiPackageVersion() string {
if version := strings.TrimSpace(os.Getenv(envDocsPiPackageVersion)); version != "" {
return version
}
return defaultPiPackageVersion
}
func docsPiOmitProvider() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(envDocsPiOmitProvider))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}

View file

@ -1,351 +0,0 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
type docsPiClientOptions struct {
SystemPrompt string
Thinking string
}
type docsPiClient struct {
process *exec.Cmd
stdin io.WriteCloser
stderr bytes.Buffer
events chan piEvent
promptLock sync.Mutex
closeOnce sync.Once
closed chan struct{}
requestID uint64
}
type piEvent struct {
Type string
Raw json.RawMessage
}
type agentEndPayload struct {
Type string `json:"type,omitempty"`
Messages []agentMessage `json:"messages"`
}
type rpcResponse struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Command string `json:"command,omitempty"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type agentMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
StopReason string `json:"stopReason,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
func startDocsPiClient(ctx context.Context, options docsPiClientOptions) (*docsPiClient, error) {
command, err := resolveDocsPiCommand(ctx)
if err != nil {
return nil, err
}
args := append([]string{}, command.Args...)
args = append(args, "--mode", "rpc")
if provider := docsPiProviderArg(); provider != "" && !docsPiOmitProvider() {
args = append(args, "--provider", provider)
}
args = append(args,
"--model", docsPiModelRef(),
"--thinking", options.Thinking,
"--no-session",
)
if strings.TrimSpace(options.SystemPrompt) != "" {
args = append(args, "--system-prompt", options.SystemPrompt)
}
process := exec.Command(command.Executable, args...)
agentDir, err := resolveDocsPiAgentDir()
if err != nil {
return nil, err
}
process.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir))
stdin, err := process.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := process.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := process.StderrPipe()
if err != nil {
return nil, err
}
client := &docsPiClient{
process: process,
stdin: stdin,
events: make(chan piEvent, 256),
closed: make(chan struct{}),
}
if err := process.Start(); err != nil {
return nil, err
}
go client.captureStderr(stderr)
go client.readStdout(stdout)
return client, nil
}
func (client *docsPiClient) Prompt(ctx context.Context, message string) (string, error) {
client.promptLock.Lock()
defer client.promptLock.Unlock()
command := map[string]string{
"type": "prompt",
"id": fmt.Sprintf("req-%d", atomic.AddUint64(&client.requestID, 1)),
"message": message,
}
payload, err := json.Marshal(command)
if err != nil {
return "", err
}
if _, err := client.stdin.Write(append(payload, '\n')); err != nil {
return "", err
}
for {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-client.closed:
return "", errors.New("pi process closed")
case event, ok := <-client.events:
if !ok {
return "", errors.New("pi event stream closed")
}
if event.Type == "response" {
response, err := decodeRpcResponse(event.Raw)
if err != nil {
return "", err
}
if !response.Success {
if strings.TrimSpace(response.Error) == "" {
return "", errors.New("pi prompt failed")
}
return "", errors.New(strings.TrimSpace(response.Error))
}
continue
}
if event.Type == "agent_end" {
return extractTranslationResult(event.Raw)
}
}
}
}
func (client *docsPiClient) Stderr() string {
return client.stderr.String()
}
func (client *docsPiClient) Close() error {
client.closeOnce.Do(func() {
close(client.closed)
if client.stdin != nil {
_ = client.stdin.Close()
}
if client.process != nil && client.process.Process != nil {
_ = client.process.Process.Signal(syscall.SIGTERM)
}
done := make(chan struct{})
go func() {
if client.process != nil {
_ = client.process.Wait()
}
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
if client.process != nil && client.process.Process != nil {
_ = client.process.Process.Kill()
}
}
})
return nil
}
func (client *docsPiClient) captureStderr(stderr io.Reader) {
_, _ = io.Copy(&client.stderr, stderr)
}
func (client *docsPiClient) readStdout(stdout io.Reader) {
defer close(client.events)
reader := bufio.NewReader(stdout)
for {
line, err := reader.ReadBytes('\n')
line = bytes.TrimSpace(line)
if len(line) > 0 {
var envelope struct {
Type string `json:"type"`
}
if json.Unmarshal(line, &envelope) == nil && envelope.Type != "" {
select {
case client.events <- piEvent{Type: envelope.Type, Raw: append([]byte{}, line...)}:
case <-client.closed:
return
}
}
}
if err != nil {
return
}
}
}
func extractTranslationResult(raw json.RawMessage) (string, error) {
var payload agentEndPayload
if err := json.Unmarshal(raw, &payload); err != nil {
return "", err
}
for index := len(payload.Messages) - 1; index >= 0; index-- {
message := payload.Messages[index]
if message.Role != "assistant" {
continue
}
if message.ErrorMessage != "" || isTerminalPiStopReason(message.StopReason) {
text, _ := extractContentText(message.Content)
return "", formatPiAgentError(message, text)
}
text, err := extractContentText(message.Content)
if err != nil {
return "", err
}
return text, nil
}
return "", errors.New("assistant message not found")
}
func isTerminalPiStopReason(stopReason string) bool {
switch strings.ToLower(strings.TrimSpace(stopReason)) {
case "error", "terminated", "cancelled", "canceled", "aborted":
return true
default:
return false
}
}
func formatPiAgentError(message agentMessage, assistantText string) error {
parts := []string{}
if msg := strings.TrimSpace(message.ErrorMessage); msg != "" {
parts = append(parts, msg)
}
if stop := strings.TrimSpace(message.StopReason); stop != "" {
parts = append(parts, "stopReason="+stop)
}
if preview := previewPiAssistantText(assistantText); preview != "" {
parts = append(parts, "assistant="+preview)
}
if len(parts) == 0 {
parts = append(parts, "unknown error")
}
return fmt.Errorf("pi error: %s", strings.Join(parts, "; "))
}
func previewPiAssistantText(text string) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return ""
}
trimmed = strings.ReplaceAll(trimmed, "\n", " ")
trimmed = strings.Join(strings.Fields(trimmed), " ")
const limit = 160
if len(trimmed) <= limit {
return trimmed
}
return trimmed[:limit] + "..."
}
func extractContentText(content json.RawMessage) (string, error) {
trimmed := strings.TrimSpace(string(content))
if trimmed == "" {
return "", nil
}
if strings.HasPrefix(trimmed, "\"") {
var text string
if err := json.Unmarshal(content, &text); err != nil {
return "", err
}
return text, nil
}
var blocks []contentBlock
if err := json.Unmarshal(content, &blocks); err != nil {
return "", err
}
var parts []string
for _, block := range blocks {
if block.Type == "text" && block.Text != "" {
parts = append(parts, block.Text)
}
}
return strings.Join(parts, ""), nil
}
func decodeRpcResponse(raw json.RawMessage) (rpcResponse, error) {
var response rpcResponse
if err := json.Unmarshal(raw, &response); err != nil {
return rpcResponse{}, err
}
return response, nil
}
func getDocsPiAgentDir() (string, error) {
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = os.TempDir()
}
dir := filepath.Join(cacheDir, "openclaw", "docs-i18n", "agent")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
return dir, nil
}
func resolveDocsPiAgentDir() (string, error) {
if override := strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")); override != "" {
if err := os.MkdirAll(override, 0o700); err != nil {
return "", err
}
return override, nil
}
return getDocsPiAgentDir()
}

View file

@ -1,84 +0,0 @@
package main
import (
"strings"
"testing"
)
func TestExtractTranslationResultIncludesStopReasonAndPreview(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"agent_end",
"messages":[
{
"role":"assistant",
"stopReason":"terminated",
"content":[
{"type":"text","text":"provider disconnected while streaming the translation chunk"}
]
}
]
}`)
_, err := extractTranslationResult(raw)
if err == nil {
t.Fatal("expected error")
}
message := err.Error()
for _, want := range []string{
"pi error:",
"stopReason=terminated",
"assistant=provider disconnected while streaming the translation chunk",
} {
if !strings.Contains(message, want) {
t.Fatalf("expected %q in error, got %q", want, message)
}
}
}
func TestPreviewPiAssistantTextTruncatesAndFlattensWhitespace(t *testing.T) {
t.Parallel()
input := "line one\n\nline two\tline three " + strings.Repeat("x", 200)
preview := previewPiAssistantText(input)
if strings.Contains(preview, "\n") {
t.Fatalf("expected flattened whitespace, got %q", preview)
}
if !strings.HasPrefix(preview, "line one line two line three ") {
t.Fatalf("unexpected preview prefix: %q", preview)
}
if !strings.HasSuffix(preview, "...") {
t.Fatalf("expected truncation suffix, got %q", preview)
}
}
func TestExtractTranslationResultReturnsPiErrorBeforeDecodingStructuredErrorContent(t *testing.T) {
t.Parallel()
raw := []byte(`{
"type":"agent_end",
"messages":[
{
"role":"assistant",
"stopReason":"terminated",
"content":{"type":"error","message":"provider disconnected"}
}
]
}`)
_, err := extractTranslationResult(raw)
if err == nil {
t.Fatal("expected error")
}
message := err.Error()
if !strings.Contains(message, "pi error:") {
t.Fatalf("expected normalized pi error, got %q", message)
}
if !strings.Contains(message, "stopReason=terminated") {
t.Fatalf("expected stopReason in error, got %q", message)
}
if strings.Contains(message, "cannot unmarshal") {
t.Fatalf("expected terminal pi error before decode failure, got %q", message)
}
}

View file

@ -65,8 +65,8 @@ func processFile(ctx context.Context, translator docsTranslator, tm *Translation
TextHash: seg.TextHash,
Text: seg.Text,
Translated: translated,
Provider: docsPiProvider(),
Model: docsPiModel(),
Provider: docsI18nProvider(),
Model: docsI18nModel(),
SrcLang: srcLang,
TgtLang: tgtLang,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
@ -122,8 +122,8 @@ func encodeFrontMatter(frontData map[string]any, relPath string, source []byte)
frontData["x-i18n"] = map[string]any{
"source_path": relPath,
"source_hash": hashBytes(source),
"provider": docsPiProvider(),
"model": docsPiModel(),
"provider": docsI18nProvider(),
"model": docsI18nModel(),
"workflow": workflowVersion,
"generated_at": time.Now().UTC().Format(time.RFC3339),
}
@ -229,8 +229,8 @@ func translateSnippet(ctx context.Context, translator docsTranslator, tm *Transl
TextHash: textHash,
Text: textValue,
Translated: translated,
Provider: docsPiProvider(),
Model: docsPiModel(),
Provider: docsI18nProvider(),
Model: docsI18nModel(),
SrcLang: srcLang,
TgtLang: tgtLang,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
@ -250,6 +250,9 @@ func validateFrontmatterScalarTranslation(source, translated string) error {
if strings.Contains(lower, "<frontmatter>") || strings.Contains(lower, "</frontmatter>") || strings.Contains(lower, "<body>") || strings.Contains(lower, "</body>") {
return fmt.Errorf("tagged document wrapper detected")
}
if err := validateNoTranslationTranscriptArtifacts(source, trimmed); err != nil {
return err
}
if strings.Contains(trimmed, "[[[FM_") {
return fmt.Errorf("frontmatter marker leaked into scalar translation")
}

View file

@ -1,26 +1,34 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
const (
translateMaxAttempts = 3
translateBaseDelay = 15 * time.Second
defaultPromptTimeout = 2 * time.Minute
envDocsI18nPromptTimeout = "OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT"
translateMaxAttempts = 3
translateBaseDelay = 15 * time.Second
defaultPromptTimeout = 2 * time.Minute
envDocsI18nPromptTimeout = "OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT"
envDocsI18nCodexExecutable = "OPENCLAW_DOCS_I18N_CODEX_EXECUTABLE"
)
var errEmptyTranslation = errors.New("empty translation")
type PiTranslator struct {
client docsPiPromptClient
clientFactory docsPiClientFactory
var translateRetryDelay = func(attempt int) time.Duration {
return translateBaseDelay * time.Duration(attempt)
}
type CodexTranslator struct {
systemPrompt string
thinking string
runPrompt codexPromptRunner
}
type docsTranslator interface {
@ -31,40 +39,32 @@ type docsTranslator interface {
type docsTranslatorFactory func(string, string, []GlossaryEntry, string) (docsTranslator, error)
type docsPiPromptClient interface {
promptRunner
Close() error
type codexPromptRunner func(context.Context, codexPromptRequest) (string, error)
type codexPromptRequest struct {
SystemPrompt string
Message string
Model string
Thinking string
}
type docsPiClientFactory func(context.Context) (docsPiPromptClient, error)
func NewPiTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*PiTranslator, error) {
options := docsPiClientOptions{
SystemPrompt: translationPrompt(srcLang, tgtLang, glossary),
Thinking: normalizeThinking(thinking),
}
clientFactory := func(ctx context.Context) (docsPiPromptClient, error) {
return startDocsPiClient(ctx, options)
}
client, err := clientFactory(context.Background())
if err != nil {
return nil, err
}
return &PiTranslator{client: client, clientFactory: clientFactory}, nil
func NewCodexTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*CodexTranslator, error) {
return &CodexTranslator{
systemPrompt: translationPrompt(srcLang, tgtLang, glossary),
thinking: normalizeThinking(thinking),
runPrompt: runCodexExecPrompt,
}, nil
}
func (t *PiTranslator) Translate(ctx context.Context, text, srcLang, tgtLang string) (string, error) {
func (t *CodexTranslator) Translate(ctx context.Context, text, srcLang, tgtLang string) (string, error) {
return t.translate(ctx, text, t.translateMasked)
}
func (t *PiTranslator) TranslateRaw(ctx context.Context, text, srcLang, tgtLang string) (string, error) {
func (t *CodexTranslator) TranslateRaw(ctx context.Context, text, srcLang, tgtLang string) (string, error) {
return t.translate(ctx, text, t.translateRaw)
}
func (t *PiTranslator) translate(ctx context.Context, text string, run func(context.Context, string) (string, error)) (string, error) {
if t.client == nil {
return "", errors.New("pi client unavailable")
}
func (t *CodexTranslator) translate(ctx context.Context, text string, run func(context.Context, string) (string, error)) (string, error) {
prefix, core, suffix := splitWhitespace(text)
if core == "" {
return text, nil
@ -78,7 +78,7 @@ func (t *PiTranslator) translate(ctx context.Context, text string, run func(cont
return prefix + translated + suffix, nil
}
func (t *PiTranslator) translateWithRetry(ctx context.Context, run func(context.Context) (string, error)) (string, error) {
func (t *CodexTranslator) translateWithRetry(ctx context.Context, run func(context.Context) (string, error)) (string, error) {
var lastErr error
for attempt := 0; attempt < translateMaxAttempts; attempt++ {
translated, err := run(ctx)
@ -90,13 +90,7 @@ func (t *PiTranslator) translateWithRetry(ctx context.Context, run func(context.
}
lastErr = err
if attempt+1 < translateMaxAttempts {
if shouldRestartPiClientForError(err) {
if err := t.restartClient(ctx); err != nil {
return "", fmt.Errorf("%w (pi client restart failed: %v)", lastErr, err)
}
continue
}
delay := translateBaseDelay * time.Duration(attempt+1)
delay := translateRetryDelay(attempt + 1)
if err := sleepWithContext(ctx, delay); err != nil {
return "", err
}
@ -105,12 +99,12 @@ func (t *PiTranslator) translateWithRetry(ctx context.Context, run func(context.
return "", lastErr
}
func (t *PiTranslator) translateMasked(ctx context.Context, core string) (string, error) {
func (t *CodexTranslator) translateMasked(ctx context.Context, core string) (string, error) {
state := NewPlaceholderState(core)
placeholders := make([]string, 0, 8)
mapping := map[string]string{}
masked := maskMarkdown(core, state.Next, &placeholders, mapping)
resText, err := runPrompt(ctx, t.client, masked)
resText, err := t.prompt(ctx, masked)
if err != nil {
return "", err
}
@ -124,8 +118,8 @@ func (t *PiTranslator) translateMasked(ctx context.Context, core string) (string
return unmaskMarkdown(translated, placeholders, mapping), nil
}
func (t *PiTranslator) translateRaw(ctx context.Context, core string) (string, error) {
resText, err := runPrompt(ctx, t.client, core)
func (t *CodexTranslator) translateRaw(ctx context.Context, core string) (string, error) {
resText, err := t.prompt(ctx, core)
if err != nil {
return "", err
}
@ -136,6 +130,20 @@ func (t *PiTranslator) translateRaw(ctx context.Context, core string) (string, e
return translated, nil
}
func (t *CodexTranslator) prompt(ctx context.Context, message string) (string, error) {
if t.runPrompt == nil {
return "", errors.New("codex prompt runner unavailable")
}
promptCtx, cancel := context.WithTimeout(ctx, docsI18nPromptTimeout())
defer cancel()
return t.runPrompt(promptCtx, codexPromptRequest{
SystemPrompt: t.systemPrompt,
Message: message,
Model: docsI18nModel(),
Thinking: t.thinking,
})
}
func isRetryableTranslateError(err error) bool {
if err == nil {
return false
@ -147,44 +155,87 @@ func isRetryableTranslateError(err error) bool {
return true
}
message := strings.ToLower(err.Error())
if strings.Contains(message, "authentication failed") {
if strings.Contains(message, "authentication failed") || strings.Contains(message, "invalid_api_key") || strings.Contains(message, "api key") {
return false
}
return strings.Contains(message, "placeholder missing") ||
strings.Contains(message, "rate limit") ||
strings.Contains(message, "429") ||
shouldRestartPiClientForError(err)
strings.Contains(message, "500") ||
strings.Contains(message, "502") ||
strings.Contains(message, "503") ||
strings.Contains(message, "504") ||
strings.Contains(message, "temporarily unavailable") ||
strings.Contains(message, "connection reset") ||
strings.Contains(message, "stream")
}
func shouldRestartPiClientForError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "pi error: terminated") ||
strings.Contains(message, "stopreason=cancelled") ||
strings.Contains(message, "stopreason=canceled") ||
strings.Contains(message, "stopreason=aborted") ||
strings.Contains(message, "stopreason=terminated") ||
strings.Contains(message, "stopreason=error") ||
strings.Contains(message, "pi process closed") ||
strings.Contains(message, "pi event stream closed")
}
func (t *PiTranslator) restartClient(ctx context.Context) error {
if t.clientFactory == nil {
return errors.New("pi client restart unavailable")
}
if t.client != nil {
_ = t.client.Close()
t.client = nil
}
client, err := t.clientFactory(ctx)
func runCodexExecPrompt(ctx context.Context, req codexPromptRequest) (string, error) {
outputFile, err := os.CreateTemp("", "openclaw-docs-i18n-codex-*.txt")
if err != nil {
return err
return "", err
}
t.client = client
return nil
outputPath := outputFile.Name()
_ = outputFile.Close()
defer os.Remove(outputPath)
args := []string{
"exec",
"--model", req.Model,
"-c", fmt.Sprintf("model_reasoning_effort=%q", normalizeThinking(req.Thinking)),
"--sandbox", "read-only",
"--ignore-rules",
"--skip-git-repo-check",
"--output-last-message", outputPath,
"-",
}
command := exec.CommandContext(ctx, docsCodexExecutable(), args...)
command.Stdin = strings.NewReader(buildCodexTranslationPrompt(req.SystemPrompt, req.Message))
var stdout bytes.Buffer
var stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
if err := command.Run(); err != nil {
return "", fmt.Errorf("codex exec failed: %w (%s)", err, previewCommandOutput(stdout.String(), stderr.String()))
}
data, err := os.ReadFile(outputPath)
if err != nil {
return "", err
}
translated := strings.TrimSpace(string(data))
if translated == "" {
return "", errEmptyTranslation
}
return translated, nil
}
func docsCodexExecutable() string {
if executable := strings.TrimSpace(os.Getenv(envDocsI18nCodexExecutable)); executable != "" {
return executable
}
return "codex"
}
func buildCodexTranslationPrompt(systemPrompt, message string) string {
return strings.TrimSpace(systemPrompt) + "\n\n" +
"Translate the exact input below. Return only the translated text, with no code fences, no tool calls, no reasoning, and no commentary.\n\n" +
"<openclaw_docs_i18n_input>\n" +
message +
"\n</openclaw_docs_i18n_input>\n"
}
func previewCommandOutput(stdout, stderr string) string {
combined := strings.TrimSpace(strings.Join([]string{stdout, stderr}, "\n"))
if combined == "" {
return "no output"
}
combined = strings.Join(strings.Fields(combined), " ")
const limit = 500
if len(combined) <= limit {
return combined
}
return combined[:limit] + "..."
}
func sleepWithContext(ctx context.Context, delay time.Duration) error {
@ -198,42 +249,11 @@ func sleepWithContext(ctx context.Context, delay time.Duration) error {
}
}
func (t *PiTranslator) Close() {
if t.client != nil {
_ = t.client.Close()
}
}
type promptRunner interface {
Prompt(context.Context, string) (string, error)
Stderr() string
}
func runPrompt(ctx context.Context, client promptRunner, message string) (string, error) {
promptCtx, cancel := context.WithTimeout(ctx, docsI18nPromptTimeout())
defer cancel()
result, err := client.Prompt(promptCtx, message)
if err != nil {
return "", decoratePromptError(err, client.Stderr())
}
return result, nil
}
func decoratePromptError(err error, stderr string) error {
if err == nil {
return nil
}
trimmed := strings.TrimSpace(stderr)
if trimmed == "" {
return err
}
return fmt.Errorf("%w (pi stderr: %s)", err, trimmed)
}
func (t *CodexTranslator) Close() {}
func normalizeThinking(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "low", "high":
case "low", "medium", "high", "xhigh":
return strings.ToLower(strings.TrimSpace(value))
default:
return "high"

View file

@ -10,59 +10,33 @@ import (
"time"
)
type fakePromptRunner struct {
prompt func(context.Context, string) (string, error)
stderr string
}
func (runner fakePromptRunner) Prompt(ctx context.Context, message string) (string, error) {
return runner.prompt(ctx, message)
}
func (runner fakePromptRunner) Stderr() string {
return runner.stderr
}
type fakePiPromptClient struct {
prompt func(context.Context, string) (string, error)
stderr string
closed bool
}
func (client *fakePiPromptClient) Prompt(ctx context.Context, message string) (string, error) {
return client.prompt(ctx, message)
}
func (client *fakePiPromptClient) Stderr() string {
return client.stderr
}
func (client *fakePiPromptClient) Close() error {
client.closed = true
return nil
}
func TestRunPromptAddsTimeout(t *testing.T) {
t.Parallel()
func TestCodexTranslatorAddsTimeout(t *testing.T) {
var deadline time.Time
client := fakePromptRunner{
prompt: func(ctx context.Context, message string) (string, error) {
translator := &CodexTranslator{
systemPrompt: "Translate from English to Chinese.",
thinking: "high",
runPrompt: func(ctx context.Context, req codexPromptRequest) (string, error) {
var ok bool
deadline, ok = ctx.Deadline()
if !ok {
t.Fatal("expected prompt deadline")
}
if message != "Translate me" {
t.Fatalf("unexpected message %q", message)
if req.Message != "Translate me" {
t.Fatalf("unexpected message %q", req.Message)
}
if req.Model != defaultOpenAIModel {
t.Fatalf("unexpected model %q", req.Model)
}
if req.Thinking != "high" {
t.Fatalf("unexpected thinking %q", req.Thinking)
}
return "translated", nil
},
}
got, err := runPrompt(context.Background(), client, "Translate me")
got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN")
if err != nil {
t.Fatalf("runPrompt returned error: %v", err)
t.Fatalf("TranslateRaw returned error: %v", err)
}
if got != "translated" {
t.Fatalf("unexpected translation %q", got)
@ -96,157 +70,43 @@ func TestIsRetryableTranslateErrorRejectsAuthenticationFailures(t *testing.T) {
if isRetryableTranslateError(errors.New(`Authentication failed for "openai"`)) {
t.Fatal("auth failures should not retry")
}
}
func TestIsRetryableTranslateErrorRetriesPiTermination(t *testing.T) {
t.Parallel()
if !isRetryableTranslateError(errors.New("pi error: terminated; stopReason=error; assistant=partial output")) {
t.Fatal("terminated pi session should retry")
if isRetryableTranslateError(errors.New("invalid_api_key")) {
t.Fatal("API key failures should not retry")
}
}
func TestIsRetryableTranslateErrorRetriesTerminatedStopReason(t *testing.T) {
t.Parallel()
if !isRetryableTranslateError(errors.New("pi error: stopReason=terminated; assistant=partial output")) {
t.Fatal("terminated stopReason should retry")
}
}
func TestIsRetryableTranslateErrorRetriesCanceledStopReasons(t *testing.T) {
func TestIsRetryableTranslateErrorRetriesTransientCodexFailures(t *testing.T) {
t.Parallel()
for _, message := range []string{
"pi error: stopReason=cancelled; assistant=partial output",
"pi error: stopReason=canceled; assistant=partial output",
"pi error: stopReason=aborted; assistant=partial output",
"codex exec failed: rate limit 429",
"codex exec failed: stream disconnected",
"codex exec failed: 503 temporarily unavailable",
} {
if !isRetryableTranslateError(errors.New(message)) {
t.Fatalf("expected retryable stop reason for %q", message)
t.Fatalf("expected retryable error for %q", message)
}
}
}
func TestRunPromptIncludesStderr(t *testing.T) {
t.Parallel()
func TestCodexTranslatorRetriesTransientFailure(t *testing.T) {
previousDelay := translateRetryDelay
translateRetryDelay = func(int) time.Duration { return 0 }
defer func() { translateRetryDelay = previousDelay }()
rootErr := errors.New("context deadline exceeded")
client := fakePromptRunner{
prompt: func(context.Context, string) (string, error) {
return "", rootErr
attempts := 0
translator := &CodexTranslator{
systemPrompt: "Translate from English to Chinese.",
thinking: "high",
runPrompt: func(context.Context, codexPromptRequest) (string, error) {
attempts++
if attempts == 1 {
return "", errors.New("codex exec failed: stream disconnected")
}
return "translated", nil
},
stderr: "boom",
}
_, err := runPrompt(context.Background(), client, "Translate me")
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, rootErr) {
t.Fatalf("expected wrapped root error, got %v", err)
}
if !strings.Contains(err.Error(), "pi stderr: boom") {
t.Fatalf("expected stderr in error, got %v", err)
}
}
func TestDecoratePromptErrorLeavesCleanErrorsAlone(t *testing.T) {
t.Parallel()
rootErr := errors.New("plain failure")
got := decoratePromptError(rootErr, " ")
if !errors.Is(got, rootErr) {
t.Fatalf("expected original error, got %v", got)
}
if got.Error() != rootErr.Error() {
t.Fatalf("expected unchanged message, got %v", got)
}
}
func TestResolveDocsPiCommandUsesOverrideEnv(t *testing.T) {
t.Setenv(envDocsPiExecutable, "/tmp/custom-pi")
t.Setenv(envDocsPiArgs, "--mode rpc --foo bar")
command, err := resolveDocsPiCommand(context.Background())
if err != nil {
t.Fatalf("resolveDocsPiCommand returned error: %v", err)
}
if command.Executable != "/tmp/custom-pi" {
t.Fatalf("unexpected executable %q", command.Executable)
}
if strings.Join(command.Args, " ") != "--mode rpc --foo bar" {
t.Fatalf("unexpected args %v", command.Args)
}
}
func TestDocsPiModelRefUsesProviderPrefixWhenProviderFlagIsOmitted(t *testing.T) {
t.Setenv(envDocsI18nProvider, "openai")
t.Setenv(envDocsI18nModel, "gpt-5.5")
t.Setenv(envDocsPiOmitProvider, "1")
if got := docsPiProviderArg(); got != "" {
t.Fatalf("expected empty provider arg when omit-provider is enabled, got %q", got)
}
if got := docsPiModelRef(); got != "openai/gpt-5.5" {
t.Fatalf("expected provider-qualified model ref, got %q", got)
}
}
func TestShouldMaterializePiRuntimeForPiMonoWrapper(t *testing.T) {
t.Parallel()
root := t.TempDir()
sourceDir := filepath.Join(root, "Projects", "pi-mono", "packages", "coding-agent", "dist")
binDir := filepath.Join(root, "bin")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatalf("mkdir source dir: %v", err)
}
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin dir: %v", err)
}
target := filepath.Join(sourceDir, "cli.js")
if err := os.WriteFile(target, []byte("console.log('pi');\n"), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}
link := filepath.Join(binDir, "pi")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if !shouldMaterializePiRuntime(link) {
t.Fatal("expected pi-mono wrapper to materialize runtime")
}
}
func TestPiTranslatorRestartsClientAfterPiTermination(t *testing.T) {
t.Parallel()
clients := []*fakePiPromptClient{}
factoryCalls := 0
factory := func(context.Context) (docsPiPromptClient, error) {
factoryCalls++
index := factoryCalls
client := &fakePiPromptClient{
prompt: func(context.Context, string) (string, error) {
if index == 1 {
return "", errors.New("pi error: terminated; stopReason=error; assistant=partial output")
}
return "translated", nil
},
}
clients = append(clients, client)
return client, nil
}
client, err := factory(context.Background())
if err != nil {
t.Fatalf("factory failed: %v", err)
}
translator := &PiTranslator{client: client, clientFactory: factory}
got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN")
if err != nil {
t.Fatalf("TranslateRaw returned error: %v", err)
@ -254,107 +114,71 @@ func TestPiTranslatorRestartsClientAfterPiTermination(t *testing.T) {
if got != "translated" {
t.Fatalf("unexpected translation %q", got)
}
if factoryCalls != 2 {
t.Fatalf("expected factory to run twice, got %d", factoryCalls)
}
if len(clients) != 2 {
t.Fatalf("expected 2 clients, got %d", len(clients))
}
if !clients[0].closed {
t.Fatal("expected first client to close before retry")
}
if clients[1].closed {
t.Fatal("expected replacement client to remain open")
if attempts != 2 {
t.Fatalf("expected 2 attempts, got %d", attempts)
}
}
func TestPiTranslatorRestartsClientAfterTerminatedStopReason(t *testing.T) {
t.Parallel()
func TestBuildCodexTranslationPromptIncludesGuardrailsAndInput(t *testing.T) {
prompt := buildCodexTranslationPrompt("System prompt.", "Hello\nworld")
clients := []*fakePiPromptClient{}
factoryCalls := 0
factory := func(context.Context) (docsPiPromptClient, error) {
factoryCalls++
index := factoryCalls
client := &fakePiPromptClient{
prompt: func(context.Context, string) (string, error) {
if index == 1 {
return "", errors.New("pi error: stopReason=terminated; assistant=partial output")
}
return "translated", nil
},
for _, want := range []string{
"System prompt.",
"Return only the translated text",
"<openclaw_docs_i18n_input>",
"Hello\nworld",
"</openclaw_docs_i18n_input>",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected %q in prompt:\n%s", want, prompt)
}
clients = append(clients, client)
return client, nil
}
client, err := factory(context.Background())
if err != nil {
t.Fatalf("factory failed: %v", err)
}
translator := &PiTranslator{client: client, clientFactory: factory}
got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN")
if err != nil {
t.Fatalf("TranslateRaw returned error: %v", err)
}
if got != "translated" {
t.Fatalf("unexpected translation %q", got)
}
if factoryCalls != 2 {
t.Fatalf("expected factory to run twice, got %d", factoryCalls)
}
if len(clients) != 2 {
t.Fatalf("expected 2 clients, got %d", len(clients))
}
if !clients[0].closed {
t.Fatal("expected first client to close before retry")
}
if clients[1].closed {
t.Fatal("expected replacement client to remain open")
}
}
func TestPiTranslatorRestartsClientAfterCanceledStopReason(t *testing.T) {
t.Parallel()
clients := []*fakePiPromptClient{}
factoryCalls := 0
factory := func(context.Context) (docsPiPromptClient, error) {
factoryCalls++
index := factoryCalls
client := &fakePiPromptClient{
prompt: func(context.Context, string) (string, error) {
if index == 1 {
return "", errors.New("pi error: stopReason=aborted; assistant=partial output")
}
return "translated", nil
},
}
clients = append(clients, client)
return client, nil
func TestRunCodexExecPromptUsesOutputLastMessage(t *testing.T) {
dir := t.TempDir()
fakeCodex := filepath.Join(dir, "codex")
if err := os.WriteFile(fakeCodex, []byte(`#!/bin/sh
set -eu
out=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--output-last-message" ]; then
shift
out="$1"
fi
shift || true
done
cat >/dev/null
printf 'translated from codex\n' > "$out"
`), 0o755); err != nil {
t.Fatalf("write fake codex: %v", err)
}
t.Setenv(envDocsI18nCodexExecutable, fakeCodex)
client, err := factory(context.Background())
got, err := runCodexExecPrompt(context.Background(), codexPromptRequest{
SystemPrompt: "Translate.",
Message: "Hello",
Model: "gpt-5.5",
Thinking: "high",
})
if err != nil {
t.Fatalf("factory failed: %v", err)
t.Fatalf("runCodexExecPrompt returned error: %v", err)
}
translator := &PiTranslator{client: client, clientFactory: factory}
got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN")
if err != nil {
t.Fatalf("TranslateRaw returned error: %v", err)
}
if got != "translated" {
t.Fatalf("unexpected translation %q", got)
}
if factoryCalls != 2 {
t.Fatalf("expected factory to run twice, got %d", factoryCalls)
}
if !clients[0].closed {
t.Fatal("expected first client to close before retry")
}
if clients[1].closed {
t.Fatal("expected replacement client to remain open")
if got != "translated from codex" {
t.Fatalf("unexpected output %q", got)
}
}
func TestPreviewCommandOutputFlattensAndTruncates(t *testing.T) {
input := "line one\n\nline two\tline three " + strings.Repeat("x", 600)
preview := previewCommandOutput(input, "")
if strings.Contains(preview, "\n") {
t.Fatalf("expected flattened whitespace, got %q", preview)
}
if !strings.HasPrefix(preview, "line one line two line three ") {
t.Fatalf("unexpected preview prefix: %q", preview)
}
if !strings.HasSuffix(preview, "...") {
t.Fatalf("expected truncation suffix, got %q", preview)
}
}

View file

@ -6,27 +6,29 @@ import (
"fmt"
"io"
"os"
"regexp"
"strings"
)
const (
workflowVersion = 15
docsI18nEngineName = "pi"
docsI18nEngineName = "codex"
envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER"
envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL"
defaultOpenAIModel = "gpt-5.5"
defaultAnthropicModel = "claude-opus-4-6"
defaultFallbackProvider = "openai"
defaultFallbackModelName = defaultOpenAIModel
)
var translationTranscriptArtifactRE = regexp.MustCompile(`(?i)(?:\b(?:analysis|commentary|final|assistant|user)\s+to\s*=\s*(?:functions\.[a-z0-9_-]+|[a-z_]+)|\bto\s*=\s*(?:functions\.[a-z0-9_-]+|analysis|commentary|final)\b|\bfunctions\.[a-z0-9_-]+\b|/home/runner/work/|\.agents/skills/|\bforce_parallel\s*:|\bcode\s+omitted\b|\bomitted\s+reasoning\b|全民彩票|娱乐平台开户|娱乐平台|皇平台|彩票平台|一本道|毛片|高清视频免费|不卡免费播放)`)
func cacheNamespace() string {
return fmt.Sprintf(
"wf=%d|engine=%s|provider=%s|model=%s",
workflowVersion,
docsI18nEngineName,
docsPiProvider(),
docsPiModel(),
docsI18nProvider(),
docsI18nModel(),
)
}
@ -51,89 +53,18 @@ func normalizeText(text string) string {
return strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
}
func docsPiProvider() string {
if value := strings.TrimSpace(os.Getenv(envDocsI18nProvider)); value != "" {
func docsI18nProvider() string {
if value := strings.TrimSpace(os.Getenv(envDocsI18nProvider)); strings.EqualFold(value, "openai") {
return value
}
if strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "" {
return "openai"
}
if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) != "" {
return "anthropic"
}
return defaultFallbackProvider
}
func docsPiModel() string {
func docsI18nModel() string {
if value := strings.TrimSpace(os.Getenv(envDocsI18nModel)); value != "" {
return value
}
switch docsPiProvider() {
case "anthropic":
return defaultAnthropicModel
case "openai":
return defaultOpenAIModel
default:
return defaultFallbackModelName
}
}
func docsPiProviderArg() string {
provider := docsPiProvider()
if provider == "" {
return ""
}
if docsPiOmitProvider() {
return ""
}
if strings.Contains(docsPiModel(), "/") {
return ""
}
if hasDocsPiAgentDirOverride() {
return ""
}
if !isBuiltInPiProvider(provider) {
return ""
}
return provider
}
func docsPiModelRef() string {
model := docsPiModel()
if model == "" {
return ""
}
if strings.Contains(model, "/") {
return model
}
if docsPiOmitProvider() {
provider := docsPiProvider()
if provider == "" {
return model
}
return provider + "/" + model
}
if docsPiProviderArg() != "" {
return model
}
provider := docsPiProvider()
if provider == "" {
return model
}
return provider + "/" + model
}
func isBuiltInPiProvider(provider string) bool {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "anthropic", "openai":
return true
default:
return false
}
}
func hasDocsPiAgentDirOverride() bool {
return strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")) != ""
return defaultFallbackModelName
}
func segmentID(relPath, textHash string) string {
@ -168,6 +99,21 @@ func isWhitespace(b byte) bool {
}
}
func validateNoTranslationTranscriptArtifacts(source, translated string) error {
sourceLower := strings.ToLower(source)
for _, match := range translationTranscriptArtifactRE.FindAllString(translated, -1) {
match = strings.TrimSpace(match)
if match == "" {
continue
}
if strings.Contains(sourceLower, strings.ToLower(match)) {
continue
}
return fmt.Errorf("agent transcript artifact leaked into translation: %q", match)
}
return nil
}
func fatal(err error) {
if err == nil {
return

View file

@ -2,49 +2,27 @@ package main
import "testing"
func TestDocsPiProviderPrefersExplicitOverride(t *testing.T) {
func TestDocsI18nProviderUsesOpenAI(t *testing.T) {
t.Setenv(envDocsI18nProvider, "anthropic")
t.Setenv("OPENAI_API_KEY", "openai-key")
t.Setenv("ANTHROPIC_API_KEY", "anthropic-key")
if got := docsPiProvider(); got != "anthropic" {
t.Fatalf("expected anthropic override, got %q", got)
if got := docsI18nProvider(); got != "openai" {
t.Fatalf("expected OpenAI provider, got %q", got)
}
}
func TestDocsPiProviderPrefersOpenAIEnvWhenAvailable(t *testing.T) {
t.Setenv(envDocsI18nProvider, "")
t.Setenv("OPENAI_API_KEY", "openai-key")
t.Setenv("ANTHROPIC_API_KEY", "anthropic-key")
if got := docsPiProvider(); got != "openai" {
t.Fatalf("expected openai provider, got %q", got)
}
}
func TestDocsPiModelUsesProviderDefault(t *testing.T) {
t.Setenv(envDocsI18nProvider, "anthropic")
func TestDocsI18nModelKeepsOpenAIDefaultAtGPT55(t *testing.T) {
t.Setenv(envDocsI18nModel, "")
if got := docsPiModel(); got != defaultAnthropicModel {
t.Fatalf("expected anthropic default model, got %q", got)
}
}
func TestDocsPiModelKeepsOpenAIDefaultAtGPT54(t *testing.T) {
t.Setenv(envDocsI18nProvider, "openai")
t.Setenv(envDocsI18nModel, "")
if got := docsPiModel(); got != defaultOpenAIModel {
if got := docsI18nModel(); got != defaultOpenAIModel {
t.Fatalf("expected OpenAI default model %q, got %q", defaultOpenAIModel, got)
}
}
func TestDocsPiModelPrefersExplicitOverride(t *testing.T) {
t.Setenv(envDocsI18nProvider, "openai")
t.Setenv(envDocsI18nModel, "gpt-5.2")
func TestDocsI18nModelPrefersExplicitOverride(t *testing.T) {
t.Setenv(envDocsI18nModel, "__test_model_override__")
if got := docsPiModel(); got != "gpt-5.2" {
if got := docsI18nModel(); got != "__test_model_override__" {
t.Fatalf("expected explicit model override, got %q", got)
}
}