Files
openclaw/scripts/docs-i18n/translator.go
2026-02-08 10:18:04 -08:00

248 lines
6.1 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
pi "github.com/joshp123/pi-golang"
)
const (
translateMaxAttempts = 3
translateBaseDelay = 15 * time.Second
)
var errEmptyTranslation = errors.New("empty translation")
type PiTranslator struct {
client *pi.OneShotClient
}
func NewPiTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*PiTranslator, error) {
options := pi.DefaultOneShotOptions()
options.AppName = "openclaw-docs-i18n"
options.WorkDir = "/tmp"
options.Mode = pi.ModeDragons
options.Dragons = pi.DragonsOptions{
Provider: "anthropic",
Model: modelVersion,
Thinking: normalizeThinking(thinking),
}
options.SystemPrompt = translationPrompt(srcLang, tgtLang, glossary)
client, err := pi.StartOneShot(options)
if err != nil {
return nil, err
}
return &PiTranslator{client: client}, nil
}
func (t *PiTranslator) 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) {
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")
}
prefix, core, suffix := splitWhitespace(text)
if core == "" {
return text, nil
}
translated, err := t.translateWithRetry(ctx, func(ctx context.Context) (string, error) {
return run(ctx, core)
})
if err != nil {
return "", err
}
return prefix + translated + suffix, nil
}
func (t *PiTranslator) 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)
if err == nil {
return translated, nil
}
if !isRetryableTranslateError(err) {
return "", err
}
lastErr = err
if attempt+1 < translateMaxAttempts {
delay := translateBaseDelay * time.Duration(attempt+1)
if err := sleepWithContext(ctx, delay); err != nil {
return "", err
}
}
}
return "", lastErr
}
func (t *PiTranslator) 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)
if err != nil {
return "", err
}
translated := strings.TrimSpace(resText)
if translated == "" {
return "", errEmptyTranslation
}
if err := validatePlaceholders(translated, placeholders); err != nil {
return "", err
}
return unmaskMarkdown(translated, placeholders, mapping), nil
}
func (t *PiTranslator) translateRaw(ctx context.Context, core string) (string, error) {
resText, err := runPrompt(ctx, t.client, core)
if err != nil {
return "", err
}
translated := strings.TrimSpace(resText)
if translated == "" {
return "", errEmptyTranslation
}
return translated, nil
}
func isRetryableTranslateError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errEmptyTranslation) {
return true
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "placeholder missing") || strings.Contains(message, "rate limit") || strings.Contains(message, "429")
}
func sleepWithContext(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (t *PiTranslator) Close() {
if t.client != nil {
_ = t.client.Close()
}
}
type agentEndPayload struct {
Messages []agentMessage `json:"messages"`
}
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 runPrompt(ctx context.Context, client *pi.OneShotClient, message string) (string, error) {
events, cancel := client.Subscribe(256)
defer cancel()
if err := client.Prompt(ctx, message); err != nil {
return "", err
}
for {
select {
case <-ctx.Done():
return "", ctx.Err()
case event, ok := <-events:
if !ok {
return "", errors.New("event stream closed")
}
if event.Type == "agent_end" {
return extractTranslationResult(event.Raw)
}
}
}
}
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 != "" || strings.EqualFold(message.StopReason, "error") {
msg := strings.TrimSpace(message.ErrorMessage)
if msg == "" {
msg = "unknown error"
}
return "", fmt.Errorf("pi error: %s", msg)
}
text, err := extractContentText(message.Content)
if err != nil {
return "", err
}
return text, nil
}
return "", errors.New("assistant message not found")
}
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 normalizeThinking(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "low", "high":
return strings.ToLower(strings.TrimSpace(value))
default:
return "high"
}
}