diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index 678bb17..b65999f 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -404,6 +404,56 @@ paths: description: Missing or invalid admin token. "503": description: Rust bat translation backend is unavailable. + /admin/translation/memory/summary: + get: + summary: Read Rust-owned Translation Memory summary + parameters: + - name: translation_memory_path + in: query + schema: + type: string + responses: + "200": + description: Translation Memory availability and candidate/trusted counts. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat Translation Memory backend is unavailable. + /admin/translation/memory/query: + get: + summary: Query Rust-owned Translation Memory records + parameters: + - name: source_text + in: query + required: true + schema: + type: string + - name: source_context + in: query + description: JSON object whose values are strings. + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: int64 + minimum: 1 + maximum: 1000 + default: 100 + - name: translation_memory_path + in: query + schema: + type: string + responses: + "200": + description: Translation Memory matches with reuse decision and provenance. + "400": + description: Missing source text or invalid context/limit. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat Translation Memory backend is unavailable. /admin/translation/status: get: summary: Read Rust-owned localized release status @@ -423,7 +473,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, localized-publish, localized-rollback] requestBody: required: false content: @@ -514,6 +564,14 @@ paths: minimum: 1 worker_id: type: string + translation_memory_path: + type: string + record_id: + type: string + reviewer: + type: string + reason: + type: string translation_file: type: string from_worker: diff --git a/internal/api/admin.go b/internal/api/admin.go index e92c315..23e52b8 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -54,6 +54,8 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/parse/errors", "/admin/translation/tasks", "/admin/translation/handoff", + "/admin/translation/memory/summary", + "/admin/translation/memory/query", "/admin/translation/status", }, Controls: []string{ @@ -72,6 +74,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/control/translation-task-update", "/admin/control/translation-worker-run", "/admin/control/translation-proofread", + "/admin/control/translation-memory-confirm", "/admin/control/localized-publish", "/admin/control/localized-rollback", }, @@ -117,6 +120,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { s.handleAdminTranslationProofread(w, r) return } + if action == "translation-memory-confirm" { + s.handleAdminTranslationMemoryConfirm(w, r) + return + } if action == "localized-publish" { s.handleAdminLocalizedPublish(w, r) return @@ -274,6 +281,34 @@ func (s *Server) handleAdminTranslationProofread(w http.ResponseWriter, r *http. }) } +func (s *Server) handleAdminTranslationMemoryConfirm(w http.ResponseWriter, r *http.Request) { + backend, ok := s.backend.(TranslationMemoryBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "translation_memory_backend_unavailable", "Rust bat Translation Memory backend is unavailable") + return + } + var params backendrpc.TranslationMemoryConfirmParams + if !decodeAdminTranslationJSON(w, r, ¶ms) { + return + } + if err := validateTranslationMemoryConfirmParams(params); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_memory_params", err.Error()) + return + } + result, err := backend.TranslationMemoryConfirm(r.Context(), params) + if err != nil { + s.writeControlBackendError(w, "translation-memory-confirm", err) + return + } + writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ + Service: "bat-api", + Action: "translation-memory-confirm", + RPCMethod: "translation.memory.confirm", + Status: "accepted", + Result: result, + }) +} + func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) { backend, ok := s.backend.(LocalizedBackend) if !ok || backend == nil { @@ -656,6 +691,66 @@ func (s *Server) handleAdminTranslationHandoff(w http.ResponseWriter, r *http.Re writeNoStoreJSON(w, http.StatusOK, result) } +func (s *Server) handleAdminTranslationMemorySummary(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(TranslationMemoryBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "translation_memory_backend_unavailable", "Rust bat Translation Memory backend is unavailable") + return + } + params := backendrpc.TranslationMemorySummaryParams{ + TranslationMemoryPath: firstTrimmedQuery(r.URL.Query(), "translation_memory_path", "tm_path"), + } + result, err := backend.TranslationMemorySummary(r.Context(), params) + if err != nil { + s.writeControlBackendError(w, "translation-memory-summary", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleAdminTranslationMemoryQuery(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(TranslationMemoryBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "translation_memory_backend_unavailable", "Rust bat Translation Memory backend is unavailable") + return + } + params, err := translationMemoryQueryParams(r) + if err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_memory_query", err.Error()) + return + } + result, err := backend.TranslationMemoryQuery(r.Context(), params) + if err != nil { + s.writeControlBackendError(w, "translation-memory-query", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + func (s *Server) handleAdminSchedules(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") @@ -747,6 +842,38 @@ func translationTaskListParams(r *http.Request) (backendrpc.TranslationTaskListP return params, nil } +func translationMemoryQueryParams(r *http.Request) (backendrpc.TranslationMemoryQueryParams, error) { + query := r.URL.Query() + sourceText := strings.TrimSpace(query.Get("source_text")) + if sourceText == "" { + return backendrpc.TranslationMemoryQueryParams{}, errors.New("source_text is required") + } + params := backendrpc.TranslationMemoryQueryParams{ + TranslationMemoryPath: firstTrimmedQuery(query, "translation_memory_path", "tm_path"), + SourceText: sourceText, + } + if raw := firstTrimmedQuery(query, "source_context", "context"); raw != "" { + var context backendrpc.TranslationMemoryContext + if raw != "null" { + if err := json.Unmarshal([]byte(raw), &context); err != nil { + return backendrpc.TranslationMemoryQueryParams{}, errors.New("source_context must be a JSON object with string values") + } + if context == nil { + return backendrpc.TranslationMemoryQueryParams{}, errors.New("source_context must be a JSON object") + } + params.SourceContext = context + } + } + if raw := strings.TrimSpace(query.Get("limit")); raw != "" { + limit, err := strconv.ParseUint(raw, 10, 64) + if err != nil || limit == 0 || limit > 1000 { + return backendrpc.TranslationMemoryQueryParams{}, errors.New("limit must be in 1..=1000") + } + params.Limit = &limit + } + return params, nil +} + func parseTextUnitQueryParams(r *http.Request) (backendrpc.TextUnitQueryParams, error) { query := r.URL.Query() params := backendrpc.TextUnitQueryParams{ @@ -938,6 +1065,13 @@ func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunPa return nil } +func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryConfirmParams) error { + if strings.TrimSpace(params.RecordID) == "" || strings.TrimSpace(params.Reviewer) == "" { + return errors.New("Translation Memory confirm requires record_id and reviewer") + } + return nil +} + func validateLocalizedPublishParams(params backendrpc.LocalizedPublishParams) error { hasFile := strings.TrimSpace(params.TranslationFile) != "" if hasFile == params.FromWorker { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 97550c7..cc881b7 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -629,13 +629,16 @@ func (f *fakeBackend) TaskCancel(ctx context.Context, taskID string) (*backendrp type controlBackend struct { *fakeBackend - calls []string - parseTextUnitQueries []backendrpc.TextUnitQueryParams - parseErrorQueries []backendrpc.TextUnitQueryParams - translationTaskUpdates []backendrpc.TranslationTaskUpdateParams - translationTaskListParams []backendrpc.TranslationTaskListParams - localizedPublishParams []backendrpc.LocalizedPublishParams - localizedRollbackParams []backendrpc.LocalizedRollbackParams + calls []string + parseTextUnitQueries []backendrpc.TextUnitQueryParams + parseErrorQueries []backendrpc.TextUnitQueryParams + translationTaskUpdates []backendrpc.TranslationTaskUpdateParams + translationTaskListParams []backendrpc.TranslationTaskListParams + translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams + translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams + translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams + localizedPublishParams []backendrpc.LocalizedPublishParams + localizedRollbackParams []backendrpc.LocalizedRollbackParams } func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) { @@ -777,6 +780,51 @@ func (b *controlBackend) TranslationProofread(ctx context.Context) (json.RawMess return json.RawMessage(`{"translation_workflow_status":"manual_proofreading"}`), nil } +func (b *controlBackend) TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) { + b.calls = append(b.calls, "translation.memory.summary") + b.translationMemorySummaryParams = append(b.translationMemorySummaryParams, params) + schemaVersion := uint64(1) + return &backendrpc.TranslationMemorySummaryReport{ + Available: true, + Path: params.TranslationMemoryPath, + SchemaVersion: &schemaVersion, + Summary: &backendrpc.TranslationMemorySummary{ + SchemaVersion: schemaVersion, + RecordCount: 2, + TrustedCount: 1, + CandidateCount: 1, + SupersededCount: 0, + RejectedCount: 0, + }, + }, nil +} + +func (b *controlBackend) TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error) { + b.calls = append(b.calls, "translation.memory.query") + b.translationMemoryQueryParams = append(b.translationMemoryQueryParams, params) + return &backendrpc.TranslationMemoryQueryReport{ + Available: true, + Path: params.TranslationMemoryPath, + SourceText: params.SourceText, + SourceContext: params.SourceContext, + Matches: []backendrpc.TranslationMemoryMatch{}, + }, nil +} + +func (b *controlBackend) TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error) { + b.calls = append(b.calls, "translation.memory.confirm") + b.translationMemoryConfirmParams = append(b.translationMemoryConfirmParams, params) + return &backendrpc.TranslationMemoryConfirmReport{ + Available: true, + Path: params.TranslationMemoryPath, + Entry: backendrpc.TranslationMemoryEntry{ + RecordID: params.RecordID, + TranslationSourceKind: "provider", + TrustStatus: "trusted", + }, + }, nil +} + func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { b.calls = append(b.calls, "localized.status") return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil @@ -960,6 +1008,42 @@ func TestAdminTranslationQueryEndpointsProxyAuthenticatedRequests(t *testing.T) t.Fatalf("calls=%v", backend.calls) } + request = httptest.NewRequest(http.MethodGet, "/admin/translation/memory/summary?translation_memory_path=%2Fvar%2Flib%2Fbat%2Ftranslation-memory.sqlite", nil) + request.Header.Set("Authorization", "Bearer translation-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("TM summary status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), `"trusted_count":1`) || + len(backend.translationMemorySummaryParams) != 1 || + backend.translationMemorySummaryParams[0].TranslationMemoryPath != "/var/lib/bat/translation-memory.sqlite" { + t.Fatalf("TM summary body=%s params=%#v", recorder.Body.String(), backend.translationMemorySummaryParams) + } + + request = httptest.NewRequest(http.MethodGet, "/admin/translation/memory/query?source_text=Hello&source_context=%7B%22destination%22%3A%22Bundle%2Fdialogue.bundle%22%7D&limit=25", nil) + request.Header.Set("Authorization", "Bearer translation-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("TM query status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if len(backend.translationMemoryQueryParams) != 1 || + backend.translationMemoryQueryParams[0].SourceText != "Hello" || + backend.translationMemoryQueryParams[0].SourceContext["destination"] != "Bundle/dialogue.bundle" || + backend.translationMemoryQueryParams[0].Limit == nil || + *backend.translationMemoryQueryParams[0].Limit != 25 { + t.Fatalf("TM query body=%s params=%#v", recorder.Body.String(), backend.translationMemoryQueryParams) + } + + request = httptest.NewRequest(http.MethodGet, "/admin/translation/memory/query", nil) + request.Header.Set("Authorization", "Bearer translation-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("missing TM query source status=%d body=%s", recorder.Code, recorder.Body.String()) + } + request = httptest.NewRequest(http.MethodGet, "/admin/translation/tasks?limit=0", nil) request.Header.Set("Authorization", "Bearer translation-token") recorder = httptest.NewRecorder() @@ -1502,7 +1586,9 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) { !strings.Contains(rr.Body.String(), "/admin/tasks") || !strings.Contains(rr.Body.String(), "/admin/parse/text-units") || !strings.Contains(rr.Body.String(), "translation_results") || - !strings.Contains(rr.Body.String(), "task-cancel") { + !strings.Contains(rr.Body.String(), "task-cancel") || + !strings.Contains(rr.Body.String(), "/admin/translation/memory/query") || + !strings.Contains(rr.Body.String(), "translation-memory-confirm") { t.Fatalf("openapi missing dashboard/task admin routes") } @@ -1524,6 +1610,8 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) { links := strings.Join(admin.Links, "\n") if !strings.Contains(links, "/admin/translation/tasks") || !strings.Contains(links, "/admin/translation/handoff") || + !strings.Contains(links, "/admin/translation/memory/summary") || + !strings.Contains(links, "/admin/translation/memory/query") || !strings.Contains(links, "/admin/dashboard/") || !strings.Contains(links, "/admin/parse/text-units") || !strings.Contains(links, "/admin/tasks/logs") { @@ -1729,6 +1817,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { {name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"}, {name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"}, {name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"}, + {name: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"}, {name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"}, {name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"}, } @@ -1760,6 +1849,11 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" { t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates) } + if len(backend.translationMemoryConfirmParams) != 1 || + backend.translationMemoryConfirmParams[0].RecordID != "tm-record-1" || + backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" { + t.Fatalf("TM confirm params=%#v", backend.translationMemoryConfirmParams) + } request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`)) request.Header.Set("Authorization", "Bearer control-token") @@ -1785,6 +1879,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { t.Fatalf("invalid translation worker status=%d body=%s", recorder.Code, recorder.Body.String()) } + request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-memory-confirm", strings.NewReader(`{"record_id":""}`)) + request.Header.Set("Authorization", "Bearer control-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid TM confirm status=%d body=%s", recorder.Code, recorder.Body.String()) + } + request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"translation_file":"/tmp/workbench.json"}`)) request.Header.Set("Authorization", "Bearer control-token") recorder = httptest.NewRecorder() diff --git a/internal/api/contract_fixture_test.go b/internal/api/contract_fixture_test.go index 497bea4..2a1adf6 100644 --- a/internal/api/contract_fixture_test.go +++ b/internal/api/contract_fixture_test.go @@ -110,3 +110,91 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) { t.Fatalf("legacy game_main_config field unexpectedly present: %s", snapshot.LegacyGameMainConfig) } } + +func TestTranslationMemoryRustContractMirror(t *testing.T) { + raw := []byte(`{ + "available": true, + "path": "${TM_PATH}", + "source_text": "${SOURCE_TEXT}", + "source_context": { + "destination": "${DESTINATION}", + "field_path": "${FIELD_PATH}" + }, + "matches": [{ + "entry": { + "record_id": "${RECORD_ID}", + "source_text": "${SOURCE_TEXT}", + "source_hash": "${SOURCE_HASH}", + "normalized_source_text": "${NORMALIZED_SOURCE_TEXT}", + "source_context": { + "destination": "${DESTINATION}", + "field_path": "${FIELD_PATH}" + }, + "source_context_hash": "${SOURCE_CONTEXT_HASH}", + "translated_text": "${TRANSLATED_TEXT}", + "translation_source_kind": "provider", + "trust_status": "trusted", + "official_release_id": "${OFFICIAL_RELEASE_ID}", + "source_trace": { + "official_release_id": "${OFFICIAL_RELEASE_ID}", + "unit_id": "${UNIT_ID}", + "task_id": "${TASK_ID}", + "destination": "${DESTINATION}", + "archive_entry": "${ARCHIVE_ENTRY}", + "serialized_file": "${SERIALIZED_FILE}", + "path_id": 42, + "class_id": 114, + "field_path": "${FIELD_PATH}", + "format": "json", + "asset_name": "${ASSET_NAME}", + "text_source_kind": "text_asset" + }, + "provider": "${PROVIDER}", + "provider_run_id": "${PROVIDER_RUN_ID}", + "created_unix_seconds": 100, + "updated_unix_seconds": 200, + "trusted_unix_seconds": 200, + "trusted_by": "${REVIEWER}", + "trusted_reason": "${TRUST_REASON}" + }, + "match_kind": "strong_exact", + "can_auto_reuse": true + }] + }`) + if bytes.Contains(raw, []byte("/tmp/")) { + t.Fatal("TM mirror contains a local temporary path") + } + + var report backendrpc.TranslationMemoryQueryReport + if err := json.Unmarshal(raw, &report); err != nil { + t.Fatalf("decode TM query mirror: %v", err) + } + if !report.Available || report.Path != "${TM_PATH}" || + report.SourceText != "${SOURCE_TEXT}" || + report.SourceContext["field_path"] != "${FIELD_PATH}" || + len(report.Matches) != 1 { + t.Fatalf("TM report=%+v", report) + } + match := report.Matches[0] + if match.MatchKind != "strong_exact" || !match.CanAutoReuse || + match.Entry.TrustStatus != "trusted" || + match.Entry.TranslationSourceKind != "provider" || + match.Entry.SourceTrace.PathID == nil || *match.Entry.SourceTrace.PathID != 42 || + match.Entry.SourceTrace.ClassID == nil || *match.Entry.SourceTrace.ClassID != 114 || + match.Entry.TrustedBy == nil || *match.Entry.TrustedBy != "${REVIEWER}" { + t.Fatalf("TM match=%+v", match) + } + + var missing backendrpc.TranslationMemorySummaryReport + if err := json.Unmarshal([]byte(`{ + "available": false, + "path": "${TM_PATH}", + "reason": "database_missing" + }`), &missing); err != nil { + t.Fatalf("decode missing TM summary mirror: %v", err) + } + if missing.Available || missing.Summary != nil || missing.SchemaVersion != nil || + missing.Reason != "database_missing" { + t.Fatalf("missing TM summary=%+v", missing) + } +} diff --git a/internal/api/openapi.go b/internal/api/openapi.go index ff0b99d..750481a 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -411,6 +411,56 @@ paths: description: Missing or invalid admin token. "503": description: Rust bat translation backend is unavailable. + /admin/translation/memory/summary: + get: + summary: Read Rust-owned Translation Memory summary + parameters: + - name: translation_memory_path + in: query + schema: + type: string + responses: + "200": + description: Translation Memory availability and candidate/trusted counts. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat Translation Memory backend is unavailable. + /admin/translation/memory/query: + get: + summary: Query Rust-owned Translation Memory records + parameters: + - name: source_text + in: query + required: true + schema: + type: string + - name: source_context + in: query + description: JSON object whose values are strings. + schema: + type: string + - name: limit + in: query + schema: + type: integer + format: int64 + minimum: 1 + maximum: 1000 + default: 100 + - name: translation_memory_path + in: query + schema: + type: string + responses: + "200": + description: Translation Memory matches with reuse decision and provenance. + "400": + description: Missing source text or invalid context/limit. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat Translation Memory backend is unavailable. /admin/translation/status: get: summary: Read Rust-owned localized release status @@ -430,7 +480,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, localized-publish, localized-rollback] requestBody: required: false content: @@ -521,6 +571,14 @@ paths: minimum: 1 worker_id: type: string + translation_memory_path: + type: string + record_id: + type: string + reviewer: + type: string + reason: + type: string translation_file: type: string from_worker: diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index d3ed632..cb5feab 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -83,6 +83,15 @@ type TranslationBackend interface { TranslationProofread(ctx context.Context) (json.RawMessage, error) } +// TranslationMemoryBackend exposes the Rust-owned Translation Memory query and +// explicit confirmation operations. Go forwards typed requests and responses +// but never opens or mutates the TM database itself. +type TranslationMemoryBackend interface { + TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) + TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error) + TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error) +} + // LocalizedBackend exposes localized release status and the explicit // publish/rollback controls used by the authenticated dashboard. type LocalizedBackend interface { @@ -182,6 +191,18 @@ func (r RPCClient) TranslationProofread(ctx context.Context) (json.RawMessage, e return r.Client.TranslationProofread(ctx) } +func (r RPCClient) TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) { + return r.Client.TranslationMemorySummary(ctx, params) +} + +func (r RPCClient) TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error) { + return r.Client.TranslationMemoryQuery(ctx, params) +} + +func (r RPCClient) TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error) { + return r.Client.TranslationMemoryConfirm(ctx, params) +} + func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { return r.Client.LocalizedStatus(ctx) } diff --git a/internal/api/server.go b/internal/api/server.go index 1e88f59..6ea54f0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -74,6 +74,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/admin/schedules", s.handleAdminSchedules) mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks) mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff) + mux.HandleFunc("/admin/translation/memory/summary", s.handleAdminTranslationMemorySummary) + mux.HandleFunc("/admin/translation/memory/query", s.handleAdminTranslationMemoryQuery) mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus) mux.HandleFunc("/admin/control/", s.handleAdminControl) mux.HandleFunc("/admin/", s.handleAdminIndex) @@ -151,6 +153,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { "/admin/schedules", "/admin/translation/tasks", "/admin/translation/handoff", + "/admin/translation/memory/summary", + "/admin/translation/memory/query", "/admin/translation/status", "/admin/control/{action}", }, diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 196cf53..8dc7562 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -307,26 +307,28 @@ type TranslationTaskListParams struct { // Pointer numeric fields preserve explicit zeroes so Rust can reject invalid // dashboard input instead of receiving omitted defaults. type TranslationWorkerRunParams struct { - Provider string `json:"provider,omitempty"` - FixturePath string `json:"fixture_path,omitempty"` - Concurrency *uint64 `json:"concurrency,omitempty"` - MaxAttempts *uint64 `json:"max_attempts,omitempty"` - LeaseSeconds *uint64 `json:"lease_seconds,omitempty"` - RetryBackoffSeconds *uint64 `json:"retry_backoff_seconds,omitempty"` - MaxTasks *uint64 `json:"max_tasks,omitempty"` - WorkerID string `json:"worker_id,omitempty"` + Provider string `json:"provider,omitempty"` + FixturePath string `json:"fixture_path,omitempty"` + TranslationMemoryPath string `json:"translation_memory_path,omitempty"` + Concurrency *uint64 `json:"concurrency,omitempty"` + MaxAttempts *uint64 `json:"max_attempts,omitempty"` + LeaseSeconds *uint64 `json:"lease_seconds,omitempty"` + RetryBackoffSeconds *uint64 `json:"retry_backoff_seconds,omitempty"` + MaxTasks *uint64 `json:"max_tasks,omitempty"` + WorkerID string `json:"worker_id,omitempty"` } // TranslationWorkerConfig mirrors the accepted worker config returned by Rust. type TranslationWorkerConfig struct { - Provider string `json:"provider"` - FixturePath string `json:"fixture_path,omitempty"` - Concurrency uint64 `json:"concurrency"` - MaxAttempts uint64 `json:"max_attempts"` - LeaseSeconds uint64 `json:"lease_seconds"` - RetryBackoffSeconds uint64 `json:"retry_backoff_seconds"` - MaxTasks *uint64 `json:"max_tasks,omitempty"` - WorkerID string `json:"worker_id"` + Provider string `json:"provider"` + FixturePath string `json:"fixture_path,omitempty"` + TranslationMemoryPath string `json:"translation_memory_path,omitempty"` + Concurrency uint64 `json:"concurrency"` + MaxAttempts uint64 `json:"max_attempts"` + LeaseSeconds uint64 `json:"lease_seconds"` + RetryBackoffSeconds uint64 `json:"retry_backoff_seconds"` + MaxTasks *uint64 `json:"max_tasks,omitempty"` + WorkerID string `json:"worker_id"` } // TranslationWorkerRunResult is returned when translation.worker.run is queued. @@ -336,6 +338,144 @@ type TranslationWorkerRunResult struct { Worker TranslationWorkerConfig `json:"worker"` } +// TranslationMemoryContext is the stable TextUnit context sent to Rust. +type TranslationMemoryContext map[string]string + +// TranslationMemorySourceKind identifies who supplied the translation. +type TranslationMemorySourceKind string + +const ( + TranslationMemorySourceProvider TranslationMemorySourceKind = "provider" + TranslationMemorySourceManual TranslationMemorySourceKind = "manual" + TranslationMemorySourceImported TranslationMemorySourceKind = "imported" +) + +// TranslationMemoryTrustStatus is the Rust-owned review state. +type TranslationMemoryTrustStatus string + +const ( + TranslationMemoryStatusCandidate TranslationMemoryTrustStatus = "candidate" + TranslationMemoryStatusTrusted TranslationMemoryTrustStatus = "trusted" + TranslationMemoryStatusSuperseded TranslationMemoryTrustStatus = "superseded" + TranslationMemoryStatusRejected TranslationMemoryTrustStatus = "rejected" +) + +// TranslationMemoryMatchKind describes why a record was returned. +type TranslationMemoryMatchKind string + +const ( + TranslationMemoryMatchStrongExact TranslationMemoryMatchKind = "strong_exact" + TranslationMemoryMatchCandidateExact TranslationMemoryMatchKind = "candidate_exact" + TranslationMemoryMatchSourceOnly TranslationMemoryMatchKind = "source_only" +) + +// TranslationMemorySummaryParams selects an optional Rust-owned TM database. +type TranslationMemorySummaryParams struct { + TranslationMemoryPath string `json:"translation_memory_path,omitempty"` +} + +// TranslationMemoryQueryParams queries Rust-owned TM records by raw source and +// optional complete TextUnit context. +type TranslationMemoryQueryParams struct { + TranslationMemoryPath string `json:"translation_memory_path,omitempty"` + SourceText string `json:"source_text"` + SourceContext TranslationMemoryContext `json:"source_context,omitempty"` + Limit *uint64 `json:"limit,omitempty"` +} + +// TranslationMemoryConfirmParams explicitly promotes one candidate record. +type TranslationMemoryConfirmParams struct { + TranslationMemoryPath string `json:"translation_memory_path,omitempty"` + RecordID string `json:"record_id"` + Reviewer string `json:"reviewer"` + Reason string `json:"reason,omitempty"` +} + +// TranslationMemorySummary mirrors translation.memory.summary data. +type TranslationMemorySummary struct { + SchemaVersion uint64 `json:"schema_version"` + RecordCount uint64 `json:"record_count"` + TrustedCount uint64 `json:"trusted_count"` + CandidateCount uint64 `json:"candidate_count"` + SupersededCount uint64 `json:"superseded_count"` + RejectedCount uint64 `json:"rejected_count"` +} + +// TranslationMemorySummaryReport distinguishes a missing database from an +// available database with an empty summary. +type TranslationMemorySummaryReport struct { + Available bool `json:"available"` + Path string `json:"path"` + SchemaVersion *uint64 `json:"schema_version,omitempty"` + Summary *TranslationMemorySummary `json:"summary,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// TranslationMemorySourceTrace mirrors the Rust TextUnit/provider provenance. +type TranslationMemorySourceTrace struct { + OfficialReleaseID string `json:"official_release_id"` + UnitID *string `json:"unit_id,omitempty"` + TaskID *string `json:"task_id,omitempty"` + Destination *string `json:"destination,omitempty"` + ArchiveEntry *string `json:"archive_entry,omitempty"` + SerializedFile *string `json:"serialized_file,omitempty"` + PathID *int64 `json:"path_id,omitempty"` + ClassID *int32 `json:"class_id,omitempty"` + FieldPath *string `json:"field_path,omitempty"` + Format *string `json:"format,omitempty"` + AssetName *string `json:"asset_name,omitempty"` + TextSourceKind *string `json:"text_source_kind,omitempty"` + SourceURL *string `json:"source_url,omitempty"` +} + +// TranslationMemoryEntry mirrors a persisted Rust TM record. +type TranslationMemoryEntry struct { + RecordID string `json:"record_id"` + SourceText string `json:"source_text"` + SourceHash string `json:"source_hash"` + NormalizedSourceText string `json:"normalized_source_text"` + SourceContext TranslationMemoryContext `json:"source_context,omitempty"` + SourceContextHash string `json:"source_context_hash"` + TranslatedText string `json:"translated_text"` + TranslationSourceKind TranslationMemorySourceKind `json:"translation_source_kind"` + TrustStatus TranslationMemoryTrustStatus `json:"trust_status"` + OfficialReleaseID string `json:"official_release_id"` + SourceTrace TranslationMemorySourceTrace `json:"source_trace"` + Provider *string `json:"provider,omitempty"` + ProviderRunID *string `json:"provider_run_id,omitempty"` + CreatedUnixSeconds uint64 `json:"created_unix_seconds"` + UpdatedUnixSeconds uint64 `json:"updated_unix_seconds"` + TrustedUnixSeconds *uint64 `json:"trusted_unix_seconds,omitempty"` + TrustedBy *string `json:"trusted_by,omitempty"` + TrustedReason *string `json:"trusted_reason,omitempty"` + SupersedesRecordID *string `json:"supersedes_record_id,omitempty"` + SupersededByRecordID *string `json:"superseded_by_record_id,omitempty"` +} + +// TranslationMemoryMatch is one Rust-selected match and its reuse decision. +type TranslationMemoryMatch struct { + Entry TranslationMemoryEntry `json:"entry"` + MatchKind TranslationMemoryMatchKind `json:"match_kind"` + CanAutoReuse bool `json:"can_auto_reuse"` +} + +// TranslationMemoryQueryReport mirrors translation.memory.query data. +type TranslationMemoryQueryReport struct { + Available bool `json:"available"` + Path string `json:"path"` + SourceText string `json:"source_text"` + SourceContext TranslationMemoryContext `json:"source_context,omitempty"` + Matches []TranslationMemoryMatch `json:"matches"` + Reason string `json:"reason,omitempty"` +} + +// TranslationMemoryConfirmReport mirrors translation.memory.confirm data. +type TranslationMemoryConfirmReport struct { + Available bool `json:"available"` + Path string `json:"path"` + Entry TranslationMemoryEntry `json:"entry"` +} + // LocalizedPublishParams selects the source of one localized release // publication. TranslationFile and FromWorker are mutually exclusive. type LocalizedPublishParams struct { @@ -593,6 +733,24 @@ func (c *Client) TranslationWorkerRun(ctx context.Context, params TranslationWor return &out, err } +func (c *Client) TranslationMemorySummary(ctx context.Context, params TranslationMemorySummaryParams) (*TranslationMemorySummaryReport, error) { + var out TranslationMemorySummaryReport + _, err := c.Call(ctx, "translation.memory.summary", params, &out) + return &out, err +} + +func (c *Client) TranslationMemoryQuery(ctx context.Context, params TranslationMemoryQueryParams) (*TranslationMemoryQueryReport, error) { + var out TranslationMemoryQueryReport + _, err := c.Call(ctx, "translation.memory.query", params, &out) + return &out, err +} + +func (c *Client) TranslationMemoryConfirm(ctx context.Context, params TranslationMemoryConfirmParams) (*TranslationMemoryConfirmReport, error) { + var out TranslationMemoryConfirmReport + _, err := c.Call(ctx, "translation.memory.confirm", params, &out) + return &out, err +} + func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) { return c.rawData(ctx, "localized.publish", params) } diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index 5e0efb5..cd00ede 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -489,6 +489,7 @@ func TestTranslationWorkerRunSendsProviderConfig(t *testing.T) { } if params.Provider != "mock" || params.FixturePath != "/tmp/mock-provider.json" || + params.TranslationMemoryPath != "/tmp/translation-memory.sqlite" || params.Concurrency == nil || *params.Concurrency != concurrency || params.MaxAttempts == nil || *params.MaxAttempts != maxAttempts || params.LeaseSeconds == nil || *params.LeaseSeconds != leaseSeconds || @@ -506,14 +507,15 @@ func TestTranslationWorkerRunSendsProviderConfig(t *testing.T) { "task_id": "task-worker-1", "kind": "translation.worker.run", "worker": map[string]any{ - "provider": "mock", - "fixture_path": "/tmp/mock-provider.json", - "concurrency": concurrency, - "max_attempts": maxAttempts, - "lease_seconds": leaseSeconds, - "retry_backoff_seconds": retryBackoff, - "max_tasks": maxTasks, - "worker_id": "dashboard-worker", + "provider": "mock", + "fixture_path": "/tmp/mock-provider.json", + "translation_memory_path": "/tmp/translation-memory.sqlite", + "concurrency": concurrency, + "max_attempts": maxAttempts, + "lease_seconds": leaseSeconds, + "retry_backoff_seconds": retryBackoff, + "max_tasks": maxTasks, + "worker_id": "dashboard-worker", }, }, }, @@ -521,14 +523,15 @@ func TestTranslationWorkerRunSendsProviderConfig(t *testing.T) { }) result, err := client.TranslationWorkerRun(context.Background(), TranslationWorkerRunParams{ - Provider: "mock", - FixturePath: "/tmp/mock-provider.json", - Concurrency: &concurrency, - MaxAttempts: &maxAttempts, - LeaseSeconds: &leaseSeconds, - RetryBackoffSeconds: &retryBackoff, - MaxTasks: &maxTasks, - WorkerID: "dashboard-worker", + Provider: "mock", + FixturePath: "/tmp/mock-provider.json", + TranslationMemoryPath: "/tmp/translation-memory.sqlite", + Concurrency: &concurrency, + MaxAttempts: &maxAttempts, + LeaseSeconds: &leaseSeconds, + RetryBackoffSeconds: &retryBackoff, + MaxTasks: &maxTasks, + WorkerID: "dashboard-worker", }) if err != nil { t.Fatalf("TranslationWorkerRun error: %v", err) @@ -536,12 +539,192 @@ func TestTranslationWorkerRunSendsProviderConfig(t *testing.T) { if result.TaskID != "task-worker-1" || result.Kind != "translation.worker.run" || result.Worker.Concurrency != concurrency || + result.Worker.TranslationMemoryPath != "/tmp/translation-memory.sqlite" || result.Worker.RetryBackoffSeconds != retryBackoff || result.Worker.MaxTasks == nil || *result.Worker.MaxTasks != maxTasks { t.Fatalf("unexpected result: %#v", result) } } +func TestTranslationMemoryTypedContract(t *testing.T) { + limit := uint64(25) + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + switch req.Method { + case "translation.memory.summary": + var params TranslationMemorySummaryParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode summary params: %v", err) + } + if params.TranslationMemoryPath != "/var/lib/bat/translation-memory.sqlite" { + t.Fatalf("summary params=%#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, Status: "ok", RequestID: "req-tm-summary", + Data: map[string]any{ + "available": true, + "path": "/var/lib/bat/translation-memory.sqlite", + "schema_version": 1, + "summary": map[string]any{ + "schema_version": 1, + "record_count": 3, + "trusted_count": 1, + "candidate_count": 1, + "superseded_count": 1, + "rejected_count": 0, + }, + }, + }, + } + case "translation.memory.query": + var params TranslationMemoryQueryParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode query params: %v", err) + } + if params.SourceText != "Hello" || + params.SourceContext["destination"] != "Bundle/dialogue.bundle" || + params.Limit == nil || *params.Limit != limit { + t.Fatalf("query params=%#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, Status: "ok", RequestID: "req-tm-query", + Data: map[string]any{ + "available": true, + "path": "/var/lib/bat/translation-memory.sqlite", + "source_text": "Hello", + "source_context": map[string]any{ + "destination": "Bundle/dialogue.bundle", + "field_path": "Dialog.Message", + }, + "matches": []any{ + map[string]any{ + "entry": map[string]any{ + "record_id": "tm-record-1", + "source_text": "Hello", + "source_hash": "hash-source", + "normalized_source_text": "hello", + "source_context": map[string]any{ + "destination": "Bundle/dialogue.bundle", + "field_path": "Dialog.Message", + }, + "source_context_hash": "hash-context", + "translated_text": "你好", + "translation_source_kind": "provider", + "trust_status": "trusted", + "official_release_id": "release-1", + "source_trace": map[string]any{ + "official_release_id": "release-1", + "unit_id": "textunit-1", + "task_id": "task-1", + "destination": "Bundle/dialogue.bundle", + "archive_entry": "dialogue.json", + "serialized_file": "globalgamemanagers", + "path_id": 42, + "class_id": 114, + "field_path": "Dialog.Message", + "format": "json", + "asset_name": "Dialogue", + "text_source_kind": "text_asset", + }, + "provider": "mock", + "provider_run_id": "provider-run-1", + "created_unix_seconds": 100, + "updated_unix_seconds": 200, + "trusted_unix_seconds": 200, + "trusted_by": "reviewer", + "trusted_reason": "reviewed", + }, + "match_kind": "strong_exact", + "can_auto_reuse": true, + }, + }, + }, + }, + } + case "translation.memory.confirm": + var params TranslationMemoryConfirmParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode confirm params: %v", err) + } + if params.RecordID != "tm-record-1" || + params.Reviewer != "reviewer" || + params.Reason != "reviewed" || + params.TranslationMemoryPath != "/var/lib/bat/translation-memory.sqlite" { + t.Fatalf("confirm params=%#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, Status: "ok", RequestID: "req-tm-confirm", + Data: map[string]any{ + "available": true, + "path": "/var/lib/bat/translation-memory.sqlite", + "entry": map[string]any{ + "record_id": "tm-record-1", + "translation_source_kind": "provider", + "trust_status": "trusted", + }, + }, + }, + } + default: + t.Fatalf("unexpected method %q", req.Method) + return testResponse{} + } + }) + + summary, err := client.TranslationMemorySummary(context.Background(), TranslationMemorySummaryParams{ + TranslationMemoryPath: "/var/lib/bat/translation-memory.sqlite", + }) + if err != nil { + t.Fatalf("TranslationMemorySummary error: %v", err) + } + if !summary.Available || summary.Summary == nil || + summary.Summary.TrustedCount != 1 || summary.Summary.CandidateCount != 1 { + t.Fatalf("summary=%#v", summary) + } + + query, err := client.TranslationMemoryQuery(context.Background(), TranslationMemoryQueryParams{ + TranslationMemoryPath: "/var/lib/bat/translation-memory.sqlite", + SourceText: "Hello", + SourceContext: TranslationMemoryContext{ + "destination": "Bundle/dialogue.bundle", + "field_path": "Dialog.Message", + }, + Limit: &limit, + }) + if err != nil { + t.Fatalf("TranslationMemoryQuery error: %v", err) + } + if len(query.Matches) != 1 || + query.Matches[0].MatchKind != "strong_exact" || + !query.Matches[0].CanAutoReuse || + query.Matches[0].Entry.TrustStatus != "trusted" || + query.Matches[0].Entry.TranslationSourceKind != "provider" || + query.Matches[0].Entry.SourceTrace.UnitID == nil || + *query.Matches[0].Entry.SourceTrace.UnitID != "textunit-1" || + query.Matches[0].Entry.SourceTrace.PathID == nil || + *query.Matches[0].Entry.SourceTrace.PathID != 42 || + query.Matches[0].Entry.TrustedBy == nil || + *query.Matches[0].Entry.TrustedBy != "reviewer" { + t.Fatalf("query=%#v", query) + } + + confirmed, err := client.TranslationMemoryConfirm(context.Background(), TranslationMemoryConfirmParams{ + TranslationMemoryPath: "/var/lib/bat/translation-memory.sqlite", + RecordID: "tm-record-1", + Reviewer: "reviewer", + Reason: "reviewed", + }) + if err != nil { + t.Fatalf("TranslationMemoryConfirm error: %v", err) + } + if !confirmed.Available || confirmed.Entry.TrustStatus != "trusted" || + confirmed.Entry.RecordID != "tm-record-1" { + t.Fatalf("confirmed=%#v", confirmed) + } +} + func TestTranslationProofreadUsesRustMethod(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "translation.proofread" {