Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
28 changes: 28 additions & 0 deletions docs/slide-translations-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,31 @@ review screen writes `reviewed` edits straight into `slideTranslations` (and sti
POSTs to the library for persistence), and the Python service's seed now
**protects existing `reviewed` entries** rather than overwriting them. See "Two tiers"
and Phase C above.

**The `reviewed`/`auto` tier, removed (post-build).** Everything above that turns on a
stored `status: 'reviewed' | 'auto'` (and its `reviewedAt` stamp) is history: the field is
gone. It never earned its keep. Viewers were told "unreviewed" about text nobody was going
to fix mid-service, which is noise they can't act on; and in practice the thing that decides
whether a slide gets a human's attention is a **note**, not a tier. What replaced it:

- `SlideTranslationEntry` is `{ text, provenance }`. `provenance` still records where a
translation came from; nothing branches on it. `'imported'` went with the tier — it existed
only for the `firstDraftBySlide` path the alignment rethink removed (Phase C-import above),
so it had no writer left. `'bible'`/`'creed'` are still unwritten but kept: they are the
vocabulary for the agent *claiming* a slide is the published wording, which is worth having
because a harness can check the claim against the looked-up source.
- `resolveSlideTranslation` returns the first entry along the fallback chain. A Creole
viewer with no Creole text now sees the French one whatever produced it — previously an
unreviewed French text was withheld and the slide read "(not translated)".
- The library is still the durable tier, and still only holds what a human saved. The review
screen's per-cell chip says **Saved**, not Reviewed: it is about persistence, not quality.
- The Python work-ahead translator no longer needs to recognize a protected entry — it
writes only keys that are **missing** (`_store_translations`), so it can't clobber anyone.

**Notes, surfaced (same change).** `set_translations` always let the model attach a
per-segment `note` — "a genuine ambiguity or judgement call worth flagging" — and we dropped
it on the floor. Notes are now carried on the conversation (`SlideConversation.notes`,
content-addressed by language + source text, replaced wholesale by each agent run) and are
what the review screen is organized around: the service-item list badges each item with its
note count, flagged rows are tinted and marked, and a flagged cell leads with its note above
the textarea. An item with no notes is an item nobody needs to open.
77 changes: 77 additions & 0 deletions nlp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,3 +526,80 @@ describe('buildSeedConversationPrompt', () => {
expect(prompt).not.toContain('\\n');
});
});

