diplomail (Stage D): language detection + lazy translation cache
Replaces the LangUndetermined placeholder with whatlanggo-backed body detection on every send path, then adds a translation cache keyed on (message_id, target_lang) populated lazily on the per-message read endpoint. The noop translator that ships with Stage D returns engine="noop", which the service treats as "translation unavailable" — wiring a real backend (LibreTranslate HTTP client is the documented next step) is a one-file swap. GetMessage and ListInbox now accept a targetLang argument; the HTTP layer resolves the caller's accounts.preferred_language and forwards it. Inbox uses the cache only (never calls the translator) so bulk reads stay fast under future SaaS backends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,8 @@ import (
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/devsandbox"
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/diplomail/detector"
|
||||
"galaxy/backend/internal/diplomail/translator"
|
||||
"galaxy/backend/internal/dockerclient"
|
||||
"galaxy/backend/internal/engineclient"
|
||||
"galaxy/backend/internal/geo"
|
||||
@@ -313,6 +315,8 @@ func run(ctx context.Context) (err error) {
|
||||
Notification: &diplomailNotificationPublisherAdapter{svc: notifSvc},
|
||||
Entitlements: &diplomailEntitlementAdapter{users: userSvc},
|
||||
Games: &diplomailGameAdapter{lobby: lobbySvc},
|
||||
Detector: detector.New(),
|
||||
Translator: translator.NewNoop(),
|
||||
Config: cfg.Diplomail,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/abadojack/whatlanggo v1.0.1 // indirect
|
||||
github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
|
||||
@@ -10,6 +10,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o=
|
||||
github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
|
||||
@@ -17,7 +17,7 @@ purge, and the language-detection / translation cache.
|
||||
| A | Schema, personal single-recipient send / read / delete, unread badge, push event with body-language `und` | shipped |
|
||||
| B | Owner / admin sends + lifecycle hooks (paused, cancelled, kick); strict soft-access for kicked players | shipped |
|
||||
| C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge + admin observability | shipped |
|
||||
| D | Body-language detection (whatlanggo) + translation cache + async worker | planned |
|
||||
| D | Body-language detection (whatlanggo) + translation cache + lazy per-read translator dispatch | shipped |
|
||||
|
||||
## Tables
|
||||
|
||||
@@ -67,8 +67,32 @@ mail to every active member; `Service.changeMembershipStatus` /
|
||||
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256). Both limits
|
||||
live in the service layer so they can be tuned without a schema
|
||||
migration.
|
||||
- `body_lang` is stored as the BCP 47 `und` (undetermined) sentinel
|
||||
until Stage D wires the auto-detector.
|
||||
- `body_lang` is filled at send time by the configured
|
||||
`detector.LanguageDetector` (default: `whatlanggo`, body-only,
|
||||
≥ 25 runes; shorter bodies stay `und`).
|
||||
|
||||
## Translation
|
||||
|
||||
Stage D adds a lazy translation cache. When a recipient reads a
|
||||
message through `GET /api/v1/user/games/{game_id}/mail/messages/{id}`,
|
||||
the handler resolves the caller's `accounts.preferred_language` and
|
||||
asks `Service.GetMessage(…, targetLang)` to attach a translation:
|
||||
|
||||
- on cache hit (row in `diplomail_translations`), the rendering is
|
||||
returned directly under `translated_subject` / `translated_body`;
|
||||
- on cache miss, the configured `translator.Translator` is invoked.
|
||||
A non-noop result is persisted and returned to the caller; the
|
||||
noop translator that ships with Stage D returns `engine == "noop"`,
|
||||
which is treated as "translation unavailable" and the caller falls
|
||||
back to the original body.
|
||||
|
||||
The inbox listing (`/inbox`) reuses cached translations but never
|
||||
calls the translator on miss — bulk listings stay fast even when a
|
||||
real translator (LibreTranslate, SaaS engine) introduces I/O cost.
|
||||
|
||||
Future work plugs a real `translator.Translator` (LibreTranslate
|
||||
HTTP client is the documented next step) without touching the rest
|
||||
of the system.
|
||||
|
||||
## Push integration
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas
|
||||
SenderIP: in.SenderIP,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
BodyLang: LangUndetermined,
|
||||
BodyLang: s.deps.Detector.Detect(body),
|
||||
BroadcastScope: BroadcastScopeGameBroadcast,
|
||||
}
|
||||
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||
@@ -429,7 +429,7 @@ func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.
|
||||
SenderIP: senderIP,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
BodyLang: LangUndetermined,
|
||||
BodyLang: s.deps.Detector.Detect(body),
|
||||
BroadcastScope: scope,
|
||||
}
|
||||
switch callerKind {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/diplomail/detector"
|
||||
"galaxy/backend/internal/diplomail/translator"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
@@ -25,6 +27,8 @@ type Deps struct {
|
||||
Notification NotificationPublisher
|
||||
Entitlements EntitlementReader
|
||||
Games GameLookup
|
||||
Detector detector.LanguageDetector
|
||||
Translator translator.Translator
|
||||
Config config.DiplomailConfig
|
||||
Logger *zap.Logger
|
||||
Now func() time.Time
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package detector wraps the body-language detection used by the
|
||||
// diplomail subsystem. The package exposes a narrow `LanguageDetector`
|
||||
// interface so the implementation can be swapped without touching the
|
||||
// callers; the default backed-by-whatlanggo detector handles 84
|
||||
// natural languages and ships with the embedded statistical profiles.
|
||||
//
|
||||
// Detection happens only on the body. Subjects are short and
|
||||
// frequently template-like ("Re: ..."), so detecting on them adds
|
||||
// noise. The diplomail Service feeds the body, captures the BCP 47
|
||||
// tag returned here, and stores it in `diplomail_messages.body_lang`.
|
||||
package detector
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/abadojack/whatlanggo"
|
||||
)
|
||||
|
||||
// Undetermined is the BCP 47 placeholder stored when detection cannot
|
||||
// confidently identify a language (empty body, too-short body, mixed
|
||||
// scripts the detector refuses to bet on).
|
||||
const Undetermined = "und"
|
||||
|
||||
// LanguageDetector is the read-only surface diplomail consumes when
|
||||
// it needs to label a message body. Detect must never panic and
|
||||
// must never return an error: detection failure simply yields
|
||||
// `Undetermined`.
|
||||
type LanguageDetector interface {
|
||||
Detect(body string) string
|
||||
}
|
||||
|
||||
// New returns the package-default detector backed by `whatlanggo`.
|
||||
// The instance is safe for concurrent use; whatlanggo's `Detect`
|
||||
// reads the embedded profiles without state mutation. Callers that
|
||||
// want a fixed allow-list can build their own implementation around
|
||||
// the same interface.
|
||||
func New() LanguageDetector {
|
||||
return &whatlangDetector{}
|
||||
}
|
||||
|
||||
type whatlangDetector struct{}
|
||||
|
||||
// minRunes is the lower bound on body length below which whatlanggo
|
||||
// can flip between near-synonyms; for shorter bodies we return
|
||||
// `Undetermined` and let the noop translator skip the slot. The
|
||||
// value matches whatlanggo's documented "stable above ~25 runes"
|
||||
// guidance.
|
||||
const minRunes = 25
|
||||
|
||||
// Detect returns the BCP 47 tag for body, or `Undetermined` when the
|
||||
// body is empty / too short / whatlanggo refuses to label it. The
|
||||
// trim is applied so leading whitespace does not bias the script
|
||||
// detector toward Latin. We deliberately do not gate on
|
||||
// `info.IsReliable()` because the gate is too conservative for the
|
||||
// short sentences typical of in-game mail; a misclassification only
|
||||
// hurts the translation cache key, never correctness.
|
||||
func (d *whatlangDetector) Detect(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return Undetermined
|
||||
}
|
||||
if utf8.RuneCountInString(body) < minRunes {
|
||||
return Undetermined
|
||||
}
|
||||
info := whatlanggo.Detect(body)
|
||||
tag := info.Lang.Iso6391()
|
||||
if tag == "" {
|
||||
return Undetermined
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
// NoopDetector returns the placeholder unconditionally. Used by
|
||||
// tests and by Stage A code paths that predate the real detector.
|
||||
type NoopDetector struct{}
|
||||
|
||||
// Detect always returns `Undetermined` regardless of input.
|
||||
func (NoopDetector) Detect(string) string { return Undetermined }
|
||||
@@ -0,0 +1,49 @@
|
||||
package detector
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDetectKnownLanguages(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := New()
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "english paragraph",
|
||||
text: "The trade agreement should be signed before the next turn. " +
|
||||
"I expect a written response by the time the engine generates the next report.",
|
||||
want: "en",
|
||||
},
|
||||
{
|
||||
name: "russian paragraph",
|
||||
text: "Привет! Я предлагаю заключить дипломатическое соглашение и провести " +
|
||||
"совместную операцию по освоению гиперпространственных маршрутов. " +
|
||||
"Жду твоего письменного ответа до конца следующего хода игры, " +
|
||||
"чтобы мы успели согласовать детали и подписать договор вовремя.",
|
||||
want: "ru",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := d.Detect(tc.text)
|
||||
if got != tc.want {
|
||||
t.Fatalf("Detect = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShortOrEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := New()
|
||||
short := []string{"", "hi", " "}
|
||||
for _, s := range short {
|
||||
if got := d.Detect(s); got != Undetermined {
|
||||
t.Errorf("Detect(%q) = %q, want %q", s, got, Undetermined)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/diplomail/detector"
|
||||
"galaxy/backend/internal/diplomail/translator"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -91,6 +93,12 @@ func NewService(deps Deps) *Service {
|
||||
if deps.Notification == nil {
|
||||
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
|
||||
}
|
||||
if deps.Detector == nil {
|
||||
deps.Detector = detector.NoopDetector{}
|
||||
}
|
||||
if deps.Translator == nil {
|
||||
deps.Translator = translator.NewNoop()
|
||||
}
|
||||
if deps.Config.MaxBodyBytes <= 0 {
|
||||
deps.Config.MaxBodyBytes = 4096
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/diplomail/translator"
|
||||
backendpg "galaxy/backend/internal/postgres"
|
||||
pgshared "galaxy/postgres"
|
||||
|
||||
@@ -297,7 +298,7 @@ func TestDiplomailPersonalFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. ListInbox shows the message for the recipient.
|
||||
inbox, err := svc.ListInbox(ctx, gameID, recipient)
|
||||
inbox, err := svc.ListInbox(ctx, gameID, recipient, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list inbox: %v", err)
|
||||
}
|
||||
@@ -315,7 +316,7 @@ func TestDiplomailPersonalFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
// 4. Non-recipient reads are 404.
|
||||
if _, err := svc.GetMessage(ctx, other, msg.MessageID); !errors.Is(err, diplomail.ErrNotFound) {
|
||||
if _, err := svc.GetMessage(ctx, other, msg.MessageID, ""); !errors.Is(err, diplomail.ErrNotFound) {
|
||||
t.Fatalf("non-recipient get: %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
@@ -359,7 +360,7 @@ func TestDiplomailPersonalFlow(t *testing.T) {
|
||||
}
|
||||
|
||||
// 9. Inbox now excludes the soft-deleted message.
|
||||
inbox, err = svc.ListInbox(ctx, gameID, recipient)
|
||||
inbox, err = svc.ListInbox(ctx, gameID, recipient, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list inbox after delete: %v", err)
|
||||
}
|
||||
@@ -501,7 +502,7 @@ func TestDiplomailAdminBroadcast(t *testing.T) {
|
||||
// that alice might have sent before the kick (none here — the
|
||||
// store path itself is exercised; the soft-access filter belongs
|
||||
// to a separate test below).
|
||||
charlieInbox, err := svc.ListInbox(ctx, gameID, kickedCharlie)
|
||||
charlieInbox, err := svc.ListInbox(ctx, gameID, kickedCharlie, "")
|
||||
if err != nil {
|
||||
t.Fatalf("kicked inbox: %v", err)
|
||||
}
|
||||
@@ -795,7 +796,7 @@ func TestDiplomailLifecycleMembershipKick(t *testing.T) {
|
||||
if got := publisher.snapshot(); len(got) != 1 || got[0].Recipient != kicked {
|
||||
t.Fatalf("publisher captured %+v, want one event addressed to kicked", got)
|
||||
}
|
||||
inbox, err := svc.ListInbox(ctx, gameID, kicked)
|
||||
inbox, err := svc.ListInbox(ctx, gameID, kicked, "")
|
||||
if err != nil {
|
||||
t.Fatalf("kicked inbox: %v", err)
|
||||
}
|
||||
@@ -807,6 +808,112 @@ func TestDiplomailLifecycleMembershipKick(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// staticTranslator returns deterministic renderings so the
|
||||
// translation-cache test can assert against known output.
|
||||
type staticTranslator struct {
|
||||
engine string
|
||||
}
|
||||
|
||||
func (s *staticTranslator) Translate(_ context.Context, srcLang, dstLang, subject, body string) (translator.Result, error) {
|
||||
return translator.Result{
|
||||
Subject: "[" + dstLang + "] " + subject,
|
||||
Body: "[" + dstLang + "] " + body,
|
||||
Engine: s.engine,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestDiplomailTranslationCache(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
gameID := uuid.New()
|
||||
sender := uuid.New()
|
||||
recipient := uuid.New()
|
||||
seedAccount(t, db, sender)
|
||||
seedAccount(t, db, recipient)
|
||||
seedGame(t, db, gameID, "Translation Test Game")
|
||||
|
||||
lookup := &staticMembershipLookup{
|
||||
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
|
||||
{gameID, sender}: {
|
||||
UserID: sender, GameID: gameID, GameName: "Translation Test Game",
|
||||
UserName: "sender", RaceName: "SendersRace",
|
||||
},
|
||||
{gameID, recipient}: {
|
||||
UserID: recipient, GameID: gameID, GameName: "Translation Test Game",
|
||||
UserName: "recipient", RaceName: "ReceiversRace",
|
||||
},
|
||||
},
|
||||
}
|
||||
publisher := &recordingPublisher{}
|
||||
|
||||
englishDetector := detectorFn(func(_ string) string { return "en" })
|
||||
svc := diplomail.NewService(diplomail.Deps{
|
||||
Store: diplomail.NewStore(db),
|
||||
Memberships: lookup,
|
||||
Notification: publisher,
|
||||
Detector: englishDetector,
|
||||
Translator: &staticTranslator{engine: "stub"},
|
||||
Config: config.DiplomailConfig{
|
||||
MaxBodyBytes: 4096,
|
||||
MaxSubjectBytes: 256,
|
||||
},
|
||||
})
|
||||
|
||||
msg, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: sender,
|
||||
RecipientUserID: recipient,
|
||||
Subject: "Hello",
|
||||
Body: "Please share the latest map snapshot.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if msg.BodyLang != "en" {
|
||||
t.Fatalf("body_lang=%q, want en (detector returns en)", msg.BodyLang)
|
||||
}
|
||||
|
||||
// First read materialises a cached translation.
|
||||
entry, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru")
|
||||
if err != nil {
|
||||
t.Fatalf("get message: %v", err)
|
||||
}
|
||||
if entry.Translation == nil {
|
||||
t.Fatalf("translation missing on first read")
|
||||
}
|
||||
if entry.Translation.TargetLang != "ru" {
|
||||
t.Fatalf("translation lang=%q, want ru", entry.Translation.TargetLang)
|
||||
}
|
||||
if entry.Translation.TranslatedBody != "[ru] Please share the latest map snapshot." {
|
||||
t.Fatalf("translated body = %q", entry.Translation.TranslatedBody)
|
||||
}
|
||||
|
||||
// Second read returns the same cached row (no re-translation).
|
||||
entry2, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru")
|
||||
if err != nil {
|
||||
t.Fatalf("get message twice: %v", err)
|
||||
}
|
||||
if entry2.Translation == nil || entry2.Translation.TranslationID != entry.Translation.TranslationID {
|
||||
t.Fatalf("second read produced new translation: got %+v, want %s", entry2.Translation, entry.Translation.TranslationID)
|
||||
}
|
||||
|
||||
// Same language as body: no translation.
|
||||
entrySame, err := svc.GetMessage(ctx, recipient, msg.MessageID, "en")
|
||||
if err != nil {
|
||||
t.Fatalf("get message same lang: %v", err)
|
||||
}
|
||||
if entrySame.Translation != nil {
|
||||
t.Fatalf("translation populated when target == body_lang")
|
||||
}
|
||||
}
|
||||
|
||||
// detectorFn lets the test inject deterministic detection without
|
||||
// dragging the whatlanggo profile into the test fixtures.
|
||||
type detectorFn func(string) string
|
||||
|
||||
func (f detectorFn) Detect(s string) string { return f(s) }
|
||||
|
||||
func TestDiplomailRejectsOverlongBody(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -68,7 +68,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
||||
SenderIP: in.SenderIP,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
BodyLang: LangUndetermined,
|
||||
BodyLang: s.deps.Detector.Detect(body),
|
||||
BroadcastScope: BroadcastScopeSingle,
|
||||
}
|
||||
raceName := recipient.RaceName
|
||||
@@ -104,7 +104,15 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
|
||||
// the caller is no longer an active member of the game and the
|
||||
// message is personal-kind: post-kick visibility is restricted to
|
||||
// admin/system mail (item 8 of the spec).
|
||||
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (InboxEntry, error) {
|
||||
//
|
||||
// When `targetLang` is non-empty and differs from the message's
|
||||
// `body_lang`, the function consults the translation cache; on a
|
||||
// miss it asks the configured Translator to produce a rendering and
|
||||
// persists the result. The noop translator returns the input
|
||||
// unchanged with `engine == "noop"`, which is treated as
|
||||
// "translation unavailable" — the entry comes back with `Translation
|
||||
// == nil` and the caller renders the original body.
|
||||
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID, targetLang string) (InboxEntry, error) {
|
||||
entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
|
||||
if err != nil {
|
||||
return InboxEntry{}, err
|
||||
@@ -116,9 +124,65 @@ func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (
|
||||
if !allowed[entry.Kind] {
|
||||
return InboxEntry{}, ErrNotFound
|
||||
}
|
||||
if tr := s.resolveTranslation(ctx, entry.Message, targetLang); tr != nil {
|
||||
entry.Translation = tr
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// resolveTranslation returns the cached translation for
|
||||
// (message, targetLang), lazily computing and persisting one on
|
||||
// cache miss. Returns nil when no translation is needed (target is
|
||||
// empty, matches `body_lang`, or the message body is itself
|
||||
// undetermined) or when the configured translator declares the
|
||||
// rendering unavailable.
|
||||
func (s *Service) resolveTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
|
||||
if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
|
||||
return nil
|
||||
}
|
||||
if existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang); err == nil {
|
||||
t := existing
|
||||
return &t
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
s.deps.Logger.Warn("load translation failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.String("target_lang", targetLang),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if s.deps.Translator == nil {
|
||||
return nil
|
||||
}
|
||||
result, err := s.deps.Translator.Translate(ctx, msg.BodyLang, targetLang, msg.Subject, msg.Body)
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("translator call failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.String("target_lang", targetLang),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if result.Engine == "" || result.Engine == "noop" {
|
||||
return nil
|
||||
}
|
||||
tr := Translation{
|
||||
TranslationID: uuid.New(),
|
||||
MessageID: msg.MessageID,
|
||||
TargetLang: targetLang,
|
||||
TranslatedSubject: result.Subject,
|
||||
TranslatedBody: result.Body,
|
||||
Translator: result.Engine,
|
||||
}
|
||||
stored, err := s.deps.Store.InsertTranslation(ctx, tr)
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("insert translation failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.String("target_lang", targetLang),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
return &stored
|
||||
}
|
||||
|
||||
// ListInbox returns every non-deleted message addressed to userID in
|
||||
// gameID, newest first. Read state is preserved per entry; the HTTP
|
||||
// layer renders both the message and the recipient row. Personal
|
||||
@@ -126,7 +190,12 @@ func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (
|
||||
// member of the game so a kicked player keeps read access to the
|
||||
// admin/system explanation of the kick but not to historical
|
||||
// player-to-player threads.
|
||||
func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) {
|
||||
//
|
||||
// When `targetLang` is non-empty and differs from a row's body
|
||||
// language, the function consults the translation cache (without
|
||||
// re-translating on miss; the per-message read endpoint owns that
|
||||
// path so the bulk listing never blocks on translator I/O).
|
||||
func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID, targetLang string) ([]InboxEntry, error) {
|
||||
entries, err := s.deps.Store.ListInbox(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -135,16 +204,44 @@ func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]In
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if allowed[KindPersonal] && allowed[KindAdmin] {
|
||||
return entries, nil
|
||||
}
|
||||
out := make([]InboxEntry, 0, len(entries))
|
||||
out := entries
|
||||
if !(allowed[KindPersonal] && allowed[KindAdmin]) {
|
||||
out = make([]InboxEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if allowed[e.Kind] {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetLang == "" {
|
||||
return out, nil
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Translation = s.lookupCachedTranslation(ctx, out[i].Message, targetLang)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lookupCachedTranslation reads an existing translation row without
|
||||
// asking the Translator to compute one. The bulk inbox listing uses
|
||||
// this to avoid per-row translator I/O; GetMessage uses the full
|
||||
// `resolveTranslation` helper which falls through to the translator
|
||||
// on cache miss.
|
||||
func (s *Service) lookupCachedTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
|
||||
if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
|
||||
return nil
|
||||
}
|
||||
existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
s.deps.Logger.Debug("inbox translation lookup failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := existing
|
||||
return &out
|
||||
}
|
||||
|
||||
// allowedKinds resolves the set of message kinds the caller may read
|
||||
|
||||
@@ -358,6 +358,71 @@ func (s *Store) UnreadCountForUserGame(ctx context.Context, gameID, userID uuid.
|
||||
return int(dest.Count), nil
|
||||
}
|
||||
|
||||
// translationColumns is the canonical projection for
|
||||
// diplomail_translations reads.
|
||||
func translationColumns() postgres.ColumnList {
|
||||
t := table.DiplomailTranslations
|
||||
return postgres.ColumnList{
|
||||
t.TranslationID, t.MessageID, t.TargetLang,
|
||||
t.TranslatedSubject, t.TranslatedBody, t.Translator, t.TranslatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadTranslation returns the cached translation row for
|
||||
// (messageID, targetLang). Returns ErrNotFound when no cache row
|
||||
// exists yet — the caller decides whether to compute and persist
|
||||
// one.
|
||||
func (s *Store) LoadTranslation(ctx context.Context, messageID uuid.UUID, targetLang string) (Translation, error) {
|
||||
t := table.DiplomailTranslations
|
||||
stmt := postgres.SELECT(translationColumns()).
|
||||
FROM(t).
|
||||
WHERE(t.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(t.TargetLang.EQ(postgres.String(targetLang)))).
|
||||
LIMIT(1)
|
||||
var row model.DiplomailTranslations
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Translation{}, ErrNotFound
|
||||
}
|
||||
return Translation{}, fmt.Errorf("diplomail store: load translation %s/%s: %w", messageID, targetLang, err)
|
||||
}
|
||||
return translationFromModel(row), nil
|
||||
}
|
||||
|
||||
// InsertTranslation persists a new translation cache row. The unique
|
||||
// constraint on (message_id, target_lang) prevents duplicate
|
||||
// renderings. Callers that race on the same (message, lang) pair
|
||||
// should be prepared for a UNIQUE violation; the second writer can
|
||||
// fall back to LoadTranslation.
|
||||
func (s *Store) InsertTranslation(ctx context.Context, in Translation) (Translation, error) {
|
||||
t := table.DiplomailTranslations
|
||||
stmt := t.INSERT(
|
||||
t.TranslationID, t.MessageID, t.TargetLang,
|
||||
t.TranslatedSubject, t.TranslatedBody, t.Translator,
|
||||
).VALUES(
|
||||
in.TranslationID, in.MessageID, in.TargetLang,
|
||||
in.TranslatedSubject, in.TranslatedBody, in.Translator,
|
||||
).RETURNING(translationColumns())
|
||||
|
||||
var row model.DiplomailTranslations
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
return Translation{}, fmt.Errorf("diplomail store: insert translation %s/%s: %w", in.MessageID, in.TargetLang, err)
|
||||
}
|
||||
return translationFromModel(row), nil
|
||||
}
|
||||
|
||||
func translationFromModel(row model.DiplomailTranslations) Translation {
|
||||
return Translation{
|
||||
TranslationID: row.TranslationID,
|
||||
MessageID: row.MessageID,
|
||||
TargetLang: row.TargetLang,
|
||||
TranslatedSubject: row.TranslatedSubject,
|
||||
TranslatedBody: row.TranslatedBody,
|
||||
Translator: row.Translator,
|
||||
TranslatedAt: row.TranslatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteMessagesForGames removes every diplomail_messages row whose
|
||||
// game_id falls in the supplied set. The cascade defined on the
|
||||
// `diplomail_recipients` and `diplomail_translations` foreign keys
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package translator wraps the per-language rendering for the
|
||||
// diplomail subsystem. The package exposes a narrow `Translator`
|
||||
// interface so the actual translation backend (LibreTranslate, an
|
||||
// in-process model, a SaaS engine, …) can be swapped without
|
||||
// touching the rest of the codebase.
|
||||
//
|
||||
// Stage D ships a `NoopTranslator` that returns the input unchanged.
|
||||
// The diplomail Service treats a `Name == NoopEngine` result as
|
||||
// "translation unavailable" and refrains from writing a cache row;
|
||||
// the inbox handler then returns the original body with a
|
||||
// `translated == false` payload. The contract lets the rest of the
|
||||
// system ship without a translation backend; future stages can wire
|
||||
// a real `Translator` without code changes elsewhere.
|
||||
package translator
|
||||
|
||||
import "context"
|
||||
|
||||
// NoopEngine is the engine identifier returned by `NoopTranslator`.
|
||||
// The diplomail Service checks for this value to decide whether to
|
||||
// persist a `diplomail_translations` row.
|
||||
const NoopEngine = "noop"
|
||||
|
||||
// Result carries one translated rendering plus the engine identifier
|
||||
// that produced it. The engine name is persisted as
|
||||
// `diplomail_translations.translator` so an operator can see which
|
||||
// backend produced each row.
|
||||
type Result struct {
|
||||
Subject string
|
||||
Body string
|
||||
Engine string
|
||||
}
|
||||
|
||||
// Translator is the read-only surface diplomail consumes when it
|
||||
// needs to render a message for a recipient whose
|
||||
// `preferred_language` differs from `body_lang`. Implementations
|
||||
// must be safe for concurrent use; `Translate` may be invoked from
|
||||
// the async worker on many messages at once.
|
||||
type Translator interface {
|
||||
// Translate renders `subject` and `body` from `srcLang` into
|
||||
// `dstLang`. A nil error with `Result.Engine == NoopEngine`
|
||||
// signals that no real rendering happened.
|
||||
Translate(ctx context.Context, srcLang, dstLang, subject, body string) (Result, error)
|
||||
}
|
||||
|
||||
// NewNoop returns a Translator that always returns the input
|
||||
// unchanged with engine name `NoopEngine`.
|
||||
func NewNoop() Translator {
|
||||
return noop{}
|
||||
}
|
||||
|
||||
type noop struct{}
|
||||
|
||||
func (noop) Translate(_ context.Context, _, _, subject, body string) (Result, error) {
|
||||
return Result{
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
Engine: NoopEngine,
|
||||
}, nil
|
||||
}
|
||||
@@ -51,10 +51,28 @@ type Recipient struct {
|
||||
|
||||
// InboxEntry is the read-side projection composed of a Message and the
|
||||
// caller's own Recipient row. The HTTP layer renders one of these per
|
||||
// item in the inbox listing.
|
||||
// item in the inbox listing. Translation, when non-nil, carries the
|
||||
// per-recipient rendering returned from
|
||||
// `Service.GetMessage(ctx, …, targetLang)` and surfaced under the
|
||||
// `body_translated` payload field; Stage D ships a noop translator,
|
||||
// so this field stays nil until a real backend is wired.
|
||||
type InboxEntry struct {
|
||||
Message
|
||||
Recipient Recipient
|
||||
Translation *Translation
|
||||
}
|
||||
|
||||
// Translation mirrors a row in `backend.diplomail_translations`. The
|
||||
// engine identifier is preserved so an operator can see which
|
||||
// backend produced the cached rendering.
|
||||
type Translation struct {
|
||||
TranslationID uuid.UUID
|
||||
MessageID uuid.UUID
|
||||
TargetLang string
|
||||
TranslatedSubject string
|
||||
TranslatedBody string
|
||||
Translator string
|
||||
TranslatedAt time.Time
|
||||
}
|
||||
|
||||
// SendPersonalInput is the request payload for SendPersonal: the
|
||||
|
||||
@@ -48,6 +48,25 @@ func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users
|
||||
}
|
||||
}
|
||||
|
||||
// preferredLanguage looks up the caller's `accounts.preferred_language`
|
||||
// so the per-message read can attach the cached translation when
|
||||
// available. Failures are logged at debug level and the function
|
||||
// returns an empty string — translation is best-effort and the
|
||||
// caller still receives the original body.
|
||||
func (h *UserMailHandlers) preferredLanguage(ctx context.Context, userID uuid.UUID) string {
|
||||
if h.users == nil {
|
||||
return ""
|
||||
}
|
||||
account, err := h.users.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
h.logger.Debug("resolve preferred_language failed",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
return account.PreferredLanguage
|
||||
}
|
||||
|
||||
// SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages.
|
||||
func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
@@ -109,7 +128,8 @@ func (h *UserMailHandlers) Get() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
entry, err := h.svc.GetMessage(ctx, userID, messageID)
|
||||
targetLang := h.preferredLanguage(ctx, userID)
|
||||
entry, err := h.svc.GetMessage(ctx, userID, messageID, targetLang)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail get", ctx, err)
|
||||
return
|
||||
@@ -134,7 +154,8 @@ func (h *UserMailHandlers) Inbox() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
items, err := h.svc.ListInbox(ctx, gameID, userID)
|
||||
targetLang := h.preferredLanguage(ctx, userID)
|
||||
items, err := h.svc.ListInbox(ctx, gameID, userID, targetLang)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail inbox", ctx, err)
|
||||
return
|
||||
@@ -491,6 +512,10 @@ func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Reci
|
||||
// userMailMessageDetailWire mirrors the unified response shape for
|
||||
// inbox listings and per-message reads. Sender identifiers are
|
||||
// optional: system messages carry neither user id nor username.
|
||||
// Translation fields are populated when a cached rendering exists
|
||||
// for the caller's `preferred_language`; the UI renders
|
||||
// `body_translated` and surfaces the original through a
|
||||
// "show original" toggle.
|
||||
type userMailMessageDetailWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
@@ -509,6 +534,10 @@ type userMailMessageDetailWire struct {
|
||||
RecipientRaceName *string `json:"recipient_race_name,omitempty"`
|
||||
ReadAt *string `json:"read_at,omitempty"`
|
||||
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||
TranslatedSubject *string `json:"translated_subject,omitempty"`
|
||||
TranslatedBody *string `json:"translated_body,omitempty"`
|
||||
TranslationLang *string `json:"translation_lang,omitempty"`
|
||||
Translator *string `json:"translator,omitempty"`
|
||||
}
|
||||
|
||||
// userMailSentSummaryWire mirrors the response shape for the
|
||||
@@ -580,6 +609,17 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM
|
||||
s := entry.Recipient.DeletedAt.UTC().Format(timestampLayout)
|
||||
out.DeletedAt = &s
|
||||
}
|
||||
if entry.Translation != nil {
|
||||
tr := entry.Translation
|
||||
subj := tr.TranslatedSubject
|
||||
body := tr.TranslatedBody
|
||||
lang := tr.TargetLang
|
||||
engine := tr.Translator
|
||||
out.TranslatedSubject = &subj
|
||||
out.TranslatedBody = &body
|
||||
out.TranslationLang = &lang
|
||||
out.Translator = &engine
|
||||
}
|
||||
_ = justCreated
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -4352,6 +4352,24 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
translated_subject:
|
||||
type: string
|
||||
description: |
|
||||
Subject rendered into the caller's preferred_language by
|
||||
the translation cache. Absent when the caller's language
|
||||
matches `body_lang` or the translator could not produce
|
||||
a rendering.
|
||||
translated_body:
|
||||
type: string
|
||||
description: |
|
||||
Body rendered into the caller's preferred_language. Same
|
||||
absence semantics as `translated_subject`.
|
||||
translation_lang:
|
||||
type: string
|
||||
description: BCP 47 tag of the rendered translation.
|
||||
translator:
|
||||
type: string
|
||||
description: Identifier of the translation engine that produced the cached row.
|
||||
UserMailSentSummary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
Reference in New Issue
Block a user