feat(glossary): 实现 Rust Glossary V1

This commit is contained in:
2026-09-07 22:38:53 +08:00
parent 8fc93b8f39
commit 94483ff14d
42 changed files with 4543 additions and 92 deletions
+206
View File
@@ -56,6 +56,9 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/translation/handoff",
"/admin/translation/memory/summary",
"/admin/translation/memory/query",
"/admin/translation/glossary/summary",
"/admin/translation/glossary/query",
"/admin/translation/glossary/diagnose",
"/admin/translation/status",
},
Controls: []string{
@@ -75,6 +78,10 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/translation-worker-run",
"/admin/control/translation-proofread",
"/admin/control/translation-memory-confirm",
"/admin/control/translation-glossary-add",
"/admin/control/translation-glossary-update",
"/admin/control/translation-glossary-approve",
"/admin/control/translation-glossary-deprecate",
"/admin/control/localized-publish",
"/admin/control/localized-rollback",
},
@@ -124,6 +131,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
s.handleAdminTranslationMemoryConfirm(w, r)
return
}
if strings.HasPrefix(action, "translation-glossary-") {
s.handleAdminGlossaryControl(w, r, action)
return
}
if action == "localized-publish" {
s.handleAdminLocalizedPublish(w, r)
return
@@ -309,6 +320,201 @@ func (s *Server) handleAdminTranslationMemoryConfirm(w http.ResponseWriter, r *h
})
}
func (s *Server) handleAdminGlossarySummary(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.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
params := backendrpc.GlossarySummaryParams{
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
}
result, err := backend.GlossarySummary(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-glossary-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) handleAdminGlossaryQuery(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.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
params := backendrpc.GlossaryQueryParams{
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
SourceText: firstTrimmedQuery(r.URL.Query(), "source_text"),
Category: firstTrimmedQuery(r.URL.Query(), "category"),
ReviewStatus: firstTrimmedQuery(r.URL.Query(), "review_status"),
}
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
limit, err := strconv.ParseUint(raw, 10, 64)
if err != nil || limit == 0 || limit > 1000 {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "limit must be in 1..=1000")
return
}
params.Limit = &limit
}
result, err := backend.GlossaryQuery(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-glossary-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) handleAdminGlossaryDiagnose(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.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
params := backendrpc.GlossaryDiagnoseParams{
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
SourceText: firstTrimmedQuery(r.URL.Query(), "source_text"),
}
if params.SourceText == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "source_text is required")
return
}
if raw := firstTrimmedQuery(r.URL.Query(), "context", "source_context"); raw != "" {
if err := json.Unmarshal([]byte(raw), &params.Context); err != nil || params.Context == nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "context must be a JSON object with string values")
return
}
}
result, err := backend.GlossaryDiagnose(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-glossary-diagnose", 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) handleAdminGlossaryControl(w http.ResponseWriter, r *http.Request, action string) {
backend, ok := s.backend.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
switch action {
case "translation-glossary-add", "translation-glossary-update":
var params backendrpc.GlossaryTermMutationParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.SourceTerm) == "" ||
strings.TrimSpace(params.RecommendedTranslation) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, source_term and recommended_translation are required")
return
}
var result *backendrpc.GlossaryMutationReport
var err error
if action == "translation-glossary-add" {
result, err = backend.GlossaryAdd(r.Context(), params)
} else {
if strings.TrimSpace(params.Reviewer) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "reviewer is required for update")
return
}
result, err = backend.GlossaryUpdate(r.Context(), params)
}
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: glossaryControlRPCMethod(action),
Status: "accepted",
Result: result,
})
case "translation-glossary-approve", "translation-glossary-deprecate":
var params backendrpc.GlossaryReviewParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.Reviewer) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id and reviewer are required")
return
}
var result *backendrpc.GlossaryMutationReport
var err error
if action == "translation-glossary-approve" {
result, err = backend.GlossaryApprove(r.Context(), params)
} else {
result, err = backend.GlossaryDeprecate(r.Context(), params)
}
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: glossaryControlRPCMethod(action),
Status: "accepted",
Result: result,
})
default:
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown glossary control action")
}
}
func glossaryControlRPCMethod(action string) string {
switch action {
case "translation-glossary-add":
return "translation.glossary.add"
case "translation-glossary-update":
return "translation.glossary.update"
case "translation-glossary-approve":
return "translation.glossary.approve"
case "translation-glossary-deprecate":
return "translation.glossary.deprecate"
default:
return ""
}
}
func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(LocalizedBackend)
if !ok || backend == nil {
+133
View File
@@ -637,6 +637,11 @@ type controlBackend struct {
translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams
translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams
translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams
glossarySummaryParams []backendrpc.GlossarySummaryParams
glossaryQueryParams []backendrpc.GlossaryQueryParams
glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams
glossaryMutationParams []backendrpc.GlossaryTermMutationParams
glossaryReviewParams []backendrpc.GlossaryReviewParams
localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams
}
@@ -825,6 +830,81 @@ func (b *controlBackend) TranslationMemoryConfirm(ctx context.Context, params ba
}, nil
}
func (b *controlBackend) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) {
b.calls = append(b.calls, "translation.glossary.summary")
b.glossarySummaryParams = append(b.glossarySummaryParams, params)
schemaVersion := uint64(1)
return &backendrpc.GlossarySummaryReport{
Available: true,
Path: params.GlossaryPath,
SchemaVersion: &schemaVersion,
Summary: &backendrpc.GlossarySummary{
SchemaVersion: schemaVersion,
TermCount: 2,
ApprovedCount: 1,
DraftCount: 1,
},
}, nil
}
func (b *controlBackend) GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) {
b.calls = append(b.calls, "translation.glossary.query")
b.glossaryQueryParams = append(b.glossaryQueryParams, params)
return &backendrpc.GlossaryQueryReport{
Available: true,
Path: params.GlossaryPath,
Terms: []backendrpc.GlossaryTerm{{
TermID: "term-sensei",
GlossaryTermSnapshot: backendrpc.GlossaryTermSnapshot{
SourceTerm: "Sensei",
RecommendedTranslation: "老师",
Priority: 10,
},
ReviewStatus: backendrpc.GlossaryStatusApproved,
Source: backendrpc.GlossarySourceRecord{
SourceKind: "manual",
ObservedUnixSeconds: 100,
},
}},
}, nil
}
func (b *controlBackend) GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) {
b.calls = append(b.calls, "translation.glossary.diagnose")
b.glossaryDiagnoseParams = append(b.glossaryDiagnoseParams, params)
return &backendrpc.GlossaryDiagnoseReport{
Available: true,
Path: params.GlossaryPath,
SourceText: params.SourceText,
Context: params.Context,
Evaluation: json.RawMessage(`{"constraints":[],"diagnostics":[],"blocked":false}`),
}, nil
}
func (b *controlBackend) GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.add")
b.glossaryMutationParams = append(b.glossaryMutationParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.update")
b.glossaryMutationParams = append(b.glossaryMutationParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.approve")
b.glossaryReviewParams = append(b.glossaryReviewParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.deprecate")
b.glossaryReviewParams = append(b.glossaryReviewParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, 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
@@ -1044,6 +1124,48 @@ func TestAdminTranslationQueryEndpointsProxyAuthenticatedRequests(t *testing.T)
t.Fatalf("missing TM query source status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/summary?glossary_path=%2Fvar%2Flib%2Fbat%2Fglossary.sqlite", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
!strings.Contains(recorder.Body.String(), `"approved_count":1`) ||
len(backend.glossarySummaryParams) != 1 ||
backend.glossarySummaryParams[0].GlossaryPath != "/var/lib/bat/glossary.sqlite" {
t.Fatalf("Glossary summary status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossarySummaryParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/query?source_text=Sensei&limit=20", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
len(backend.glossaryQueryParams) != 1 ||
backend.glossaryQueryParams[0].SourceText != "Sensei" ||
backend.glossaryQueryParams[0].Limit == nil ||
*backend.glossaryQueryParams[0].Limit != 20 {
t.Fatalf("Glossary query status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossaryQueryParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/diagnose?source_text=Sensei&context=%7B%22destination%22%3A%22Bundle%2Fdialogue.bundle%22%7D", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
len(backend.glossaryDiagnoseParams) != 1 ||
backend.glossaryDiagnoseParams[0].SourceText != "Sensei" ||
backend.glossaryDiagnoseParams[0].Context["destination"] != "Bundle/dialogue.bundle" {
t.Fatalf("Glossary diagnose status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossaryDiagnoseParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/diagnose", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("missing Glossary diagnose 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()
@@ -1818,6 +1940,10 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{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: "translation glossary add", action: "translation-glossary-add", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.add", call: "translation.glossary.add"},
{name: "translation glossary update", action: "translation-glossary-update", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","reviewer":"reviewer","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.update", call: "translation.glossary.update"},
{name: "translation glossary approve", action: "translation-glossary-approve", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"approved"}`, rpcMethod: "translation.glossary.approve", call: "translation.glossary.approve"},
{name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"},
{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"},
}
@@ -1854,6 +1980,13 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" {
t.Fatalf("TM confirm params=%#v", backend.translationMemoryConfirmParams)
}
if len(backend.glossaryMutationParams) != 2 ||
backend.glossaryMutationParams[0].TermID != "term-sensei" ||
backend.glossaryMutationParams[1].Reviewer != "reviewer" ||
len(backend.glossaryReviewParams) != 2 ||
backend.glossaryReviewParams[0].TermID != "term-sensei" {
t.Fatalf("Glossary params mutation=%#v review=%#v", backend.glossaryMutationParams, backend.glossaryReviewParams)
}
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
request.Header.Set("Authorization", "Bearer control-token")
+41
View File
@@ -198,3 +198,44 @@ func TestTranslationMemoryRustContractMirror(t *testing.T) {
t.Fatalf("missing TM summary=%+v", missing)
}
}
func TestGlossaryRustContractMirror(t *testing.T) {
raw := readContractFixture(t, "glossary-query.json")
if bytes.Contains(raw, []byte("/tmp/")) {
t.Fatal("Glossary mirror contains a local temporary path")
}
var report backendrpc.GlossaryQueryReport
if err := json.Unmarshal(raw, &report); err != nil {
t.Fatalf("decode Glossary query mirror: %v", err)
}
if !report.Available || report.Path != "${GLOSSARY_PATH}" ||
report.SourceText != "${SOURCE_TEXT}" || len(report.Terms) != 1 {
t.Fatalf("Glossary report=%+v", report)
}
term := report.Terms[0]
if term.TermID != "${TERM_ID}" ||
term.SourceTerm != "${SOURCE_TERM}" ||
term.RecommendedTranslation != "${RECOMMENDED_TRANSLATION}" ||
term.ReviewStatus != backendrpc.GlossaryStatusApproved ||
term.Source.SourceKind != "manual" ||
term.Source.ObservedUnixSeconds != 100 ||
len(term.History) != 2 ||
term.History[0].ReviewStatus != backendrpc.GlossaryStatusDraft ||
term.History[1].Action != "approved" ||
term.History[1].ReviewStatus != backendrpc.GlossaryStatusApproved ||
term.History[1].Snapshot.Priority != 10 {
t.Fatalf("Glossary term=%+v", term)
}
var missing backendrpc.GlossarySummaryReport
if err := json.Unmarshal([]byte(`{
"available": false,
"path": "${GLOSSARY_PATH}",
"reason": "database_missing"
}`), &missing); err != nil {
t.Fatalf("decode missing Glossary summary mirror: %v", err)
}
if missing.Available || missing.Summary != nil || missing.SchemaVersion != nil ||
missing.Reason != "database_missing" {
t.Fatalf("missing Glossary summary=%+v", missing)
}
}
+130 -1
View File
@@ -461,6 +461,86 @@ paths:
description: Missing or invalid admin token.
"503":
description: Rust bat Translation Memory backend is unavailable.
/admin/translation/glossary/summary:
get:
summary: Read Rust-owned Glossary summary
parameters:
- name: glossary_path
in: query
schema:
type: string
responses:
"200":
description: Glossary availability and review-state counts.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Glossary backend is unavailable.
/admin/translation/glossary/query:
get:
summary: Query Rust-owned Glossary terms
parameters:
- name: source_text
in: query
schema:
type: string
- name: category
in: query
schema:
type: string
- name: review_status
in: query
schema:
type: string
enum: [draft, approved, deprecated, rejected]
- name: limit
in: query
schema:
type: integer
format: int64
minimum: 1
maximum: 1000
default: 100
- name: glossary_path
in: query
schema:
type: string
responses:
"200":
description: Glossary terms with source and review history.
"400":
description: Invalid Glossary query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Glossary backend is unavailable.
/admin/translation/glossary/diagnose:
get:
summary: Run deterministic Glossary diagnostics
parameters:
- name: source_text
in: query
required: true
schema:
type: string
- name: context
in: query
description: JSON object whose values are strings.
schema:
type: string
- name: glossary_path
in: query
schema:
type: string
responses:
"200":
description: Glossary constraints, diagnostics, and blocked decision.
"400":
description: Missing source text or invalid context.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Glossary backend is unavailable.
/admin/translation/status:
get:
summary: Read Rust-owned localized release status
@@ -480,7 +560,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, translation-memory-confirm, 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, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, localized-publish, localized-rollback]
requestBody:
required: false
content:
@@ -573,8 +653,57 @@ paths:
type: string
translation_memory_path:
type: string
glossary_path:
type: string
record_id:
type: string
term_id:
type: string
source_term:
type: string
aliases:
type: array
items:
type: string
recommended_translation:
type: string
allowed_translations:
type: array
items:
type: string
source_language:
type: string
target_language:
type: string
category:
type: string
priority:
type: integer
format: int64
scope:
type: object
additionalProperties:
type: string
review_status:
type: string
enum: [draft, approved, deprecated, rejected]
source:
type: object
additionalProperties: false
required: [source_kind, observed_unix_seconds]
properties:
source_kind:
type: string
enum: [manual, imported]
source_ref:
type: string
source_author:
type: string
source_note:
type: string
observed_unix_seconds:
type: integer
format: int64
reviewer:
type: string
reason:
+34
View File
@@ -92,6 +92,18 @@ type TranslationMemoryBackend interface {
TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error)
}
// GlossaryBackend exposes Rust-owned Glossary management and diagnostics.
// Go forwards these typed calls and never opens the Glossary database.
type GlossaryBackend interface {
GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error)
GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error)
GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error)
GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
}
// LocalizedBackend exposes localized release status and the explicit
// publish/rollback controls used by the authenticated dashboard.
type LocalizedBackend interface {
@@ -203,6 +215,28 @@ func (r RPCClient) TranslationMemoryConfirm(ctx context.Context, params backendr
return r.Client.TranslationMemoryConfirm(ctx, params)
}
func (r RPCClient) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) {
return r.Client.GlossarySummary(ctx, params)
}
func (r RPCClient) GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) {
return r.Client.GlossaryQuery(ctx, params)
}
func (r RPCClient) GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) {
return r.Client.GlossaryDiagnose(ctx, params)
}
func (r RPCClient) GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryAdd(ctx, params)
}
func (r RPCClient) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryUpdate(ctx, params)
}
func (r RPCClient) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryApprove(ctx, params)
}
func (r RPCClient) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryDeprecate(ctx, params)
}
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.LocalizedStatus(ctx)
}
+6
View File
@@ -76,6 +76,9 @@ func (s *Server) Handler() http.Handler {
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/glossary/summary", s.handleAdminGlossarySummary)
mux.HandleFunc("/admin/translation/glossary/query", s.handleAdminGlossaryQuery)
mux.HandleFunc("/admin/translation/glossary/diagnose", s.handleAdminGlossaryDiagnose)
mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus)
mux.HandleFunc("/admin/control/", s.handleAdminControl)
mux.HandleFunc("/admin/", s.handleAdminIndex)
@@ -155,6 +158,9 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/admin/translation/handoff",
"/admin/translation/memory/summary",
"/admin/translation/memory/query",
"/admin/translation/glossary/summary",
"/admin/translation/glossary/query",
"/admin/translation/glossary/diagnose",
"/admin/translation/status",
"/admin/control/{action}",
},
+1
View File
@@ -10,6 +10,7 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本
- `resource.manifest` 第一页分页响应。
- 对应 release 的 `official-sync-snapshot.json`
- `launcher_metadata``game_main_config_bootstrap` 的 Go mirror 解码。
- Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live
daemon socket 或完整发布切换验证。
+99
View File
@@ -0,0 +1,99 @@
{
"available": true,
"path": "${GLOSSARY_PATH}",
"source_text": "${SOURCE_TEXT}",
"terms": [
{
"term_id": "${TERM_ID}",
"source_term": "${SOURCE_TERM}",
"aliases": [
"${ALIAS}"
],
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
"allowed_translations": [
"${ALLOWED_TRANSLATION}"
],
"source_language": "en",
"target_language": "zh-Hans",
"category": "character",
"priority": 10,
"scope": {
"destination": "${DESTINATION}"
},
"review_status": "approved",
"source": {
"source_kind": "manual",
"source_ref": "${SOURCE_REF}",
"source_author": "${SOURCE_AUTHOR}",
"source_note": "${SOURCE_NOTE}",
"observed_unix_seconds": 100
},
"history": [
{
"history_id": "${HISTORY_CREATED_ID}",
"action": "created",
"source": {
"source_kind": "manual",
"source_ref": "${SOURCE_REF}",
"source_author": "${SOURCE_AUTHOR}",
"source_note": "${SOURCE_NOTE}",
"observed_unix_seconds": 100
},
"review_status": "draft",
"snapshot": {
"source_term": "${SOURCE_TERM}",
"aliases": [
"${ALIAS}"
],
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
"allowed_translations": [
"${ALLOWED_TRANSLATION}"
],
"source_language": "en",
"target_language": "zh-Hans",
"category": "character",
"priority": 10,
"scope": {
"destination": "${DESTINATION}"
}
},
"observed_unix_seconds": 100
},
{
"history_id": "${HISTORY_APPROVED_ID}",
"action": "approved",
"reviewer": "${REVIEWER}",
"reason": "reviewed",
"source": {
"source_kind": "manual",
"source_ref": "${SOURCE_REF}",
"source_author": "${SOURCE_AUTHOR}",
"source_note": "${SOURCE_NOTE}",
"observed_unix_seconds": 100
},
"review_status": "approved",
"snapshot": {
"source_term": "${SOURCE_TERM}",
"aliases": [
"${ALIAS}"
],
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
"allowed_translations": [
"${ALLOWED_TRANSLATION}"
],
"source_language": "en",
"target_language": "zh-Hans",
"category": "character",
"priority": 10,
"scope": {
"destination": "${DESTINATION}"
}
},
"observed_unix_seconds": 101
}
],
"created_unix_seconds": 100,
"updated_unix_seconds": 101
}
]
}
+196 -3
View File
@@ -267,9 +267,10 @@ type ScheduleRunParams struct {
// TranslationTaskUnitResultParam is the dashboard/manual-review subset of one
// TextUnit result accepted by Rust translation.task.update.
type TranslationTaskUnitResultParam struct {
UnitID string `json:"unit_id"`
SourceText string `json:"source_text"`
TranslatedText string `json:"translated_text"`
UnitID string `json:"unit_id"`
SourceText string `json:"source_text"`
TranslatedText string `json:"translated_text"`
GlossaryOverride *GlossaryOverride `json:"glossary_override,omitempty"`
}
// TranslationTaskUpdateParams is used by translation.task.update to persist
@@ -310,6 +311,7 @@ type TranslationWorkerRunParams struct {
Provider string `json:"provider,omitempty"`
FixturePath string `json:"fixture_path,omitempty"`
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
GlossaryPath string `json:"glossary_path,omitempty"`
Concurrency *uint64 `json:"concurrency,omitempty"`
MaxAttempts *uint64 `json:"max_attempts,omitempty"`
LeaseSeconds *uint64 `json:"lease_seconds,omitempty"`
@@ -323,6 +325,7 @@ type TranslationWorkerConfig struct {
Provider string `json:"provider"`
FixturePath string `json:"fixture_path,omitempty"`
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
GlossaryPath string `json:"glossary_path,omitempty"`
Concurrency uint64 `json:"concurrency"`
MaxAttempts uint64 `json:"max_attempts"`
LeaseSeconds uint64 `json:"lease_seconds"`
@@ -476,6 +479,154 @@ type TranslationMemoryConfirmReport struct {
Entry TranslationMemoryEntry `json:"entry"`
}
// GlossaryReviewStatus is the Rust-owned term review state.
type GlossaryReviewStatus string
const (
GlossaryStatusDraft GlossaryReviewStatus = "draft"
GlossaryStatusApproved GlossaryReviewStatus = "approved"
GlossaryStatusDeprecated GlossaryReviewStatus = "deprecated"
GlossaryStatusRejected GlossaryReviewStatus = "rejected"
)
// GlossarySourceRecord identifies the source/provenance of a term.
type GlossarySourceRecord struct {
SourceKind string `json:"source_kind"`
SourceRef *string `json:"source_ref,omitempty"`
SourceAuthor *string `json:"source_author,omitempty"`
SourceNote *string `json:"source_note,omitempty"`
ObservedUnixSeconds uint64 `json:"observed_unix_seconds"`
}
// GlossaryTermSnapshot is the versioned definition shared by Rust and Go.
type GlossaryTermSnapshot struct {
SourceTerm string `json:"source_term"`
Aliases []string `json:"aliases,omitempty"`
RecommendedTranslation string `json:"recommended_translation"`
AllowedTranslations []string `json:"allowed_translations,omitempty"`
SourceLanguage *string `json:"source_language,omitempty"`
TargetLanguage *string `json:"target_language,omitempty"`
Category *string `json:"category,omitempty"`
Priority int `json:"priority"`
Scope map[string]string `json:"scope,omitempty"`
}
// GlossaryOverride records explicit human approval for a deviation.
type GlossaryOverride struct {
Reviewer string `json:"reviewer"`
Reason string `json:"reason"`
Provenance string `json:"provenance"`
ConfirmedUnixSeconds uint64 `json:"confirmed_unix_seconds"`
}
// GlossaryTerm mirrors a persisted Rust term and its source history.
type GlossaryTerm struct {
TermID string `json:"term_id"`
GlossaryTermSnapshot
ReviewStatus GlossaryReviewStatus `json:"review_status"`
Source GlossarySourceRecord `json:"source"`
History []GlossaryHistoryRecord `json:"history,omitempty"`
CreatedUnixSeconds uint64 `json:"created_unix_seconds"`
UpdatedUnixSeconds uint64 `json:"updated_unix_seconds"`
}
// GlossaryHistoryRecord is one durable term mutation.
type GlossaryHistoryRecord struct {
HistoryID string `json:"history_id"`
Action string `json:"action"`
Reviewer *string `json:"reviewer,omitempty"`
Reason *string `json:"reason,omitempty"`
Source GlossarySourceRecord `json:"source"`
ReviewStatus GlossaryReviewStatus `json:"review_status"`
Snapshot GlossaryTermSnapshot `json:"snapshot"`
ObservedUnixSeconds uint64 `json:"observed_unix_seconds"`
}
// GlossarySummary mirrors translation.glossary.summary.
type GlossarySummary struct {
SchemaVersion uint64 `json:"schema_version"`
TermCount uint64 `json:"term_count"`
ApprovedCount uint64 `json:"approved_count"`
DraftCount uint64 `json:"draft_count"`
DeprecatedCount uint64 `json:"deprecated_count"`
RejectedCount uint64 `json:"rejected_count"`
}
type GlossarySummaryParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
}
type GlossaryQueryParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
SourceText string `json:"source_text,omitempty"`
Category string `json:"category,omitempty"`
ReviewStatus string `json:"review_status,omitempty"`
Limit *uint64 `json:"limit,omitempty"`
}
type GlossaryDiagnoseParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
SourceText string `json:"source_text"`
Context map[string]string `json:"context,omitempty"`
}
type GlossaryTermMutationParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
TermID string `json:"term_id"`
SourceTerm string `json:"source_term"`
Aliases []string `json:"aliases,omitempty"`
RecommendedTranslation string `json:"recommended_translation"`
AllowedTranslations []string `json:"allowed_translations,omitempty"`
SourceLanguage *string `json:"source_language,omitempty"`
TargetLanguage *string `json:"target_language,omitempty"`
Category *string `json:"category,omitempty"`
Priority int `json:"priority"`
Scope map[string]string `json:"scope,omitempty"`
ReviewStatus string `json:"review_status"`
Source GlossarySourceRecord `json:"source"`
Reviewer string `json:"reviewer,omitempty"`
Reason string `json:"reason,omitempty"`
}
type GlossaryReviewParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
TermID string `json:"term_id"`
Reviewer string `json:"reviewer"`
Reason string `json:"reason,omitempty"`
}
type GlossarySummaryReport struct {
Available bool `json:"available"`
Path string `json:"path"`
SchemaVersion *uint64 `json:"schema_version,omitempty"`
Summary *GlossarySummary `json:"summary,omitempty"`
Reason string `json:"reason,omitempty"`
}
type GlossaryQueryReport struct {
Available bool `json:"available"`
Path string `json:"path"`
SourceText string `json:"source_text"`
Terms []GlossaryTerm `json:"terms"`
Reason string `json:"reason,omitempty"`
}
type GlossaryMutationReport struct {
Available bool `json:"available"`
Path string `json:"path"`
SchemaVersion *uint64 `json:"schema_version,omitempty"`
Term GlossaryTerm `json:"term"`
}
type GlossaryDiagnoseReport struct {
Available bool `json:"available"`
Path string `json:"path"`
SourceText string `json:"source_text"`
Context map[string]string `json:"context,omitempty"`
Evaluation json.RawMessage `json:"evaluation"`
Reason string `json:"reason,omitempty"`
}
// LocalizedPublishParams selects the source of one localized release
// publication. TranslationFile and FromWorker are mutually exclusive.
type LocalizedPublishParams struct {
@@ -751,6 +902,48 @@ func (c *Client) TranslationMemoryConfirm(ctx context.Context, params Translatio
return &out, err
}
func (c *Client) GlossarySummary(ctx context.Context, params GlossarySummaryParams) (*GlossarySummaryReport, error) {
var out GlossarySummaryReport
_, err := c.Call(ctx, "translation.glossary.summary", params, &out)
return &out, err
}
func (c *Client) GlossaryQuery(ctx context.Context, params GlossaryQueryParams) (*GlossaryQueryReport, error) {
var out GlossaryQueryReport
_, err := c.Call(ctx, "translation.glossary.query", params, &out)
return &out, err
}
func (c *Client) GlossaryDiagnose(ctx context.Context, params GlossaryDiagnoseParams) (*GlossaryDiagnoseReport, error) {
var out GlossaryDiagnoseReport
_, err := c.Call(ctx, "translation.glossary.diagnose", params, &out)
return &out, err
}
func (c *Client) GlossaryAdd(ctx context.Context, params GlossaryTermMutationParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.add", params, &out)
return &out, err
}
func (c *Client) GlossaryUpdate(ctx context.Context, params GlossaryTermMutationParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.update", params, &out)
return &out, err
}
func (c *Client) GlossaryApprove(ctx context.Context, params GlossaryReviewParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.approve", params, &out)
return &out, err
}
func (c *Client) GlossaryDeprecate(ctx context.Context, params GlossaryReviewParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.deprecate", params, &out)
return &out, err
}
func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) {
return c.rawData(ctx, "localized.publish", params)
}
+173
View File
@@ -823,6 +823,179 @@ func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) {
}
}
func TestGlossaryTypedContract(t *testing.T) {
limit := uint64(20)
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
switch req.Method {
case "translation.glossary.summary":
var params GlossarySummaryParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode summary params: %v", err)
}
if params.GlossaryPath != "/var/lib/bat/glossary.sqlite" {
t.Fatalf("summary params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-summary",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"schema_version": 1,
"summary": map[string]any{
"schema_version": 1,
"term_count": 2,
"approved_count": 1,
"draft_count": 1,
},
},
}}
case "translation.glossary.query":
var params GlossaryQueryParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode query params: %v", err)
}
if params.SourceText != "Sensei" ||
params.GlossaryPath != "/var/lib/bat/glossary.sqlite" ||
params.Limit == nil || *params.Limit != limit {
t.Fatalf("query params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-query",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"terms": []any{map[string]any{
"term_id": "term-sensei",
"source_term": "Sensei",
"recommended_translation": "老师",
"priority": 10,
"review_status": "approved",
"source": map[string]any{
"source_kind": "manual",
"observed_unix_seconds": 100,
},
}},
},
}}
case "translation.glossary.diagnose":
var params GlossaryDiagnoseParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode diagnose params: %v", err)
}
if params.SourceText != "Sensei" || params.Context["destination"] != "Bundle/dialogue.bundle" {
t.Fatalf("diagnose params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-diagnose",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"source_text": "Sensei",
"evaluation": map[string]any{
"constraints": []any{map[string]any{
"term_id": "term-sensei",
"matched_source": "Sensei",
"recommended_translation": "老师",
}},
"diagnostics": []any{},
"blocked": false,
},
},
}}
case "translation.glossary.add":
var params GlossaryTermMutationParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode add params: %v", err)
}
if params.TermID != "term-sensei" || params.Source.SourceKind != "manual" ||
params.Source.ObservedUnixSeconds != 100 {
t.Fatalf("add params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-add",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{
"term_id": "term-sensei",
"source_term": "Sensei",
"recommended_translation": "老师",
"review_status": "draft",
"source": map[string]any{
"source_kind": "manual",
"observed_unix_seconds": 100,
},
},
},
}}
case "translation.glossary.approve":
var params GlossaryReviewParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode approve params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" {
t.Fatalf("approve params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-approve",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{"term_id": "term-sensei", "review_status": "approved"},
},
}}
default:
t.Fatalf("unexpected method %q", req.Method)
return testResponse{}
}
})
summary, err := client.GlossarySummary(context.Background(), GlossarySummaryParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
})
if err != nil || summary.Summary == nil || summary.Summary.ApprovedCount != 1 {
t.Fatalf("summary=%#v err=%v", summary, err)
}
query, err := client.GlossaryQuery(context.Background(), GlossaryQueryParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
SourceText: "Sensei",
Limit: &limit,
})
if err != nil || len(query.Terms) != 1 || query.Terms[0].ReviewStatus != GlossaryStatusApproved {
t.Fatalf("query=%#v err=%v", query, err)
}
diagnose, err := client.GlossaryDiagnose(context.Background(), GlossaryDiagnoseParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
SourceText: "Sensei",
Context: TranslationMemoryContext{"destination": "Bundle/dialogue.bundle"},
})
if err != nil || !json.Valid(diagnose.Evaluation) {
t.Fatalf("diagnose=%#v err=%v", diagnose, err)
}
add, err := client.GlossaryAdd(context.Background(), GlossaryTermMutationParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
SourceTerm: "Sensei",
RecommendedTranslation: "老师",
ReviewStatus: "draft",
Source: GlossarySourceRecord{
SourceKind: "manual",
ObservedUnixSeconds: 100,
},
})
if err != nil || add.Term.TermID != "term-sensei" {
t.Fatalf("add=%#v err=%v", add, err)
}
approved, err := client.GlossaryApprove(context.Background(), GlossaryReviewParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
Reviewer: "reviewer",
})
if err != nil || approved.Term.ReviewStatus != GlossaryStatusApproved {
t.Fatalf("approve=%#v err=%v", approved, err)
}
}
func TestApplicationErrorReturnsAPIError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "task.status" {