314 lines
11 KiB
Plaintext
314 lines
11 KiB
Plaintext
-- 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).
|
|
}
|