B1-2: distill auto-translation system into translation.allium spec

This commit is contained in:
2026-05-30 14:08:33 +02:00
parent dd760d0f2b
commit 7c7f629dd2
2 changed files with 127 additions and 2 deletions

View File

@@ -186,3 +186,128 @@ invariant FtsIncludesTranslations {
for t in post.translations:
includes_text(search_index(post), t.title)
}
-- ===========================================================================
-- Auto-Translation System
-- Distilled from: lib/bds/posts/auto_translation.ex
--
-- Two entry points share one translation primitive:
-- 1. ScheduleAutoTranslation - reactive, fired after a post is created or
-- updated. One background task per missing language produces a DRAFT
-- translation, then cascades to the post's linked media.
-- 2. FillMissingTranslations - batch maintenance action. Scans every
-- published post, AUTO-PUBLISHES the generated translations, fills linked
-- media, reports progress and returns a summary.
-- All AI work is gated by a resolvable endpoint and runs on background tasks.
-- ===========================================================================
config {
-- Background translations use the "AI" task group named per project.
auto_translation_task_group_name: String = "AI"
}
surface AutoTranslationControlSurface {
facing _: TranslationOperator
provides:
-- Reactive trigger: emitted by post create/update side effects.
PostSavedForAutoTranslation(post)
-- Batch trigger: "fill missing translations" maintenance action.
FillMissingTranslationsRequested(project)
}
invariant AutoTranslationGatedByEndpoint {
-- No automatic translation runs unless an endpoint is resolvable for the
-- current mode. Airplane mode needs url+model; online additionally needs an
-- api_key. When unconfigured, scheduling is a silent no-op.
-- See ai.allium AirplaneModeGating for endpoint selection.
for post in Posts:
auto_translation_runs(post) implies endpoint_configured(post.project)
}
invariant AutoTranslationSkipsDoNotTranslate {
-- Posts flagged do_not_translate never schedule background translation,
-- and the batch scan rejects them before computing missing languages.
for post in Posts where post.do_not_translate:
not auto_translation_runs(post)
}
invariant AutoTranslationOnlyMissingLanguages {
-- The target set is the configured languages (main_language plus
-- blog_languages, normalized + de-duplicated) minus the post's source
-- language and any language that already has a translation.
for post in Posts:
auto_translation_targets(post) =
configured_languages(post.project)
- source_language(post)
- post.available_languages
}
rule ScheduleAutoTranslation {
when: PostSavedForAutoTranslation(post)
requires: not post.do_not_translate
requires: endpoint_configured(post.project)
-- One background task per missing language, each producing a DRAFT
-- translation (not auto-published) followed by a media cascade.
for language in auto_translation_targets(post):
ensures: BackgroundTaskSubmitted(
group: post.project,
group_name: config.auto_translation_task_group_name)
ensures: AutoTranslatePost(post, language, auto_publish: false)
ensures: AutoTranslateMediaCascade(post, language)
@guidance
-- Best-effort: missing metadata, unconfigured endpoint, or
-- do_not_translate all collapse to a silent success with no task.
}
rule AutoTranslatePost {
when: AutoTranslatePost(post, language, auto_publish)
requires: trim(editor_body(post)) != ""
-- Calls the AI endpoint with the post's source language, then upserts a
-- translation marked auto_generated. Publishes only in the batch path.
ensures:
let translation = UpsertPostTranslation(post, language)
if auto_publish:
translation.status = published
else:
translation.status = draft
@guidance
-- An empty body yields a no_content_to_translate error and no
-- translation is created.
}
rule AutoTranslateMediaCascade {
when: AutoTranslateMediaCascade(post, language)
-- After a post translation, each linked media (ordered by sort_order) gets
-- its own background task when its source language differs from the target
-- and it lacks a translation in that language.
for m in post.linked_media:
if m.language != "" and m.language != language and not (language in m.available_languages):
ensures: BackgroundTaskSubmitted(
group: post.project,
group_name: config.auto_translation_task_group_name)
ensures: media/UpsertMediaTranslation(m, language)
}
rule FillMissingTranslations {
when: FillMissingTranslationsRequested(project)
-- Batch maintenance. No-op (nothing_to_do) when there is at most one
-- configured language or nothing is missing. Otherwise scans published,
-- non-do_not_translate posts and their linked media.
requires: configured_languages(project).count > 1
for post in project.posts where status = published and not do_not_translate:
for language in auto_translation_targets(post):
ensures: AutoTranslatePost(post, language, auto_publish: true)
ensures: AutoTranslateMediaCascade(post, language)
ensures: ProgressReported(project)
ensures: FillMissingTranslationsCompleted(project)
@guidance
-- Returns a summary of counts: translated_posts, translated_media,
-- failed_count, warned_count, and nothing_to_do. nothing_to_do is true
-- when there is at most one configured language or nothing is missing.
-- Per-item failures increment failed_count and never abort the batch.
-- Progress runs through scanning (0.0-0.15) then per-item (0.15-1.0).
}