Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions ai/translate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fetchLinkedPages } from "@vertana/context-web";
import type { RequiredContextSource } from "@vertana/core";
import { fetchWebPage } from "@vertana/context-web";
import type { ContextSource, RequiredContextSource } from "@vertana/core";
import { translate as vertanaTranslate } from "@vertana/facade";
import type { LanguageModel } from "ai";

Expand Down Expand Up @@ -64,7 +64,7 @@ function createTagsContextSource(

export async function translate(options: TranslationOptions): Promise<string> {
// Build context sources
const contextSources: RequiredContextSource[] = [];
const contextSources: ContextSource[] = [];

const authorSource = createAuthorContextSource(
options.authorName,
Expand All @@ -75,12 +75,11 @@ export async function translate(options: TranslationOptions): Promise<string> {
const tagsSource = createTagsContextSource(options.tags);
if (tagsSource) contextSources.push(tagsSource);

// Add web context to fetch linked pages
const webContext = fetchLinkedPages({
text: options.text,
mediaType: "text/markdown",
});
contextSources.push(webContext);
// Expose linked-page fetching as a passive tool the model can call
// when it actually needs context, instead of dumping every linked
// page's full body into the system prompt up front (which made the
// translator confuse the context for the text to translate).
contextSources.push(fetchWebPage);

const result = await vertanaTranslate(
options.model,
Expand Down
149 changes: 149 additions & 0 deletions models/article.background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { articleContentTable, articleSourceTable } from "./schema.ts";
import {
restartArticleContentTranslations,
startArticleContentSummary,
startArticleContentTranslation,
} from "./article.ts";
Expand Down Expand Up @@ -134,3 +135,151 @@ test("startArticleContentTranslation() deletes queued rows when translation fail
});
});
});

test(
"restartArticleContentTranslations() resets each translation row to placeholder state and re-runs the translator",
async () => {
await withRollback(async (tx) => {
const fedCtx = createFedCtx(tx);
// Use the same `{} as never` translator stub as the existing
// failure-path test: it lets us observe the row pass through
// the placeholder state and then be cleaned up by the failure
// branch, which is enough to confirm
// restartArticleContentTranslations actually re-fired the
// translation pipeline against the reset row.
fedCtx.data.models = {
summarizer: {} as never,
translator: {} as never,
} as typeof fedCtx.data.models;
const author = await insertAccountWithActor(tx, {
username: "restarttranslator",
name: "Restart Translator",
email: "restarttranslator@example.com",
});
const requester = await insertAccountWithActor(tx, {
username: "restartrequester",
name: "Restart Requester",
email: "restartrequester@example.com",
});
const sourceId = generateUuidV7();
const published = new Date("2026-04-15T00:00:00.000Z");

const [articleSource] = await tx.insert(articleSourceTable).values({
id: sourceId,
accountId: author.account.id,
publishedYear: 2026,
slug: "restart-translation",
tags: [],
allowLlmTranslation: true,
published,
updated: published,
}).returning();
// The original row carries the *new* (post-edit) body — the
// shape updateArticleSource leaves behind for
// restartArticleContentTranslations to mirror into each
// translation placeholder.
await tx.insert(articleContentTable).values({
sourceId,
language: "en",
title: "New original title",
content: "New original body",
published,
updated: published,
});
// A previously completed translation that is now stale relative
// to the freshly edited original.
await tx.insert(articleContentTable).values({
sourceId,
language: "ko",
title: "Stale translated title",
content: "Stale translated body",
summary: "Stale summary.",
originalLanguage: "en",
translationRequesterId: requester.account.id,
beingTranslated: false,
published: new Date("2026-04-15T01:00:00.000Z"),
updated: new Date("2026-04-15T01:00:00.000Z"),
});

await restartArticleContentTranslations(fedCtx, articleSource);

// The row must briefly pass through placeholder state before
// the failing stub model causes the failure branch to delete
// it; assert on either observable.
await waitFor(async () => {
const current = await tx.query.articleContentTable.findFirst({
where: { sourceId, language: "ko" },
});
if (current == null) return true;
// Placeholder reset: title/content mirror the new original
// and beingTranslated has flipped back true with summary
// state cleared. translationRequesterId is preserved.
return current.beingTranslated === true &&
current.title === "New original title" &&
current.content === "New original body" &&
current.summary === null &&
current.translationRequesterId === requester.account.id;
});

// Eventually the failing stub causes deletion via the
// run-translation failure branch.
await waitFor(async () => {
const current = await tx.query.articleContentTable.findFirst({
where: { sourceId, language: "ko" },
});
return current == null;
});
});
},
);