describe('runSlideTranslationAgent — per-slide review notes', () => {
const slides = ['Praise the Lord', 'Forever'];

it('carries a note attached in set_translations, keyed by source text', async () => {
const { provider } = fakeAgentProvider({
languages: [
{
language: 'French',
segments: [
{ segmentId: 0, translation: 'Louez le Seigneur' },
{ segmentId: 1, translation: 'Pour toujours', note: '"Forever" could be liturgical here.' },
],
},
],
});

const result = await runSlideTranslationAgent(provider, {
sourceSlides: slides,
messages: [{ role: 'user', parts: [{ text: 'prompt' }] }],
bibleLanguages: [],
});

expect(result.notes).toEqual([
{ language: 'French', sourceText: 'Forever', text: '"Forever" could be liturgical here.' },
]);
});

it('withdraws a note when the slide is rewritten without one', async () => {
const { provider } = fakeAgentProvider({
languages: [
{ language: 'French', segments: [{ segmentId: 1, translation: "Pour l'éternité" }] },
],
});

const result = await runSlideTranslationAgent(provider, {
sourceSlides: slides,
messages: [{ role: 'user', parts: [{ text: 'prompt' }] }],
bibleLanguages: [],
currentNotes: [{ language: 'French', sourceText: 'Forever', text: 'Old caveat.' }],
});

expect(result.notes).toEqual([]);
});

it('keeps notes on the slides a follow-up never touched', async () => {
const provider = fakeScriptedProvider([
[{ name: 'revise_translation', args: { language: 'French', segmentId: 0, find: 'Louez', replace: 'Louons' } }],
]);

const result = await runSlideTranslationAgent(provider, {
sourceSlides: slides,
messages: [{ role: 'user', parts: [{ text: 'prompt' }] }],
bibleLanguages: [],
currentTranslations: { French: ['Louez le Seigneur', 'Pour toujours'] },
currentNotes: [{ language: 'French', sourceText: 'Forever', text: 'Old caveat.' }],
});

// The run reports the item's whole note set, so the server can replace rather than merge.
expect(result.notes).toEqual([
{ language: 'French', sourceText: 'Forever', text: 'Old caveat.' },
]);
});

it('drops a seeded note whose source text is no longer in the item', async () => {
const { provider } = fakeAgentProvider({ languages: [] });

const result = await runSlideTranslationAgent(provider, {
sourceSlides: slides,
messages: [{ role: 'user', parts: [{ text: 'prompt' }] }],
bibleLanguages: [],
currentNotes: [{ language: 'French', sourceText: 'A slide that was edited away', text: 'Stale.' }],
});

expect(result.notes).toEqual([]);
});
});
67 changes: 59 additions & 8 deletions nlp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { FunctionCallingConfigMode, Type, type Content, type FunctionDeclaration
import { Gemini as GoogleGenAI } from '@posthog/ai/gemini';
import { PostHog } from 'posthog-node';
import { BIBLE_TRANSLATIONS, lookupBiblePassage, type BibleLookupArgs, type BibleToolCall } from './bible.ts';
import { unescapeLiteralEscapes } from './src/slideTranslation.ts';
import { normalizeSlideText, unescapeLiteralEscapes, type SlideReviewNote } from './src/slideTranslation.ts';
export type { SlideReviewNote };

export class GeminiProvider {
apiClient: GoogleGenAI;
Expand Down Expand Up @@ -237,7 +238,7 @@ export type DraftItemTarget = {
language: string;
/** Per source slide: does this slide still need translating into this language? */
isTranslationNeeded: boolean[];
/** Already-reviewed translations in this language, joined for style/terminology context. */
/** Translations already in the library for this language, joined for style/terminology context. */
context: string;
};

Expand Down Expand Up @@ -344,8 +345,12 @@ const SET_TRANSLATIONS_TOOL: FunctionDeclaration = {
note: {
type: Type.STRING,
description:
'Optional caveat for the reviewer. Omit unless there is ' +
'a genuine ambiguity or judgement call worth flagging.',
'Optional caveat for the reviewer. It is shown beside this ' +
"slide's translation on the review screen and marks the whole " +
'item as wanting a look, so omit it unless there is a genuine ' +
'ambiguity or judgement call worth flagging — a note on every ' +
'slide is worth no more than none. Rewriting a slide without a ' +
'note withdraws the one it had.',
},
},
},
Expand Down Expand Up @@ -425,12 +430,29 @@ const workingFor = (working: WorkingTranslations, language: string): Map<number,
return perSlide;
};

/** The agent's working copy of the notes: language → slide id → note text. */
type WorkingNotes = Map<string, Map<number, string>>;

/** Flatten the working notes back to the stored, content-keyed shape. */
const collectNotes = (notes: WorkingNotes, sourceSlides: string[]): SlideReviewNote[] => {
const out: SlideReviewNote[] = [];
for (const [language, perSlide] of notes) {
for (const segmentId of [...perSlide.keys()].sort((a, b) => a - b)) {
const text = perSlide.get(segmentId) ?? '';
if (text === '' || sourceSlides[segmentId] === undefined) continue;
out.push({ language, sourceText: sourceSlides[segmentId], text });
}
}
return out;
};

