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.
This commit is contained in:
2026-03-27 01:28:29 -03:00
parent 1a7f5d706a
commit a83ea85857
6 changed files with 394 additions and 42 deletions

View File

@@ -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'
)
})
})

View File

@@ -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(<CreateTaskPage />, { 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(<CreateTaskPage />, { wrapper })
await screen.findByRole('link', { name: /back to entity a/i })

View File

@@ -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 } })

View File

@@ -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<string> {
function getEntityValue(value: string | null | undefined): string {
return value && value.trim().length > 0 ? value : 'Not provided'
}
async function readOllamaError(response: Response): Promise<string> {
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<string> {
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<EntityTaskResponse[]> {
return readTasks().filter((task) => task.active)

View File

@@ -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<CronParts>(DEFAULT_CRON_PARTS)
const [taskForm, setTaskForm] = useState<TaskFormState>(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'}
</button>
{preview && (
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
{preview}
<div className="mt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
Final Prompt
</p>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
{finalPrompt}
</pre>
</div>
{previewError && (
<p role="alert" className="mt-4 text-sm text-red-300">
{previewError}
</p>
)}
{preview && (
<div className="mt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
Generated Message
</p>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
{preview}
</pre>
</div>
)}
</div>

View File

@@ -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<CronParts>(DEFAULT_CRON_PARTS)
const [taskForm, setTaskForm] = useState<TaskFormState>(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'}
</button>
{preview && (
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
{preview}
<div className="mt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
Final Prompt
</p>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
{finalPrompt}
</pre>
</div>
{previewError && (
<p role="alert" className="mt-4 text-sm text-red-300">
{previewError}
</p>
)}
{preview && (
<div className="mt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
Generated Message
</p>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-slate-950 p-4 text-xs text-slate-200">
{preview}
</pre>
</div>
)}
</div>