Skip to content
Open
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
10 changes: 10 additions & 0 deletions docs/slide-translations-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,3 +405,13 @@ fix mid-service, which is noise they can't act on. What replaced it:
only**. Keys are content-addressed, so an existing entry already translates this exact
text, and it may be a reviewer's save or an agent follow-up's revision that a fresh
draft must not undo. "Protect reviewed" was a special case of that rule.

**Notes, surfaced (post-build).** `set_translations` always let the model attach a
per-segment `note`, and we dropped it. It now rides on the entry, `{ text, provenance,
note? }`, because a note is a caveat about *that text*: whatever replaces the text replaces
the note. A rewrite through `set_translations` brings its own note or none, a
`revise_translation` drops it, and a reviewer's Save drops it — the human has dealt with it.
The model's original wording stays in the stored conversation either way. Notes are not
seeded into a follow-up run: a slide the run doesn't write keeps its note where it is stored.
The library never stores one. The review screen shows a note above its cell and counts an
item's notes in the picker.
42 changes: 42 additions & 0 deletions nlp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,48 @@ describe('revise_translation (targeted edits)', () => {
});
});

describe('per-slide notes', () => {
const slides = ['Praise the Lord', 'Forever'];
const set = (segments: object[]) => ({
name: 'set_translations',
args: { languages: [{ language: 'French', segments }] },
});
const run = (rounds: Array<Array<{ name: string; args: object }>>) =>
runSlideTranslationAgent(fakeScriptedProvider(rounds), {
sourceSlides: slides,
messages: [{ role: 'user', parts: [{ text: 'prompt' }] }],
bibleLanguages: [],
});

it('attaches a note to the slide it was given with, trimmed, and to no other', async () => {
const result = await run([
[set([
{ segmentId: 0, translation: 'Louez le Seigneur', note: ' Seigneur or Éternel? ' },
{ segmentId: 1, translation: 'Pour toujours', note: ' ' },
])],
]);
expect(result.translations.French.map((r) => r.note)).toEqual(['Seigneur or Éternel?', undefined]);
});

it('a rewrite without a note withdraws the one the slide had', async () => {
const result = await run([
[set([{ segmentId: 0, translation: 'Louez le Seigneur', note: 'Seigneur or Éternel?' }])],
[set([{ segmentId: 0, translation: "Louez l'Éternel" }])],
]);
expect(result.translations.French[0]).not.toHaveProperty('note');
});

it('a revision withdraws the note, which stays in the conversation', async () => {
const result = await run([
[set([{ segmentId: 0, translation: 'Louez le Seigneur', note: 'Seigneur or Éternel?' }])],
[{ name: 'revise_translation', args: { language: 'French', segmentId: 0, find: 'le Seigneur', replace: "l'Éternel" } }],
]);
expect(result.translations.French[0]).toMatchObject({ translatedText: "Louez l'Éternel" });
expect(result.translations.French[0]).not.toHaveProperty('note');
expect(JSON.stringify(result.messages)).toContain('Seigneur or Éternel?');
});
});