/** Apply a `set_translations` call to the working copy; returns the ids it touched. */
const applySetTranslations = (
args: Record<string, unknown> | undefined,
sourceSlides: string[],
working: WorkingTranslations,
changed: Map<string, Set<number>>,
notes: WorkingNotes,
): void => {
const languageResults = ((args?.languages as unknown[]) ?? []) as Array<{
language: string;
Expand All @@ -445,6 +467,11 @@ const applySetTranslations = (
workingFor(working, language).set(segmentId, unescapeLiteralEscapes(segment.translation ?? ''));
if (!changed.has(language)) changed.set(language, new Set());
changed.get(language)?.add(segmentId);
// Rewriting a slide restates its caveat: a note given here replaces whatever
// was there, and a rewrite with no note means the model no longer has one.
const note = (segment.note ?? '').trim();
if (note) workingFor(notes, language).set(segmentId, note);
else notes.get(language)?.delete(segmentId);
}
}
};
Expand Down Expand Up @@ -530,6 +557,11 @@ export type SlideAgentRunResult = {
* run never touched are absent, so a targeted edit reports only what it edited.
*/
translations: Record<string, TranslationBlockResult[]>;
/**
* Every caveat currently attached to a slide, for the whole item — the seeded notes
* carried forward plus whatever this run set or cleared. Replaces the stored set.
*/
notes: SlideReviewNote[];
/** The full updated conversation (raw Gemini Content, replay-safe — keep verbatim). */
messages: Content[];
/** Whether the model called `set_translations` during this run. */
Expand Down Expand Up @@ -567,12 +599,17 @@ export const runSlideTranslationAgent = async (
* missing are treated as "not yet translated".
*/
currentTranslations?: Record<string, (string | null | undefined)[]>;
/**
* Caveats already attached to these slides, carried across rounds so a follow-up
* that touches one slide doesn't silently drop the notes on all the others.
*/
currentNotes?: SlideReviewNote[];
onToolCall?: (call: BibleToolCall) => void;
/** PostHog trace/distinct-id tags so every round groups under one conversation. */
observability?: AgentObservability;
},
): Promise<SlideAgentRunResult> => {
const { sourceSlides, messages, bibleLanguages, currentTranslations, onToolCall, observability } = params;
const { sourceSlides, messages, bibleLanguages, currentTranslations, currentNotes, onToolCall, observability } = params;
const model = params.model ?? provider.defaultModel;

const functionDeclarations: FunctionDeclaration[] = [SET_TRANSLATIONS_TOOL, REVISE_TRANSLATION_TOOL];
Expand All @@ -590,6 +627,15 @@ export const runSlideTranslationAgent = async (
}
});
}
// Notes are seeded the same way, by matching the stored source text back to a slide id.
// A `revise_translation` deliberately leaves them alone: only a rewrite of the whole
// slide restates (or withdraws) the caveat.
const notes: WorkingNotes = new Map();
const slideIdByText = new Map(sourceSlides.map((slide, i) => [normalizeSlideText(slide), i]));
for (const note of currentNotes ?? []) {
const segmentId = slideIdByText.get(normalizeSlideText(note.sourceText));
if (segmentId !== undefined && note.text) workingFor(notes, note.language).set(segmentId, note.text);
}

