You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
182 lines
5.7 KiB
182 lines
5.7 KiB
package translate
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/aliyun/alibaba-cloud-sdk-go/sdk"
|
|
"github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials"
|
|
"github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests"
|
|
)
|
|
|
|
// Aliyun Machine Translation (alimt) TranslateGeneral
|
|
// Docs: https://help.aliyun.com/document_detail/158244.html
|
|
|
|
type AliTranslate struct {
|
|
options Options
|
|
client *sdk.Client
|
|
}
|
|
|
|
func newSys(options Options) (sys *AliTranslate, err error) {
|
|
if options.AccessKeyId == "" || options.AccessKeySecret == "" {
|
|
return nil, fmt.Errorf("aliyun translate: AccessKeyId/AccessKeySecret required")
|
|
}
|
|
cfg := sdk.NewConfig()
|
|
cfg.HttpTransport = &http.Transport{IdleConnTimeout: 10 * time.Second}
|
|
cfg.Timeout = 15 * time.Second
|
|
cred := credentials.NewAccessKeyCredential(options.AccessKeyId, options.AccessKeySecret)
|
|
cli, err := sdk.NewClientWithOptions(options.Region, cfg, cred)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new sdk client: %w", err)
|
|
}
|
|
sys = &AliTranslate{
|
|
options: options,
|
|
client: cli,
|
|
}
|
|
return
|
|
}
|
|
|
|
// translateGeneralResponse Aliyun MT TranslateGeneral 响应
|
|
type translateGeneralResponse struct {
|
|
RequestId string `json:"RequestId"`
|
|
Code string `json:"Code"`
|
|
Message string `json:"Message,omitempty"`
|
|
Data struct {
|
|
WordCount string `json:"WordCount"`
|
|
Translated string `json:"Translated"`
|
|
} `json:"Data"`
|
|
}
|
|
|
|
func (this *AliTranslate) translateOne(from, to, text string) (string, error) {
|
|
req := requests.NewCommonRequest()
|
|
req.Method = "POST"
|
|
req.Scheme = "https"
|
|
req.Domain = fmt.Sprintf("mt.%s.aliyuncs.com", this.options.Region)
|
|
req.ApiName = "TranslateGeneral"
|
|
req.Version = "2018-10-12"
|
|
req.QueryParams["FormatType"] = this.options.FormatType
|
|
req.QueryParams["SourceLanguage"] = from
|
|
req.QueryParams["TargetLanguage"] = to
|
|
req.QueryParams["SourceText"] = text
|
|
req.QueryParams["Scene"] = this.options.Scene
|
|
|
|
resp, err := this.client.ProcessCommonRequest(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("translate request: %w", err)
|
|
}
|
|
body := resp.GetHttpContentString()
|
|
var out translateGeneralResponse
|
|
if err = json.Unmarshal([]byte(body), &out); err != nil {
|
|
return "", fmt.Errorf("unmarshal response: %w body=%s", err, body)
|
|
}
|
|
if out.Code != "" && out.Code != "200" {
|
|
return "", fmt.Errorf("translate failed: code=%s msg=%s", out.Code, out.Message)
|
|
}
|
|
return out.Data.Translated, nil
|
|
}
|
|
|
|
// isThrottling 判断是否为阿里 MT 的用户级流控错误(ErrorCode: Throttling.User)。
|
|
// 这类错误是暂时的,退避后重试通常能成功。
|
|
func isThrottling(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
s := err.Error()
|
|
return strings.Contains(s, "Throttling") || strings.Contains(s, "flow control")
|
|
}
|
|
|
|
// translateRetryBackoff 限流重试的退避间隔。阿里返回的 X-Acs-Retry-After 常达 80~170s,
|
|
// 全额等待会把一篇会议的翻译拖到十几分钟;这里用较短的递增退避,配合「失败降级为原文」兜底。
|
|
var translateRetryBackoff = []time.Duration{500 * time.Millisecond, 2 * time.Second, 5 * time.Second}
|
|
|
|
// translateOneWithRetry 单句翻译;遇到流控按 translateRetryBackoff 退避重试。
|
|
// 非流控错误(如参数错误)不重试,直接返回。
|
|
func (this *AliTranslate) translateOneWithRetry(ctx context.Context, from, to, text string) (string, error) {
|
|
var lastErr error
|
|
for attempt := 0; ; attempt++ {
|
|
r, e := this.translateOne(from, to, text)
|
|
if e == nil {
|
|
return r, nil
|
|
}
|
|
lastErr = e
|
|
if !isThrottling(e) || attempt >= len(translateRetryBackoff) {
|
|
return "", lastErr
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-time.After(translateRetryBackoff[attempt]):
|
|
}
|
|
}
|
|
}
|
|
|
|
// Translate 批量翻译,按 Concurrency 并发执行 TranslateGeneral。
|
|
// from / to 为阿里 MT 语言码:zh / en / ja / ko / ...(保持调用方传入的标准)
|
|
//
|
|
// 容错语义(调用方需注意):
|
|
// - 单句重试后仍失败,**用原文占位**,results 始终与 texts 等长;
|
|
// - 部分失败:返回 (results, err) —— results 可用,err 描述失败句数,调用方记日志后应继续;
|
|
// - 全部失败:返回 (nil, err) —— 视为硬失败。
|
|
//
|
|
// 这样一次限流不会把整篇会议的译文清空(进而让 AI 总结拿到空内容、报 content field is required)。
|
|
func (this *AliTranslate) Translate(ctx context.Context, from, to string, texts []string) (results []string, err error) {
|
|
if len(texts) == 0 {
|
|
return []string{}, nil
|
|
}
|
|
results = make([]string, len(texts))
|
|
errs := make([]error, len(texts))
|
|
|
|
sem := make(chan struct{}, this.options.Concurrency)
|
|
var wg sync.WaitGroup
|
|
for i, t := range texts {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(idx int, text string) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
// 空串/纯空白直接返回原值,阿里 MT 对空文本会报 code=10004
|
|
if strings.TrimSpace(text) == "" {
|
|
results[idx] = text
|
|
return
|
|
}
|
|
r, e := this.translateOneWithRetry(ctx, from, to, text)
|
|
if e != nil {
|
|
results[idx] = text // 降级:保留原文,别让整批作废
|
|
errs[idx] = e
|
|
return
|
|
}
|
|
results[idx] = r
|
|
}(i, t)
|
|
}
|
|
wg.Wait()
|
|
|
|
failed, firstIdx := 0, -1
|
|
var firstErr error
|
|
for i, e := range errs {
|
|
if e != nil {
|
|
failed++
|
|
if firstErr == nil {
|
|
firstErr, firstIdx = e, i
|
|
}
|
|
}
|
|
}
|
|
switch {
|
|
case failed == 0:
|
|
return results, nil
|
|
case failed == len(texts):
|
|
return nil, fmt.Errorf("translate all %d failed, first index=%d: %w", failed, firstIdx, firstErr)
|
|
default:
|
|
return results, fmt.Errorf("translate partial failure: %d/%d failed (已用原文占位), first index=%d: %w",
|
|
failed, len(texts), firstIdx, firstErr)
|
|
}
|
|
}
|
|
|