diplomail (Stage D): language detection + lazy translation cache
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m0s
Tests · Integration / integration (pull_request) Successful in 1m35s

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:
Ilia Denisov
2026-05-15 19:16:12 +02:00
parent 362f92e520
commit e22f4b7800
16 changed files with 599 additions and 24 deletions
+65
View File
@@ -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