-- allium: 1 -- bDS Translation System -- Scope: core (Wave 1) -- Distilled from: src/main/engine/PostEngine.ts (translation methods), -- postTranslationFileUtils.ts, MediaEngine.ts use "./post.allium" as post use "./media.allium" as media enum PostTranslationStatus { draft published } surface TranslationControlSurface { facing _: TranslationOperator provides: UpsertPostTranslationRequested(post, language, title, content, excerpt) DeletePostTranslationRequested(translation) ValidateTranslationsRequested(project) } surface TranslationRuntimeSurface { facing _: TranslationRuntime provides: PostPublished(post) PublishTranslationRequested(translation) TranslationEdited(translation, changes) } value SupportedLanguage { -- en, de, fr, it, es code: String } surface SupportedLanguageSurface { context language: SupportedLanguage exposes: language.code } entity PostTranslation { canonical_post: post/Post language: SupportedLanguage title: String excerpt: String? content: String? status: PostTranslationStatus file_path: String checksum: String? created_at: Timestamp updated_at: Timestamp published_at: Timestamp? -- Derived content_location: if status = published: file_path else: content transitions status { draft -> published published -> draft } } invariant TranslationFilesCarryFullMetadata { -- Translation markdown files include status and timestamps alongside -- language-specific fields. This allows each translation to be rebuilt -- independently. On rebuild, missing fields fall back to canonical post -- values for compatibility with legacy files. for t in PostTranslations where file_path != "": translation_file(t).has_fields( id, translation_for, language, title, excerpt?, status, created_at, updated_at, published_at ) } surface PostTranslationSurface { context translation: PostTranslation exposes: translation.canonical_post translation.language.code translation.title translation.excerpt when translation.excerpt != null translation.content when translation.content != null translation.status translation.file_path translation.checksum when translation.checksum != null translation.created_at translation.updated_at translation.published_at when translation.published_at != null translation.content_location } invariant UniqueTranslationPerLanguage { for a in PostTranslations: for b in PostTranslations: (a != b and a.canonical_post = b.canonical_post) implies a.language != b.language } invariant TranslationFilePath { -- posts/YYYY/MM/{slug}.{language}.md for t in PostTranslations where file_path != "": t.file_path = format("posts/{yyyy}/{mm}/{slug}.{lang}.md", yyyy: t.canonical_post.created_at.year, mm: t.canonical_post.created_at.month_padded, slug: t.canonical_post.slug, lang: t.language.code) } rule UpsertPostTranslation { when: UpsertPostTranslationRequested(post, language, title, content, excerpt) requires: not post.do_not_translate ensures: let translation = PostTranslation.created( canonical_post: post, language: language, title: title, content: content, excerpt: excerpt, status: draft, file_path: "" ) translation.status = draft -- If translation already exists, update it instead } rule PublishPostTranslation { when: PostPublished(post) -- All translations are also published when the canonical post is published for t in post.translations: ensures: PublishTranslationRequested(t) } rule PublishTranslation { when: PublishTranslationRequested(translation) requires: translation.status = draft ensures: translation.status = published ensures: translation.published_at = translation.published_at ?? now ensures: TranslationFileWritten(translation) ensures: translation.content = null -- Content moves to filesystem } rule ReopenPublishedTranslation { when: TranslationEdited(translation, changes) requires: translation.status = published requires: translation_edit_affects_published_content(changes) ensures: translation.status = draft ensures: translation.updated_at = now } rule DeletePostTranslation { when: DeletePostTranslationRequested(translation) ensures: not exists translation ensures: TranslationFileDeleted(translation) ensures: SearchIndexUpdated(translation.canonical_post) -- FTS index includes all translations of a post } rule ValidateTranslations { when: ValidateTranslationsRequested(project) -- Checks all posts against configured blog languages -- Reports: missing translations, orphan translation files, -- posts marked do_not_translate for post in project.posts where status = published: for lang in project.blog_languages: if lang != project.main_language: if not post.do_not_translate: if not (lang in post.available_languages): ensures: ValidationIssueReported(post, lang, "missing") @guidance -- This produces a validation report, not automatic fixes -- The report drives targeted re-rendering } invariant FtsIncludesTranslations { -- Full-text search index for a post includes stemmed content -- from all its translations, not just the canonical language for post in Posts: includes_text(search_index(post), post.title) 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). }