fix(cv): gate AI proposals on grounding and source freshness

This commit is contained in:
cesnimda
2026-10-08 14:28:06 +02:00
parent d8d262e4d1
commit 2398ff354e
27 changed files with 2553 additions and 157 deletions
@@ -755,15 +755,21 @@ test('[P5-11 PRES-C06 C10 E08] returning from preview focuses the existing edito
async function reviewDocumentSuggestion(result: string) {
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Canonical headline', sections: [] });
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
? { result } : { html: '<p>Preview</p>' } } as never));
? { original: JSON.stringify([{ id: 'headline', items: ['Canonical headline'] }]), result, proposalToken: 'bound-proposal' }
: url === '/cv/variants/3/ai/apply'
? { ...variant, version: 5, settings: { ...variant.settings, headline: 'Reviewed headline' } }
: { html: '<p>Preview</p>' } } as never));
mockedApi.put.mockImplementation((_url, body) => Promise.resolve({ data: { ...variant, ...(body as object) } } as never));
renderAt(3);
await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.objectContaining({
action: 'document-translate', text: JSON.stringify([{ id: 'headline', items: ['Canonical headline'] }]),
action: 'document-translate', variantId: 3, expectedVersion: 4,
})));
const request = mockedApi.post.mock.calls.find(([url]) => url === '/cv/ai/assist')![1] as any;
expect(request).not.toHaveProperty('text');
expect(request).not.toHaveProperty('targetId');
}
test.each(['Apply', 'Reject'])('[PRES-C11 PRES-C07] whole-document AI %s changes only an explicitly accepted CV override', async action => {
@@ -773,15 +779,13 @@ test.each(['Apply', 'Reject'])('[PRES-C11 PRES-C07] whole-document AI %s changes
expect(screen.getByText('Reviewed headline')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: action }));
expect(screen.queryByText('Review 1 proposed change(s)')).not.toBeInTheDocument();
await waitFor(() => expect(screen.queryByText('Review 1 proposed change(s)')).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(await screen.findByLabelText('Headline override')).toHaveValue(action === 'Apply' ? 'Reviewed headline' : '');
if (action === 'Apply') {
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({
settings: expect.objectContaining({ headline: 'Reviewed headline' }),
})));
expect(mockedApi.put.mock.calls.every(([url]) => url === '/cv/variants/3')).toBe(true);
expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', { proposalToken: 'bound-proposal' });
expect(screen.getByText('Saved')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
} else {
expect(screen.getByText('Saved')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
@@ -800,3 +804,124 @@ test.each([
expect(await screen.findByLabelText('Headline override')).toHaveValue('');
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('bound document generation saves the pending draft, reviews server Original, and accepted AI remains undoable', async () => {
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Outline headline', sections: [] });
mockedApi.put.mockImplementation((_url, body) => Promise.resolve({ data: { ...variant, ...(body as object), version: 5 } } as never));
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
? { original: JSON.stringify([{ id: 'headline', items: ['Server source headline'] }]), result: JSON.stringify([{ id: 'headline', items: ['Accepted headline'] }]), proposalToken: 'saved-source-token' }
: url === '/cv/variants/3/ai/apply'
? { ...variant, version: 6, settings: { ...variant.settings, headline: 'Accepted headline' } }
: { html: '<p>Preview</p>' } } as never));
renderAt(3);
await openContentTab();
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Draft before generation' } });
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
expect(await screen.findByText('Server source headline')).toBeInTheDocument();
expect(screen.queryByText('Outline headline')).not.toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.objectContaining({ variantId: 3, expectedVersion: 5 }));
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument());
expect(mockedApi.put).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('Accepted headline');
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('Draft before generation');
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({ expectedVersion: 6, settings: expect.objectContaining({ headline: 'Draft before generation' }) })));
await screen.findByText('Saved');
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('Draft before generation');
});
test('a newer draft is flushed before stale proposal acceptance fails without rebasing or changing content', async () => {
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
mockedApi.post.mockImplementation((url: string) => url === '/cv/variants/3/ai/apply'
? Promise.reject({ response: { status: 409, data: { code: 'cv_ai_source_changed' } } })
: Promise.resolve({ data: { html: '<p>Preview</p>' } } as never));
mockedApi.put.mockImplementation((_url, body) => Promise.resolve({ data: { ...variant, ...(body as object), version: 5 } } as never));
fireEvent.change(screen.getByDisplayValue('Backend CV'), { target: { value: 'Newer retained name' } });
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(await screen.findByText('AI suggestion failed.')).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({ name: 'Newer retained name', expectedVersion: 4 }));
expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', { proposalToken: 'bound-proposal' });
expect(mockedApi.post.mock.calls.filter(([url]) => url === '/cv/ai/assist')).toHaveLength(1);
expect(screen.getByDisplayValue('Newer retained name')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
});
test('late document proposals cannot return after tab unmount or a source undo', async () => {
let resolveProposal: (response: any) => void = () => {};
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Canonical headline', sections: [] });
mockedApi.post.mockImplementation((url: string) => url === '/cv/ai/assist'
? new Promise(resolve => { resolveProposal = resolve; })
: Promise.resolve({ data: { html: '<p>Preview</p>' } } as never));
renderAt(3);
await openContentTab();
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Generation source' } });
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.any(Object)));
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
await act(async () => resolveProposal({ data: { original: JSON.stringify([{ id: 'headline', items: ['Generation source'] }]), result: JSON.stringify([{ id: 'headline', items: ['Late proposal'] }]), proposalToken: 'late-token' } }));
expect(screen.queryByText('Late proposal')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Review suggestion' })[0]);
await waitFor(() => expect(mockedApi.post.mock.calls.filter(([url]) => url === '/cv/ai/assist')).toHaveLength(2));
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
await act(async () => resolveProposal({ data: { original: JSON.stringify([{ id: 'headline', items: ['Canonical headline'] }]), result: JSON.stringify([{ id: 'headline', items: ['Unmounted proposal'] }]), proposalToken: 'unmounted-token' } }));
fireEvent.click(screen.getByRole('tab', { name: 'AI Tools' }));
expect(screen.queryByText('Unmounted proposal')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
});
test.each([
{ label: 'Skills', targetId: 'section:skills', outline: { sections: [{ key: 'skills', title: 'Skills', kind: 'tags', tags: ['Original item'], bullets: [], entries: [] }] }, customSections: [] },
{ label: 'Custom evidence', targetId: 'custom:sample', outline: { sections: [] }, customSections: [{ key: 'sample', title: 'Custom evidence', contentType: 'paragraphs', items: ['Original item'] }] },
{ label: 'Experience', targetId: 'entry:experience:job-1', outline: { sections: [{ key: 'experience', title: 'Experience', kind: 'entries', tags: [], bullets: [], entries: [{ key: 'job-1', title: 'Original role', subtitle: 'Original employer', bullets: ['Original item'], tags: [] }] }] }, customSections: [] },
])('bound $targetId assistant sends explicit scope and edited wording to server acceptance', async fixture => {
const originalSettings = { ...variant.settings, customSections: fixture.customSections };
routeGet(() => Promise.resolve({ data: { ...variant, settings: originalSettings } }), fixture.outline);
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
? { original: 'Original item', result: 'Suggested item', proposalToken: `token:${fixture.targetId}` }
: url === '/cv/variants/3/ai/apply'
? { ...variant, version: 5, settings: { ...originalSettings, headline: 'Server accepted readback' } }
: { html: '<p>Preview</p>' } } as never));
renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('button', { name: `Expand ${fixture.label}` }));
fireEvent.click(await screen.findByRole('button', { name: 'AI suggestions' }));
fireEvent.click(screen.getByRole('button', { name: 'Suggest' }));
const suggestion = await screen.findByLabelText('Suggested — edit before applying');
expect(mockedApi.post).toHaveBeenCalledWith('/cv/ai/assist', expect.objectContaining({ variantId: 3, expectedVersion: 4, targetId: fixture.targetId, text: 'Original item' }));
fireEvent.change(suggestion, { target: { value: 'Edited approved item' } });
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
await waitFor(() => expect(screen.getByLabelText('Headline override')).toHaveValue('Server accepted readback'));
expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', { proposalToken: `token:${fixture.targetId}`, result: 'Edited approved item' });
expect(mockedApi.put).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
});
test('user edits made during server acceptance are retained instead of overwritten by its late saved response', async () => {
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
let resolveApply: (response: any) => void = () => {};
mockedApi.post.mockImplementation((url: string) => url === '/cv/variants/3/ai/apply'
? new Promise(resolve => { resolveApply = resolve; })
: Promise.resolve({ data: { html: '<p>Preview</p>' } } as never));
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/ai/apply', expect.any(Object)));
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
fireEvent.change(screen.getByDisplayValue('Backend CV'), { target: { value: 'Retained during acceptance' } });
await act(async () => resolveApply({ data: { ...variant, version: 5, settings: { ...variant.settings, headline: 'Late accepted headline' } } }));
expect(await screen.findByText('CV changed elsewhere')).toBeInTheDocument();
expect(screen.getByDisplayValue('Retained during acceptance')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('');
expect(mockedApi.put).not.toHaveBeenCalled();
});