test(
"restartArticleContentTranslations() is a no-op when the article has no translations",
async () => {
await withRollback(async (tx) => {
const fedCtx = createFedCtx(tx);
fedCtx.data.models = {
summarizer: {} as never,
translator: {} as never,
} as typeof fedCtx.data.models;
const author = await insertAccountWithActor(tx, {
username: "restartnotrans",
name: "Restart No Translations",
email: "restartnotrans@example.com",
});
const sourceId = generateUuidV7();
const published = new Date("2026-04-15T00:00:00.000Z");

const [articleSource] = await tx.insert(articleSourceTable).values({
id: sourceId,
accountId: author.account.id,
publishedYear: 2026,
slug: "restart-no-translations",
tags: [],
allowLlmTranslation: false,
published,
updated: published,
}).returning();
await tx.insert(articleContentTable).values({
sourceId,
language: "en",
title: "Original",
content: "Body",
published,
updated: published,
});

// Should return without throwing despite the deliberately bad
// translator stub.
await restartArticleContentTranslations(fedCtx, articleSource);

// Original row still in place and unchanged.
const original = await tx.query.articleContentTable.findFirst({
where: { sourceId, language: "en" },
});
assert.ok(original != null);
assert.equal(original.beingTranslated, false);
assert.equal(original.content, "Body");
});
},
);
135 changes: 135 additions & 0 deletions models/article.lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createArticle, updateArticle } from "./article.ts";
import { articleContentTable } from "./schema.ts";
import {
createFedCtx,
insertAccountWithActor,
Expand All @@ -12,6 +13,17 @@ const fakeModels = {
translator: {} as never,
};

async function waitFor(predicate: () => Promise<boolean>, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`Timed out waiting for async background state after ${timeoutMs}ms`,
);
}
Comment thread
dahlia marked this conversation as resolved.
Outdated

test("createArticle() creates a post and timeline entry for the author", async () => {
await withRollback(async (tx) => {
const fedCtx = createFedCtx(tx);
Expand Down Expand Up @@ -100,3 +112,126 @@ test("updateArticle() rewrites the persisted article post", async () => {
assert.match(storedPost.contentHtml, /<strong>body<\/strong>/);
});
});

test("updateArticle() resets existing translation rows when the body changes", async () => {
await withRollback(async (tx) => {
const fedCtx = createFedCtx(tx);
fedCtx.data.models = fakeModels as typeof fedCtx.data.models;
const author = await insertAccountWithActor(tx, {
username: "retranslateauthor",
name: "Retranslate Author",
email: "retranslateauthor@example.com",
});
const requester = await insertAccountWithActor(tx, {
username: "retranslaterequester",
name: "Retranslate Requester",
email: "retranslaterequester@example.com",
});
const article = await createArticle(fedCtx, {
accountId: author.account.id,
publishedYear: 2026,
slug: "retranslate-article",
tags: [],
allowLlmTranslation: true,
published: new Date("2026-04-15T00:00:00.000Z"),
updated: new Date("2026-04-15T00:00:00.000Z"),
title: "Original article",
content: "Original body",
language: "en",
});
assert.ok(article != null);
// Pre-existing completed translation row that the edit should
// invalidate.
await tx.insert(articleContentTable).values({
sourceId: article.articleSource.id,
language: "ko",
title: "Old translated title",
content: "Old translated body",
summary: "Old summary.",
originalLanguage: "en",
translationRequesterId: requester.account.id,
beingTranslated: false,
published: new Date("2026-04-15T01:00:00.000Z"),
updated: new Date("2026-04-15T01:00:00.000Z"),
});

const updated = await updateArticle(fedCtx, article.articleSource.id, {
content: "Edited body",
});
assert.ok(updated != null);

// The retranslation runs in the background. The placeholder
// reset is awaited synchronously, then the failing stub
// translator deletes the row via the failure-cleanup branch.
// Either observable is acceptable.
await waitFor(async () => {
const ko = await tx.query.articleContentTable.findFirst({
where: { sourceId: article.articleSource.id, language: "ko" },
});
if (ko == null) return true;
return ko.beingTranslated === true &&
ko.title === "Original article" &&
ko.content === "Edited body" &&
ko.summary === null;
});
});
});

test("updateArticle() leaves existing translations alone on title-only edits", async () => {
await withRollback(async (tx) => {
const fedCtx = createFedCtx(tx);
fedCtx.data.models = fakeModels as typeof fedCtx.data.models;
const author = await insertAccountWithActor(tx, {
username: "retranslatetitleauthor",
name: "Retranslate Title Author",
email: "retranslatetitleauthor@example.com",
});
const requester = await insertAccountWithActor(tx, {
username: "retranslatetitlerequester",
name: "Retranslate Title Requester",
email: "retranslatetitlerequester@example.com",
});
const article = await createArticle(fedCtx, {
accountId: author.account.id,
publishedYear: 2026,
slug: "retranslate-title-article",
tags: [],
allowLlmTranslation: true,
published: new Date("2026-04-15T00:00:00.000Z"),
updated: new Date("2026-04-15T00:00:00.000Z"),
title: "Original article",
content: "Original body",
language: "en",
});
assert.ok(article != null);
await tx.insert(articleContentTable).values({
sourceId: article.articleSource.id,
language: "ko",
title: "Existing translated title",
content: "Existing translated body",
summary: "Existing summary.",
originalLanguage: "en",
translationRequesterId: requester.account.id,
beingTranslated: false,
published: new Date("2026-04-15T01:00:00.000Z"),
updated: new Date("2026-04-15T01:00:00.000Z"),
});

const updated = await updateArticle(fedCtx, article.articleSource.id, {
title: "Renamed only",
});
assert.ok(updated != null);

// Title-only edits don't trigger retranslation; the ko row stays
// exactly as it was — no placeholder, no deletion, original
// summary preserved.
const ko = await tx.query.articleContentTable.findFirst({
where: { sourceId: article.articleSource.id, language: "ko" },
});
assert.ok(ko != null);
assert.equal(ko.beingTranslated, false);
assert.equal(ko.title, "Existing translated title");
assert.equal(ko.content, "Existing translated body");
assert.equal(ko.summary, "Existing summary.");
});
});
Loading
Loading