From a83ea85857adb5b1a8aebbf1db01d347785fe920 Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Fri, 27 Mar 2026 01:28:29 -0300 Subject: [PATCH] feat(frontend): generate task previews with local ollama Replace the local preview stub with a real Ollama-backed test message flow using the configured local model. Show the exact final prompt live on create and edit task pages, render generated output below it, and cover the integration with frontend tests. --- frontend/src/__tests__/api/tasksApi.test.ts | 106 +++++++++++++++-- .../__tests__/pages/CreateTaskPage.test.tsx | 44 ++++++- .../src/__tests__/pages/EditTaskPage.test.tsx | 43 ++++++- frontend/src/api/tasksApi.ts | 109 ++++++++++++++++-- frontend/src/pages/CreateTaskPage.tsx | 67 +++++++++-- frontend/src/pages/EditTaskPage.tsx | 67 +++++++++-- 6 files changed, 394 insertions(+), 42 deletions(-) diff --git a/frontend/src/__tests__/api/tasksApi.test.ts b/frontend/src/__tests__/api/tasksApi.test.ts index 7005815..ec682be 100644 --- a/frontend/src/__tests__/api/tasksApi.test.ts +++ b/frontend/src/__tests__/api/tasksApi.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { activateTask, + buildTaskPreviewPrompt, createTask, deleteTask, + generateTaskPreview, getAllTasks, getTask, inactivateTask, @@ -33,6 +35,26 @@ const taskTwo: EntityTaskResponse = { createdAt: '2026-03-26T11:00:00Z', } +const entity = { + id: 'entity-1', + name: 'Milton Fiscal', + email: 'milton@condado.test', + jobTitle: 'Director of Ceremonial Logistics', + personality: 'Rigidly formal but secretly obsessed with office snacks.', + scheduleCron: '0 9 * * 1-5', + contextWindowDays: 7, + active: true, + createdAt: '2026-03-20T09:00:00Z', +} + +const previewTask = { + entityId: 'entity-1', + name: 'Snack Escalation Briefing', + prompt: 'Draft an absurdly official update about disappearing crackers.', + scheduleCron: '15 10 * * 2', + emailLookback: 'last_week' as const, +} + describe('tasksApi', () => { beforeEach(() => { localStorage.clear() @@ -54,14 +76,7 @@ describe('tasksApi', () => { it('should_returnTasksForEntity_when_getTasksByEntityCalled', async () => { localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo])) - await expect(getTasksByEntity('entity-1')).resolves.toEqual([ - taskOne, - { - ...taskOne, - id: 'task-3', - active: false, - }, - ]) + await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne]) }) it('should_hideInactiveTasks_when_getAllTasksCalled', async () => { @@ -70,7 +85,7 @@ describe('tasksApi', () => { await expect(getAllTasks()).resolves.toEqual([taskOne]) }) - it('should_hideInactiveTasksForEntity_when_getTasksByEntityCalled', async () => { + it('should_returnInactiveTasksForEntity_when_getTasksByEntityCalled', async () => { localStorage.setItem( 'condado:entity-tasks', JSON.stringify([ @@ -84,7 +99,14 @@ describe('tasksApi', () => { ]) ) - await expect(getTasksByEntity('entity-1')).resolves.toEqual([taskOne]) + await expect(getTasksByEntity('entity-1')).resolves.toEqual([ + taskOne, + { + ...taskOne, + id: 'task-3', + active: false, + }, + ]) }) it('should_storeActiveTaskByDefault_when_createTaskCalled', async () => { @@ -185,4 +207,68 @@ describe('tasksApi', () => { expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([taskTwo]) }) + + it('should_buildDeterministicPrompt_when_buildTaskPreviewPromptCalled', () => { + expect(buildTaskPreviewPrompt(entity, previewTask)).toEqual(`You are preparing a test email message for a scheduled task in Condado Abaixo da Media SA. + +ENTITY DETAILS +- Entity ID: entity-1 +- Name: Milton Fiscal +- Email: milton@condado.test +- Job Title: Director of Ceremonial Logistics +- Personality: Rigidly formal but secretly obsessed with office snacks. +- Entity Schedule Cron: 0 9 * * 1-5 +- Context Window Days: 7 +- Active: true + +TASK DETAILS +- Task Name: Snack Escalation Briefing +- Task Prompt: Draft an absurdly official update about disappearing crackers. +- Task Schedule Cron: 15 10 * * 2 +- Email Lookback: Last week + +INSTRUCTIONS +- Write exactly one email message. +- Use an extremely formal corporate tone. +- Keep the content casual, mundane, and slightly nonsensical. +- Reflect the entity personality and task prompt faithfully. +- Output plain text only with no markdown fences.`) + }) + + it('should_callOllamaGenerateEndpoint_when_generateTaskPreviewCalled', async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ response: 'SUBJECT: Memo\nBODY:\nPlease secure the crackers.' }), + }) + vi.stubGlobal('fetch', fetchSpy) + + await expect(generateTaskPreview({ entity, task: previewTask })).resolves.toBe( + 'SUBJECT: Memo\nBODY:\nPlease secure the crackers.' + ) + + expect(fetchSpy).toHaveBeenCalledWith('http://localhost:11434/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gemma3:4b', + prompt: buildTaskPreviewPrompt(entity, previewTask), + stream: false, + }), + }) + }) + + it('should_throwReadableError_when_ollamaRequestFails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ error: 'model temporarily unavailable' }), + }) + ) + + await expect(generateTaskPreview({ entity, task: previewTask })).rejects.toThrow( + 'Unable to generate a test message from the local model. model temporarily unavailable' + ) + }) }) \ No newline at end of file diff --git a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx index 651fc37..5c94f72 100644 --- a/frontend/src/__tests__/pages/CreateTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/CreateTaskPage.test.tsx @@ -40,6 +40,10 @@ const mockEntity = { describe('CreateTaskPage', () => { beforeEach(() => { vi.mocked(tasksApi.getEmailLookbackLabel).mockReturnValue('Last week') + vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation( + (entity, task) => + `PROMPT FOR ${entity.name}: ${task.name} | ${task.prompt} | ${task.scheduleCron} | ${task.emailLookback}` + ) vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity) mockNavigate.mockClear() }) @@ -98,11 +102,17 @@ describe('CreateTaskPage', () => { fireEvent.click(screen.getByRole('button', { name: /Weekdays/i })) fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } }) + expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument() + expect( + screen.getByText('PROMPT FOR Entity A: Morning Blast | Talk about coffee | 0 8 * * 1-5 | last_week') + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /generate test message/i })) await waitFor(() => { expect(tasksApi.generateTaskPreview).toHaveBeenCalled() - expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual( + expect(vi.mocked(tasksApi.buildTaskPreviewPrompt)).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ entityId: 'entity-1', name: 'Morning Blast', @@ -111,6 +121,18 @@ describe('CreateTaskPage', () => { emailLookback: 'last_week', }) ) + expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual( + expect.objectContaining({ + entity: mockEntity, + task: expect.objectContaining({ + entityId: 'entity-1', + name: 'Morning Blast', + prompt: 'Talk about coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_week', + }), + }) + ) expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument() }) @@ -131,6 +153,26 @@ describe('CreateTaskPage', () => { }) }) + it('should_showReadablePreviewError_when_generationFails', async () => { + vi.mocked(tasksApi.generateTaskPreview).mockRejectedValue( + new Error('Unable to generate a test message from the local model. Connection refused') + ) + + render(, { wrapper }) + await screen.findByRole('link', { name: /back to entity a/i }) + + fireEvent.change(screen.getByLabelText(/task name/i), { target: { value: 'Morning Blast' } }) + fireEvent.change(screen.getByLabelText(/task prompt/i), { target: { value: 'Talk about coffee' } }) + + fireEvent.click(screen.getByRole('button', { name: /generate test message/i })) + + await waitFor(() => { + expect( + screen.getByText(/Unable to generate a test message from the local model. Connection refused/i) + ).toBeInTheDocument() + }) + }) + it('should_applyDailySuggestion_when_suggestionClicked', async () => { render(, { wrapper }) await screen.findByRole('link', { name: /back to entity a/i }) diff --git a/frontend/src/__tests__/pages/EditTaskPage.test.tsx b/frontend/src/__tests__/pages/EditTaskPage.test.tsx index 2929334..5462e79 100644 --- a/frontend/src/__tests__/pages/EditTaskPage.test.tsx +++ b/frontend/src/__tests__/pages/EditTaskPage.test.tsx @@ -66,6 +66,10 @@ describe('EditTaskPage', () => { vi.clearAllMocks() vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity) vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask) + vi.mocked(tasksApi.buildTaskPreviewPrompt).mockImplementation( + (entity, task) => + `PROMPT FOR ${entity.name}: ${task.name} | ${task.prompt} | ${task.scheduleCron} | ${task.emailLookback}` + ) vi.mocked(tasksApi.activateTask).mockResolvedValue({ ...mockTask, active: true }) vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false }) vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined) @@ -112,11 +116,19 @@ describe('EditTaskPage', () => { fireEvent.click(screen.getByRole('button', { name: /Weekdays/i })) fireEvent.change(screen.getByLabelText(/^Hour$/i), { target: { value: '8' } }) + expect(screen.getByText(/Final Prompt/i)).toBeInTheDocument() + expect( + screen.getByText( + 'PROMPT FOR Entity A: Daily Check-in | Ask about ceremonial coffee | 0 8 * * 1-5 | last_day' + ) + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /generate test message/i })) await waitFor(() => { expect(tasksApi.generateTaskPreview).toHaveBeenCalled() - expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual( + expect(vi.mocked(tasksApi.buildTaskPreviewPrompt)).toHaveBeenCalledWith( + mockEntity, expect.objectContaining({ entityId: 'entity-1', name: 'Daily Check-in', @@ -125,6 +137,18 @@ describe('EditTaskPage', () => { emailLookback: 'last_day', }) ) + expect(vi.mocked(tasksApi.generateTaskPreview).mock.calls[0][0]).toEqual( + expect.objectContaining({ + entity: mockEntity, + task: expect.objectContaining({ + entityId: 'entity-1', + name: 'Daily Check-in', + prompt: 'Ask about ceremonial coffee', + scheduleCron: '0 8 * * 1-5', + emailLookback: 'last_day', + }), + }) + ) expect(screen.getByText(/Formal nonsense/i)).toBeInTheDocument() }) @@ -145,6 +169,23 @@ describe('EditTaskPage', () => { }) }) + it('should_showReadablePreviewError_when_generationFails', async () => { + vi.mocked(tasksApi.generateTaskPreview).mockRejectedValue( + new Error('Unable to generate a test message from the local model. Connection refused') + ) + + renderPage() + await screen.findByRole('link', { name: /back to entity a/i }) + + fireEvent.click(screen.getByRole('button', { name: /generate test message/i })) + + await waitFor(() => { + expect( + screen.getByText(/Unable to generate a test message from the local model. Connection refused/i) + ).toBeInTheDocument() + }) + }) + it('should_renderActivateButton_when_taskIsInactive', async () => { renderPage({ task: { ...mockTask, active: false } }) diff --git a/frontend/src/api/tasksApi.ts b/frontend/src/api/tasksApi.ts index fee8329..1666bc2 100644 --- a/frontend/src/api/tasksApi.ts +++ b/frontend/src/api/tasksApi.ts @@ -1,4 +1,8 @@ +import type { VirtualEntityResponse } from './entitiesApi' + const STORAGE_KEY = 'condado:entity-tasks' +const OLLAMA_GENERATE_URL = 'http://localhost:11434/api/generate' +const OLLAMA_MODEL = 'gemma3:4b' export type EmailLookback = 'last_day' | 'last_week' | 'last_month' @@ -23,6 +27,11 @@ export interface EntityTaskCreateDto { export type EntityTaskUpdateDto = EntityTaskCreateDto +export interface TaskPreviewRequest { + entity: VirtualEntityResponse + task: EntityTaskCreateDto +} + function readTasks(): EntityTaskResponse[] { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return [] @@ -49,22 +58,102 @@ export function getEmailLookbackLabel(value: EmailLookback): string { return 'Last week' } -/** Simulates a task preview generated from the configured prompt. */ -export async function generateTaskPreview(data: EntityTaskCreateDto): Promise { +function getEntityValue(value: string | null | undefined): string { + return value && value.trim().length > 0 ? value : 'Not provided' +} + +async function readOllamaError(response: Response): Promise { + try { + const data = (await response.json()) as { error?: string } + if (data.error?.trim()) { + return data.error + } + } catch { + return `Request failed with status ${response.status}` + } + + return `Request failed with status ${response.status}` +} + +function getReadablePreviewError(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message + } + + return 'Unknown error' +} + +export function buildTaskPreviewPrompt( + entity: VirtualEntityResponse, + task: EntityTaskCreateDto +): string { return [ - `SUBJECT: Internal Alignment Update - ${data.name}`, - 'BODY:', - `Dear Team,`, + 'You are preparing a test email message for a scheduled task in Condado Abaixo da Media SA.', '', - `In strict accordance with our communication standards, this message was generated from the prompt: "${data.prompt}".`, - `Context window considered: ${getEmailLookbackLabel(data.emailLookback)}.`, - 'Operational interpretation: please proceed casually, but with ceremonial seriousness.', + 'ENTITY DETAILS', + `- Entity ID: ${entity.id}`, + `- Name: ${entity.name}`, + `- Email: ${entity.email}`, + `- Job Title: ${entity.jobTitle}`, + `- Personality: ${getEntityValue(entity.personality)}`, + `- Entity Schedule Cron: ${getEntityValue(entity.scheduleCron)}`, + `- Context Window Days: ${entity.contextWindowDays}`, + `- Active: ${String(entity.active)}`, '', - 'Regards,', - 'Automated Task Preview', + 'TASK DETAILS', + `- Task Name: ${task.name}`, + `- Task Prompt: ${task.prompt}`, + `- Task Schedule Cron: ${task.scheduleCron}`, + `- Email Lookback: ${getEmailLookbackLabel(task.emailLookback)}`, + '', + 'INSTRUCTIONS', + '- Write exactly one email message.', + '- Use an extremely formal corporate tone.', + '- Keep the content casual, mundane, and slightly nonsensical.', + '- Reflect the entity personality and task prompt faithfully.', + '- Output plain text only with no markdown fences.', ].join('\n') } +/** Generates a task preview via the local Ollama model. */ +export async function generateTaskPreview({ entity, task }: TaskPreviewRequest): Promise { + const prompt = buildTaskPreviewPrompt(entity, task) + + try { + const response = await fetch(OLLAMA_GENERATE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + prompt, + stream: false, + }), + }) + + if (!response.ok) { + const message = await readOllamaError(response) + throw new Error(`Unable to generate a test message from the local model. ${message}`) + } + + const data = (await response.json()) as { response?: string } + const generatedMessage = data.response?.trim() + + if (!generatedMessage) { + throw new Error('Unable to generate a test message from the local model. The model returned an empty response.') + } + + return generatedMessage + } catch (error) { + const message = getReadablePreviewError(error) + + if (message.startsWith('Unable to generate a test message from the local model.')) { + throw error + } + + throw new Error(`Unable to generate a test message from the local model. ${message}`) + } +} + /** Returns all scheduled tasks currently configured in local storage. */ export async function getAllTasks(): Promise { return readTasks().filter((task) => task.active) diff --git a/frontend/src/pages/CreateTaskPage.tsx b/frontend/src/pages/CreateTaskPage.tsx index dc91199..e00341a 100644 --- a/frontend/src/pages/CreateTaskPage.tsx +++ b/frontend/src/pages/CreateTaskPage.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useNavigate, useParams } from 'react-router-dom' import { getEntity } from '../api/entitiesApi' import { + buildTaskPreviewPrompt, createTask, generateTaskPreview, type EmailLookback, @@ -83,6 +84,7 @@ export default function CreateTaskPage() { const [cronParts, setCronParts] = useState(DEFAULT_CRON_PARTS) const [taskForm, setTaskForm] = useState(DEFAULT_TASK_FORM) const [preview, setPreview] = useState('') + const [previewError, setPreviewError] = useState('') const { data: entity, isLoading } = useQuery({ queryKey: ['entity', entityId], @@ -100,7 +102,18 @@ export default function CreateTaskPage() { const previewMutation = useMutation({ mutationFn: generateTaskPreview, + onMutate: () => { + setPreview('') + setPreviewError('') + }, onSuccess: (value) => setPreview(value), + onError: (error) => { + setPreviewError( + error instanceof Error + ? error.message + : 'Unable to generate a test message from the local model.' + ) + }, }) const canSubmit = useMemo(() => { @@ -108,6 +121,25 @@ export default function CreateTaskPage() { return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts) }, [cronParts, taskForm.name, taskForm.prompt]) + const currentTask = useMemo( + () => ({ + entityId, + name: taskForm.name, + prompt: taskForm.prompt, + scheduleCron: taskForm.scheduleCron, + emailLookback: taskForm.emailLookback, + }), + [entityId, taskForm.emailLookback, taskForm.name, taskForm.prompt, taskForm.scheduleCron] + ) + + const finalPrompt = useMemo(() => { + if (!entity) { + return 'Entity details unavailable.' + } + + return buildTaskPreviewPrompt(entity, currentTask) + }, [currentTask, entity]) + const applyCronParts = (nextCronParts: CronParts) => { setCronParts(nextCronParts) setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) })) @@ -313,13 +345,8 @@ export default function CreateTaskPage() { type="button" onClick={() => { if (!canSubmit) return - previewMutation.mutate({ - entityId, - name: taskForm.name, - prompt: taskForm.prompt, - scheduleCron: taskForm.scheduleCron, - emailLookback: taskForm.emailLookback, - }) + if (!entity) return + previewMutation.mutate({ entity, task: currentTask }) }} disabled={!canSubmit || previewMutation.isPending} className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50" @@ -327,10 +354,30 @@ export default function CreateTaskPage() { {previewMutation.isPending ? 'Generating…' : 'Generate Test Message'} - {preview && ( -
-                {preview}
+            
+

+ Final Prompt +

+
+                {finalPrompt}
               
+
+ + {previewError && ( +

+ {previewError} +

+ )} + + {preview && ( +
+

+ Generated Message +

+
+                  {preview}
+                
+
)} diff --git a/frontend/src/pages/EditTaskPage.tsx b/frontend/src/pages/EditTaskPage.tsx index d3f8a24..cd251e7 100644 --- a/frontend/src/pages/EditTaskPage.tsx +++ b/frontend/src/pages/EditTaskPage.tsx @@ -4,6 +4,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom' import { getEntity } from '../api/entitiesApi' import { activateTask, + buildTaskPreviewPrompt, deleteTask, generateTaskPreview, getTask, @@ -115,6 +116,7 @@ export default function EditTaskPage() { const [cronParts, setCronParts] = useState(DEFAULT_CRON_PARTS) const [taskForm, setTaskForm] = useState(DEFAULT_TASK_FORM) const [preview, setPreview] = useState('') + const [previewError, setPreviewError] = useState('') const { data: entity, isLoading: isLoadingEntity } = useQuery({ queryKey: ['entity', entityId], @@ -184,7 +186,18 @@ export default function EditTaskPage() { const previewMutation = useMutation({ mutationFn: generateTaskPreview, + onMutate: () => { + setPreview('') + setPreviewError('') + }, onSuccess: (value) => setPreview(value), + onError: (error) => { + setPreviewError( + error instanceof Error + ? error.message + : 'Unable to generate a test message from the local model.' + ) + }, }) const canSubmit = useMemo(() => { @@ -192,6 +205,25 @@ export default function EditTaskPage() { return Boolean(taskForm.name.trim() && taskForm.prompt.trim() && hasFilledCronParts) }, [cronParts, taskForm.name, taskForm.prompt]) + const currentTask = useMemo( + () => ({ + entityId, + name: taskForm.name, + prompt: taskForm.prompt, + scheduleCron: taskForm.scheduleCron, + emailLookback: taskForm.emailLookback, + }), + [entityId, taskForm.emailLookback, taskForm.name, taskForm.prompt, taskForm.scheduleCron] + ) + + const finalPrompt = useMemo(() => { + if (!entity) { + return 'Entity details unavailable.' + } + + return buildTaskPreviewPrompt(entity, currentTask) + }, [currentTask, entity]) + const applyCronParts = (nextCronParts: CronParts) => { setCronParts(nextCronParts) setTaskForm((prev) => ({ ...prev, scheduleCron: buildCron(nextCronParts) })) @@ -402,13 +434,8 @@ export default function EditTaskPage() { type="button" onClick={() => { if (!canSubmit) return - previewMutation.mutate({ - entityId, - name: taskForm.name, - prompt: taskForm.prompt, - scheduleCron: taskForm.scheduleCron, - emailLookback: taskForm.emailLookback, - }) + if (!entity) return + previewMutation.mutate({ entity, task: currentTask }) }} disabled={!canSubmit || previewMutation.isPending} className="rounded-md border border-cyan-500 px-3 py-2 text-sm font-medium text-cyan-300 hover:bg-cyan-500/10 disabled:opacity-50" @@ -416,10 +443,30 @@ export default function EditTaskPage() { {previewMutation.isPending ? 'Generating…' : 'Generate Test Message'} - {preview && ( -
-                {preview}
+            
+

+ Final Prompt +

+
+                {finalPrompt}
               
+
+ + {previewError && ( +

+ {previewError} +

+ )} + + {preview && ( +
+

+ Generated Message +

+
+                  {preview}
+                
+
)}