let setTranslationsCalled = false;
let usage = emptyUsage();
Expand Down Expand Up @@ -622,6 +668,7 @@ export const runSlideTranslationAgent = async (
sourceSlides,
working,
changed,
notes,
);
setTranslationsCalled = true;
responseParts.push({
Expand Down Expand Up @@ -673,6 +720,7 @@ export const runSlideTranslationAgent = async (

return {
translations: collectChanged(working, changed, sourceSlides),
notes: collectNotes(notes, sourceSlides),
messages,
setTranslationsCalled,
usage,
Expand All @@ -681,7 +729,7 @@ export const runSlideTranslationAgent = async (

/**
* Build the opening user prompt for the slide-translation agent: the numbered source slides,
* the target languages (each with which slides still need translating and any reviewed
* the target languages (each with which slides still need translating and any library
* translations as style/terminology context), an optional multilingual `referenceText` dump,
* Bible-grounding instructions when any target language has a canonical translation, and the
* instruction to record results via the `set_translations` tool.
Expand Down Expand Up @@ -832,7 +880,7 @@ useful to say.`;
* Translate a whole item into several languages in one agent loop.
*
* The model receives the numbered source slides, the target languages (each with which
* slides still need translating and any already-reviewed translations as context), and an
* slides still need translating and any library translations as context), and an
* optional free-text `referenceText` dump that may hold prior translations in one or more of
* the target languages. It may call `lookup_bible_passage` to ground Scripture, then records
* results via `set_translations`. `onToolCall` reports each Bible lookup; `onConversation`
Expand Down Expand Up @@ -864,13 +912,15 @@ export const draftItemTranslations = async (
onToolCall?: (call: BibleToolCall) => void;
/** Receives the full agent conversation (raw Gemini Content) once drafting completes. */
onConversation?: (messages: Content[]) => void;
/** Receives the per-slide caveats the model raised for the reviewer. */
onNotes?: (notes: SlideReviewNote[]) => void;
/** Receives the run's token usage (incl. cache hits) once drafting completes. */
onUsage?: (usage: TokenUsage) => void;
/** PostHog trace/distinct-id tags so the draft's generations group by conversation. */
observability?: AgentObservability;
},
): Promise<Record<string, TranslationBlockResult[]>> => {
const { sourceSlides, targets, referenceText, existingTranslation, generalContext, onToolCall, onConversation, onUsage, observability } = params;
const { sourceSlides, targets, referenceText, existingTranslation, generalContext, onToolCall, onConversation, onNotes, onUsage, observability } = params;
const itemTitle = params.itemTitle?.trim();
const model = params.model ?? provider.defaultModel;
// Languages we can actually fetch canonical Scripture for.
Expand Down Expand Up @@ -898,6 +948,7 @@ export const draftItemTranslations = async (
observability,
});
onConversation?.(result.messages);
onNotes?.(result.notes);
onUsage?.(result.usage);
return result.translations;
};
4 changes: 2 additions & 2 deletions proclaim_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
TRANSLATION_SCAN_INTERVAL = float(os.getenv('PROCLAIM_TRANSLATION_SCAN_INTERVAL', '1.0')) # seconds
# Target languages to pre-translate slides into (must match the frontend's configured
# languages). The translator asks the server to translate the active item into these and
# writes the reviewed-or-auto results into the per-day slideTranslations map.
# fills any missing keys in the per-day slideTranslations map with the results.
SLIDE_TRANSLATION_LANGUAGES = [
lang.strip()
for lang in os.getenv('SLIDE_TRANSLATION_LANGUAGES', 'French,Haitian Creole,Spanish').split(',')
Expand Down Expand Up @@ -209,7 +209,7 @@ def announce(doc: Doc, doc_id: str) -> None:
def make_translate_fn(ysweet_url: str, languages: List[str], write_key: Optional[str] = None):
"""Build the translation call the SlideTranslator injects: POST /api/translateItem.

Returns the ``{language: [{text, status, provenance}, ...]}`` map, or None on failure
Returns the ``{language: [{text, provenance}, ...]}`` map, or None on failure
(translation is best-effort; a failure must not drop the session).
"""
async def translate(
Expand Down
13 changes: 11 additions & 2 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { ElevenLabs, ElevenLabsClient } from '@elevenlabs/elevenlabs-js';
import { PostHog, setupExpressErrorHandler, setupExpressRequestContext } from 'posthog-node';

import { translateBlock, draftItemTranslations, runSlideTranslationAgent, buildSeedConversationPrompt, GeminiProvider, emptyUsage, mergeUsage } from './nlp.ts';
import type { TranslationTodo, TokenUsage, AgentObservability } from './nlp.ts';
import type { TranslationTodo, TokenUsage, AgentObservability, SlideReviewNote } from './nlp.ts';
import { BIBLE_TRANSLATIONS, type BibleToolCall } from './bible.ts';
import { SlideLibrary } from './slideLibrary.ts';
import { translateItem } from './src/slideItemTranslation.ts';
Expand Down Expand Up @@ -663,6 +663,8 @@ app.post('/api/translateItem', requireWriteKey('/api/translateItem'), async (req
const bibleLookups: BibleToolCall[] = [];
// The raw agent history, captured so we can persist it for review + follow-ups.
let conversationMessages: Content[] = [];
// Per-slide caveats the model raised — the review screen's "look at this one" signal.
let slideNotes: SlideReviewNote[] = [];
// Token usage across the draft's model calls (surfaced so cache hits/cost are visible).
let usage: TokenUsage = emptyUsage();
const translations = await translateItem({
Expand All @@ -686,6 +688,9 @@ app.post('/api/translateItem', requireWriteKey('/api/translateItem'), async (req
onConversation: (messages) => {
conversationMessages = messages;
},
onNotes: (notes) => {
slideNotes = notes;
},
onUsage: (runUsage) => {
usage = mergeUsage(usage, runUsage);
},
Expand Down Expand Up @@ -717,6 +722,7 @@ app.post('/api/translateItem', requireWriteKey('/api/translateItem'), async (req
slidesHash: slidesHash(slides),
languages: requestedLanguages,
messages: seededMessages,
notes: slideNotes,
status: 'idle',
usage,
});
Expand Down Expand Up @@ -768,6 +774,7 @@ app.post('/api/slideConversation/message', requireWriteKey('/api/slideConversati
model: STRONG_MODEL,
bibleLanguages,
currentTranslations,
currentNotes: conversation.notes,
observability,
onToolCall: (call) => {
bibleLookups.push(call);
Expand All @@ -777,11 +784,13 @@ app.post('/api/slideConversation/message', requireWriteKey('/api/slideConversati
},
});
conversation.status = 'idle';
// The run reports the item's whole note set (seeded from this one), so replace, not merge.
conversation.notes = result.notes;
// Fold this run's tokens into the conversation's running total (initial draft + follow-ups).
conversation.usage = mergeUsage(conversation.usage ?? emptyUsage(), result.usage);
writeConversation(conversationsMap, conversation);

// Flatten revised translations for the browser to apply (auto / llm-agent provenance).
// Flatten revised translations for the browser to apply.
const updatedTranslations: Array<{ language: string; sourceText: string; text: string }> = [];
for (const [language, blocks] of Object.entries(result.translations)) {
for (const block of blocks) {
Expand Down
Loading
Loading