describe('token usage accounting', () => {
it('sums usageMetadata across rounds, tolerating missing fields', () => {
let usage = emptyUsage();
Expand Down
32 changes: 29 additions & 3 deletions nlp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export type TranslationTodo = {
export type TranslationBlockResult = {
sourceText: string;
translatedText: string;
/** The model's caveat for the reviewer on this slide, if it raised one (slide agent only). */
note?: string;
}

/**
Expand Down Expand Up @@ -344,8 +346,11 @@ 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, shown beside this slide on ' +
'the review screen and counted on its item. Omit unless there ' +
'is a genuine ambiguity or judgement call worth flagging. It ' +
'belongs to this text: rewriting or revising the slide ' +
'withdraws it unless you restate it here.',
},
},
},
Expand Down Expand Up @@ -425,11 +430,19 @@ const workingFor = (working: WorkingTranslations, language: string): Map<number,
return perSlide;
};

/**
* The notes on the working copy, keyed the same way. A note belongs to the text it was
* written with, so every write to a slide replaces it: `set_translations` with its own note
* (or none), `revise_translation` with none.
*/
type WorkingNotes = WorkingTranslations;

/** 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,
notes: WorkingNotes,
changed: Map<string, Set<number>>,
): void => {
const languageResults = ((args?.languages as unknown[]) ?? []) as Array<{
Expand All @@ -443,6 +456,9 @@ const applySetTranslations = (
const segmentId = segment.segmentId;
if (!(segmentId >= 0 && segmentId < sourceSlides.length)) continue;
workingFor(working, language).set(segmentId, unescapeLiteralEscapes(segment.translation ?? ''));
const note = segment.note?.trim();
if (note) workingFor(notes, language).set(segmentId, note);
else notes.get(language)?.delete(segmentId);
if (!changed.has(language)) changed.set(language, new Set());
changed.get(language)?.add(segmentId);
}
Expand All @@ -460,6 +476,7 @@ const applyReviseTranslation = (
args: Record<string, unknown> | undefined,
sourceSlides: string[],
working: WorkingTranslations,
notes: WorkingNotes,
changed: Map<string, Set<number>>,
): Record<string, unknown> => {
const language = typeof args?.language === 'string' ? args.language : '';
Expand Down Expand Up @@ -498,6 +515,7 @@ const applyReviseTranslation = (

const updated = current.slice(0, first) + replace + current.slice(first + find.length);
workingFor(working, language).set(segmentId, updated);
notes.get(language)?.delete(segmentId);
if (!changed.has(language)) changed.set(language, new Set());
changed.get(language)?.add(segmentId);
return { ok: true, language, segmentId, text: updated };
Expand All @@ -506,17 +524,20 @@ const applyReviseTranslation = (
/** Collect the slides the run changed, per language, from the working copy. */
const collectChanged = (
working: WorkingTranslations,
notes: WorkingNotes,
changed: Map<string, Set<number>>,
sourceSlides: string[],
): Record<string, TranslationBlockResult[]> => {
const out: Record<string, TranslationBlockResult[]> = {};
for (const [language, ids] of changed) {
const perSlide = working.get(language);
const notesFor = notes.get(language);
out[language] = [...ids]
.sort((a, b) => a - b)
.map((segmentId) => ({
sourceText: sourceSlides[segmentId],
translatedText: perSlide?.get(segmentId) ?? '',
...(notesFor?.has(segmentId) && { note: notesFor.get(segmentId) }),
language,
}));
}
Expand Down Expand Up @@ -582,6 +603,9 @@ export const runSlideTranslationAgent = async (
// Seeded with what already exists, so an edit-only run has something to edit. `changed`
// stays empty until the model actually writes, so seeds are never reported as updates.
const working: WorkingTranslations = new Map();
// Notes are not seeded: a slide this run doesn't write keeps whatever note it has where
// it is stored, and a slide it does write gets a fresh one (or none).
const notes: WorkingNotes = new Map();
const changed = new Map<string, Set<number>>();
for (const [language, perSlide] of Object.entries(currentTranslations ?? {})) {
perSlide.forEach((text, segmentId) => {
Expand Down Expand Up @@ -621,6 +645,7 @@ export const runSlideTranslationAgent = async (
call.args as Record<string, unknown> | undefined,
sourceSlides,
working,
notes,
changed,
);
setTranslationsCalled = true;
Expand All @@ -635,6 +660,7 @@ export const runSlideTranslationAgent = async (
call.args as Record<string, unknown> | undefined,
sourceSlides,
working,
notes,
changed,
),
},
Expand Down Expand Up @@ -672,7 +698,7 @@ export const runSlideTranslationAgent = async (
}

return {
translations: collectChanged(working, changed, sourceSlides),
translations: collectChanged(working, notes, changed, sourceSlides),
messages,
setTranslationsCalled,
usage,
Expand Down
4 changes: 2 additions & 2 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -901,10 +901,10 @@ app.post('/api/slideConversation/message', requireWriteKey('/api/slideConversati
writeConversation(conversationsMap, conversation);

// Flatten revised translations for the browser to apply.
const updatedTranslations: Array<{ language: string; sourceText: string; text: string }> = [];
const updatedTranslations: Array<{ language: string; sourceText: string; text: string; note?: string }> = [];
for (const [language, blocks] of Object.entries(result.translations)) {
for (const block of blocks) {
updatedTranslations.push({ language, sourceText: block.sourceText, text: block.translatedText });
updatedTranslations.push({ language, sourceText: block.sourceText, text: block.translatedText, note: block.note });
}
}
return res.json({ ok: true, conversation, updatedTranslations });
Expand Down
10 changes: 10 additions & 0 deletions slideSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ describe('storeTranslations', () => {
expect(map.size).toBe(1);
});

it("keeps the model's note with the text it was written for", () => {
const doc = new Y.Doc();
storeTranslations(doc, ['Hello'], { French: [{ text: 'Bonjour', provenance: 'llm', note: 'Salut?' }] });
expect(doc.getMap('slideTranslations').get(slideTranslationKey('French', 'Hello'))).toEqual({
text: 'Bonjour',
provenance: 'llm',
note: 'Salut?',
});
});

it('never overwrites an existing entry', () => {
// A reviewer's save and an agent follow-up's revision look alike here; a fresh draft
// of the same source text must not undo either.
Expand Down
6 changes: 5 additions & 1 deletion slideSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,11 @@ export function storeTranslations(
if (!slide.trim() || !entry) return;
const key = slideTranslationKey(language, slide);
if (map.has(key)) return;
map.set(key, { text: entry.text ?? '', provenance: entry.provenance ?? 'llm' });
map.set(key, {
text: entry.text ?? '',
provenance: entry.provenance ?? 'llm',
...(entry.note && { note: entry.note }),
});
});
}
});
Expand Down
16 changes: 16 additions & 0 deletions src/SlideReview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const baseProps = {
languages: ['French'] as const,
editable: true,
busy: false,
notes: {},
};

describe('SlideReview', () => {
Expand Down Expand Up @@ -85,6 +86,21 @@ describe('SlideReview', () => {
expect(screen.getAllByRole('button', { name: 'Save' })[0]).toBeDisabled();
});

it("shows the model's note on its cell only", () => {
render(
<SlideReview
{...baseProps}
drafts={{ French: ['Louez le Seigneur', 'À jamais'] }}
savedTexts={{ French: [null, null] }}
notes={{ French: [null, '"Forever" could also be "pour toujours"'] }}
onDraftChange={vi.fn()}
onSaveCell={vi.fn()}
/>,
);
expect(screen.getAllByText(/📝/)).toHaveLength(1);
expect(screen.getByText(/could also be "pour toujours"/)).toBeInTheDocument();
});

it('disables editing when not editable', () => {
render(
<SlideReview
Expand Down
13 changes: 10 additions & 3 deletions src/SlideReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface SlideReviewProps {
drafts: Record<string, string[]>;
/** The translation currently saved in the library (null if none). */
savedTexts: Record<string, (string | null)[]>;
/** The model's caveat on each draft (null if none), keyed like `drafts`. */
notes: Record<string, (string | null)[]>;
editable: boolean;
/** True while a network action (suggest/save) is in flight. */
busy: boolean;
Expand All @@ -27,15 +29,16 @@ function cellState(draft: string, savedText: string | null): CellState {

/**
* Pure grid for reviewing/editing slide translations: one row per slide, one column
* per target language, plus the source text. Each cell shows the draft translation,
* a saved/unsaved status chip, and a per-cell Save button. All data and persistence
* are owned by the container.
* per target language, plus the source text. Each cell shows the model's note (if any),
* the draft translation, a saved/unsaved status chip, and a per-cell Save button. All data
* and persistence are owned by the container.
*/
export function SlideReview({
slides,
languages,
drafts,
savedTexts,
notes,
editable,
busy,
onDraftChange,
Expand Down Expand Up @@ -70,8 +73,12 @@ export function SlideReview({
const draft = drafts[language]?.[slideIndex] ?? '';
const savedText = savedTexts[language]?.[slideIndex] ?? null;
const state = cellState(draft, savedText);
const note = notes[language]?.[slideIndex];
return (
<td key={language} className="p-2">
{note && (
<p className="mb-1 text-xs text-amber-800 dark:text-amber-300">📝 {note}</p>
)}
<textarea
aria-label={`${language} slide ${slideIndex + 1}`}
className="w-full min-h-[3rem] rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 p-1 text-sm resize-y disabled:opacity-60"
Expand Down
Loading
Loading