From 7658feaf5742e98c291d8e0a7d0a732c90ad742e Mon Sep 17 00:00:00 2001 From: vharkins <105030530+vharkins1@users.noreply.github.com> Date: Mon, 25 May 2026 08:54:07 -0700 Subject: [PATCH 1/7] feat(ui): voice recorder, model picker, and multi-form fill tiles Reworks the Fill Form screen: - Mic recorder (Record / Pause / Stop & Transcribe) that posts audio to the local /forms/transcribe endpoint and seeds the input box. Electron grants the `media` permission; audio only ever goes to the local service. - Extraction-model dropdown populated from /forms/models. - Select one or more templates via keyboard-accessible tiles and fill them in a single submit, with per-form outcome reporting. - Drops the manual "Template ID" and "Template Directory" inputs; adds a "Change PDF" control and drop-zone swap. Co-Authored-By: Claude Opus 4.7 --- frontend/app.js | 552 ++++++++++++++++++++++++++++++++++--------- frontend/electron.js | 8 + frontend/index.html | 41 ++-- frontend/styles.css | 134 +++++++++++ 4 files changed, 604 insertions(+), 131 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 2aab25b..4fd15ad 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,7 +1,8 @@ const STORAGE_TEMPLATES_KEY = "fireform.templates.v1"; const STORAGE_LAST_OUTPUT_KEY = "fireform.lastOutputPath.v1"; -const STORAGE_TEMPLATE_DIR_KEY = "fireform.templateDirectory.v1"; -const STORAGE_FIELD_ROWS_KEY = "fireform.fieldRows.v1"; +// Where uploaded template PDFs are copied. Fixed for now; longer term this +// should be user-configurable behind a Settings button (see note below). +const DEFAULT_TEMPLATE_DIRECTORY = "src/inputs"; const API_BASE_URL = "http://127.0.0.1:8000"; // UI label <-> stored type-string mapping. The stored values stay backward @@ -29,7 +30,7 @@ const elements = { templatePdfFile: document.getElementById("templatePdfFile"), pdfDropZone: document.getElementById("pdfDropZone"), selectedFileMeta: document.getElementById("selectedFileMeta"), - templateDirectory: document.getElementById("templateDirectory"), + changePdfBtn: document.getElementById("changePdfBtn"), makeFillableBtn: document.getElementById("makeFillableBtn"), makeFillableHelpBtn: document.getElementById("makeFillableHelpBtn"), makeFillableHelp: document.getElementById("makeFillableHelp"), @@ -39,8 +40,16 @@ const elements = { templateFormMessage: document.getElementById("templateFormMessage"), templateFormResponse: document.getElementById("templateFormResponse"), fillForm: document.getElementById("fillForm"), - fillTemplateId: document.getElementById("fillTemplateId"), + fillModel: document.getElementById("fillModel"), + fillTemplateTiles: document.getElementById("fillTemplateTiles"), + fillSelectionHint: document.getElementById("fillSelectionHint"), + fillSubmitBtn: document.getElementById("fillSubmitBtn"), inputText: document.getElementById("inputText"), + sttControls: document.getElementById("sttControls"), + sttRecordBtn: document.getElementById("sttRecordBtn"), + sttPauseBtn: document.getElementById("sttPauseBtn"), + sttStopBtn: document.getElementById("sttStopBtn"), + sttStatus: document.getElementById("sttStatus"), fillFormMessage: document.getElementById("fillFormMessage"), fillFormResponse: document.getElementById("fillFormResponse"), templatesEmpty: document.getElementById("templatesEmpty"), @@ -55,10 +64,21 @@ const elements = { let templates = loadTemplates(); let activeObjectUrl = null; let selectedTemplateFile = null; -let fieldRows = loadFieldRows(); +// Field rows are scratch state for building one template — they start empty +// each session and are not persisted. +let fieldRows = DEFAULT_FIELD_ROWS.map((row) => ({ ...row })); let dragSourceIndex = null; let uploadedPath = null; let uploadedFieldCount = null; +// Template ids currently selected in the Fill Form tab (multi-select). +let selectedFillIds = new Set(); + +// Speech-to-text recording state. The MediaRecorder captures compressed audio +// in the renderer; on stop we POST it straight to /forms/transcribe (the local +// Whisper service handles decoding). +let mediaRecorder = null; +let recordedChunks = []; +let recordingStream = null; waitForBackend().then(initialize); @@ -88,11 +108,12 @@ async function waitForBackend() { async function initialize() { bindEvents(); - restoreTemplateDirectory(); renderFieldRows(); renderTemplates(); + renderFillTemplates(); restorePreviewState(); updateSelectedFileMeta(); + loadModels(); await refreshTemplatesFromApi(); } @@ -105,12 +126,17 @@ function bindEvents() { elements.templatePdfFile.addEventListener("change", handleTemplateFileInput); elements.pdfDropZone.addEventListener("click", () => elements.templatePdfFile.click()); elements.pdfDropZone.addEventListener("keydown", handleDropZoneKeyDown); - elements.templateDirectory.addEventListener("input", handleTemplateDirectoryInput); + elements.changePdfBtn.addEventListener("click", () => elements.templatePdfFile.click()); elements.addFieldBtn.addEventListener("click", handleAddFieldClick); elements.makeFillableBtn.addEventListener("click", handleMakeFillableClick); elements.makeFillableHelpBtn.addEventListener("click", toggleMakeFillableHelp); bindDropZoneDragEvents(); elements.fillForm.addEventListener("submit", handleFillSubmit); + elements.fillTemplateTiles.addEventListener("click", handleTileClick); + elements.fillTemplateTiles.addEventListener("keydown", handleTileKeydown); + elements.sttRecordBtn.addEventListener("click", startRecording); + elements.sttPauseBtn.addEventListener("click", togglePauseRecording); + elements.sttStopBtn.addEventListener("click", stopRecording); elements.templatesList.addEventListener("click", handleTemplateActionClick); elements.localPdfFile.addEventListener("change", handleLocalFilePreview); elements.previewPathBtn.addEventListener("click", () => @@ -138,7 +164,11 @@ async function refreshTemplatesFromApi() { fields: template.fields || {}, })); saveTemplates(); + // Drop selections for templates that no longer exist. + const liveIds = new Set(templates.map((t) => Number(t.id))); + selectedFillIds.forEach((id) => { if (!liveIds.has(id)) selectedFillIds.delete(id); }); renderTemplates(); + renderFillTemplates(); } } catch (error) { setStatus( @@ -184,26 +214,6 @@ function handleTemplateFileInput(event) { setSelectedTemplateFile(file); } -function handleTemplateDirectoryInput() { - const directory = normalizeDirectory(elements.templateDirectory.value); - localStorage.setItem(STORAGE_TEMPLATE_DIR_KEY, directory); - updateSelectedFileMeta(); -} - -function restoreTemplateDirectory() { - const saved = localStorage.getItem(STORAGE_TEMPLATE_DIR_KEY); - if (saved) { - elements.templateDirectory.value = saved; - } -} - -function normalizeDirectory(value) { - return String(value || "") - .trim() - .replace(/\\/g, "/") - .replace(/\/+$/, ""); -} - function setSelectedTemplateFile(file) { if (!file) { return; @@ -234,8 +244,7 @@ function setSelectedTemplateFile(file) { async function uploadSelectedFileSilently() { if (!selectedTemplateFile) return; - const directory = normalizeDirectory(elements.templateDirectory.value); - if (!directory) return; + const directory = DEFAULT_TEMPLATE_DIRECTORY; const fileAtUploadStart = selectedTemplateFile; try { @@ -252,18 +261,28 @@ async function uploadSelectedFileSilently() { } } -// Prefill the field rows from the PDF's own form fields, but never overwrite -// rows the user has already started filling in. +// Auto-add a row per field the PDF already defines — same as clicking "+ Add +// Field" for each — filling in its description and type so the user can edit. +// If the list already has rows the user typed, warn before replacing them. function maybeSeedFieldRows(fields) { if (!Array.isArray(fields) || !fields.length) return; syncFieldRowsFromDom(); - if (!fieldRows.every((row) => !row.name.trim())) return; + + if (fieldRows.some((row) => row.name.trim())) { + const replace = window.confirm( + `This PDF has ${fields.length} fillable field${fields.length === 1 ? "" : "s"}.\n\n` + + "Replace your current form fields with them? Your existing entries will be lost." + ); + if (!replace) { + setStatus(elements.templateFormMessage, "Kept your existing form fields.", "info"); + return; + } + } fieldRows = fields.map((f) => ({ name: f.description || f.name || "", type: normalizeFieldType(f.type), })); - saveFieldRows(); renderFieldRows(); setStatus( elements.templateFormMessage, @@ -310,15 +329,17 @@ function isPdfFile(file) { } function updateSelectedFileMeta() { - if (!selectedTemplateFile) { + // Once a file is chosen, swap the drop zone for a compact "change" control. + const hasFile = !!selectedTemplateFile; + elements.pdfDropZone.classList.toggle("hidden", hasFile); + elements.changePdfBtn.classList.toggle("hidden", !hasFile); + + if (!hasFile) { elements.selectedFileMeta.textContent = "No PDF selected."; return; } - const directory = normalizeDirectory(elements.templateDirectory.value); - const destinationPath = directory - ? `${directory}/${selectedTemplateFile.name}` - : selectedTemplateFile.name; + const destinationPath = `${DEFAULT_TEMPLATE_DIRECTORY}/${selectedTemplateFile.name}`; elements.selectedFileMeta.textContent = `Selected: ${selectedTemplateFile.name} (${formatBytes( selectedTemplateFile.size @@ -399,13 +420,13 @@ async function handleTemplateSubmit(event) { setStatus(elements.templateFormMessage, ""); const name = elements.templateName.value.trim(); - const templateDirectory = normalizeDirectory(elements.templateDirectory.value); + const templateDirectory = DEFAULT_TEMPLATE_DIRECTORY; const collected = collectFieldRows(); - if (!name || !templateDirectory || !selectedTemplateFile) { + if (!name || !selectedTemplateFile) { setStatus( elements.templateFormMessage, - "Name, PDF file, and template directory are required.", + "Name and PDF file are required.", "error" ); return; @@ -417,8 +438,6 @@ async function handleTemplateSubmit(event) { } try { - localStorage.setItem(STORAGE_TEMPLATE_DIR_KEY, templateDirectory); - saveFieldRows(); let activePdfPath = uploadedPath; if (!activePdfPath) { setStatus(elements.templateFormMessage, "Copying PDF into project directory...", "info"); @@ -446,8 +465,10 @@ async function handleTemplateSubmit(event) { } upsertTemplate(body); + if (body.id != null) { + selectedFillIds.add(Number(body.id)); + } await refreshTemplatesFromApi(); - elements.fillTemplateId.value = String(body.id || ""); elements.serverPdfPath.value = body.pdf_path || ""; const expected = body.field_count; @@ -492,59 +513,400 @@ async function uploadTemplatePdf(file, directory) { return body; } +// ───────────────────────── Fill Form: model + template tiles ────────────── + +// "1 field" / "3 forms" — keeps the count-and-label logic in one place. +function pluralize(count, noun) { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +// Look up a template by id (ids may arrive as strings from dataset attributes). +function findTemplate(id) { + return templates.find((template) => Number(template.id) === Number(id)); +} + +// Populate the model picker from the local Ollama models the API reports. +async function loadModels() { + const select = elements.fillModel; + try { + const response = await fetch(`${API_BASE_URL}/forms/models`); + const body = await parseJsonResponse(response); + if (!response.ok) { + throw new Error(extractErrorMessage(body, response.status)); + } + + select.innerHTML = ""; + const models = body.models || []; + models.forEach((name) => { + const isDefault = name === body.default; + const option = document.createElement("option"); + option.value = name; + option.textContent = isDefault ? `${name} (default)` : name; + option.selected = isDefault; + select.append(option); + }); + } catch (_error) { + // Ollama unreachable — leave one placeholder so the picker isn't empty. + if (!select.options.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "(default model)"; + select.append(option); + } + } +} + +// Build one selectable tile. Whether it's selected is shown purely through the +// tile's highlighted styling (.selected) — there's no separate checkbox. +function createTemplateTile(template) { + const id = Number(template.id); + const selected = selectedFillIds.has(id); + + const tile = document.createElement("div"); + tile.className = selected ? "template-tile selected" : "template-tile"; + tile.dataset.templateId = String(id); + // Behaves like a toggle button for keyboard and screen-reader users. + tile.setAttribute("role", "button"); + tile.setAttribute("tabindex", "0"); + tile.setAttribute("aria-pressed", String(selected)); + + const title = document.createElement("span"); + title.className = "tile-title"; + title.textContent = template.name || "Untitled"; + + const fieldCount = template.fields ? Object.keys(template.fields).length : 0; + const meta = document.createElement("span"); + meta.className = "tile-meta"; + meta.textContent = pluralize(fieldCount, "field"); + + const body = document.createElement("div"); + body.className = "tile-body"; + body.append(title, meta); + + // Preview must not toggle selection, so it carries its own id and the click + // handler stops the event from bubbling up to the tile. + const previewButton = document.createElement("button"); + previewButton.type = "button"; + previewButton.className = "tile-preview-btn"; + previewButton.dataset.previewId = String(id); + previewButton.textContent = "Preview"; + + tile.append(body, previewButton); + return tile; +} + +function renderFillTemplates() { + const container = elements.fillTemplateTiles; + container.innerHTML = ""; + + if (!templates.length) { + const empty = document.createElement("p"); + empty.className = "empty-state"; + empty.textContent = "No templates yet — create one in the Create Template tab."; + container.append(empty); + updateFillButtonState(); + return; + } + + templates.forEach((template) => container.append(createTemplateTile(template))); + updateFillButtonState(); +} + +function handleTileClick(event) { + // A click on the Preview button previews the PDF without toggling selection. + const previewButton = event.target.closest(".tile-preview-btn"); + if (previewButton) { + event.stopPropagation(); + const template = findTemplate(previewButton.dataset.previewId); + if (template) { + elements.serverPdfPath.value = template.pdf_path || ""; + previewFromPath(template.pdf_path || "", { switchToPreview: true }); + } + return; + } + + // A click anywhere else on the tile toggles it on/off for filling. + const tile = event.target.closest(".template-tile"); + if (tile) { + toggleFillSelection(Number(tile.dataset.templateId)); + } +} + +function handleTileKeydown(event) { + // Enter/Space activate the focused tile, matching its role="button". + if (event.key !== "Enter" && event.key !== " ") { + return; + } + const tile = event.target.closest(".template-tile"); + if (tile) { + event.preventDefault(); + toggleFillSelection(Number(tile.dataset.templateId)); + } +} + +function toggleFillSelection(id) { + if (selectedFillIds.has(id)) { + selectedFillIds.delete(id); + } else { + selectedFillIds.add(id); + } + renderFillTemplates(); +} + +function updateFillButtonState() { + const count = selectedFillIds.size; + const nothingSelected = count === 0; + + // Greyed out (but still clickable) until at least one form is chosen. + elements.fillSubmitBtn.classList.toggle("is-disabled", nothingSelected); + elements.fillSubmitBtn.textContent = count > 1 ? `Fill ${count} Forms` : "Fill Form"; + + elements.fillSelectionHint.classList.remove("error"); + elements.fillSelectionHint.textContent = nothingSelected + ? "Select one or more forms to fill." + : `${pluralize(count, "form")} selected.`; +} + +// A human-readable label for a template, used in the success/error summary. +function templateLabel(id) { + const template = findTemplate(id); + return template && template.name ? template.name : `id ${id}`; +} + +// Fill a single template and return its submission. Throws on failure so the +// caller can note which form failed and still continue with the others. +async function fillOneTemplate(id, inputText, model) { + const response = await fetch(`${API_BASE_URL}/forms/fill`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ template_id: id, input_text: inputText, model }), + }); + const body = await parseJsonResponse(response); + if (!response.ok) { + throw new Error(extractErrorMessage(body, response.status)); + } + return body; +} + +// Summarize "N filled, M failed" into the status line, choosing the right tone: +// all-good = success, some failed but some worked = info, nothing worked = error. +function reportFillOutcome(results, errors) { + const parts = []; + if (results.length) parts.push(`${results.length} filled`); + if (errors.length) parts.push(`${errors.length} failed`); + + let level = "success"; + if (errors.length) { + level = results.length ? "info" : "error"; + } + + const detail = errors.length ? ` ${errors.join("; ")}` : ""; + setStatus(elements.fillFormMessage, `${parts.join(", ")}.${detail}`, level); +} + async function handleFillSubmit(event) { event.preventDefault(); clearJson(elements.fillFormResponse); setStatus(elements.fillFormMessage, ""); - const templateId = Number(elements.fillTemplateId.value); - const inputText = elements.inputText.value.trim(); - - if (!Number.isInteger(templateId) || templateId < 1) { - setStatus(elements.fillFormMessage, "Template ID must be a positive integer.", "error"); + const ids = Array.from(selectedFillIds); + if (!ids.length) { + // The button looks disabled but stays clickable, so prompt the user here. + elements.fillSelectionHint.classList.add("error"); + elements.fillSelectionHint.textContent = "Select at least one form to fill."; + setStatus(elements.fillFormMessage, "Select at least one form to fill.", "error"); return; } + const inputText = elements.inputText.value.trim(); if (!inputText) { setStatus(elements.fillFormMessage, "Input text is required.", "error"); return; } - const payload = { - template_id: templateId, - input_text: inputText, - }; + // An empty picker value means "let the server use its default model". + const model = elements.fillModel.value || undefined; + setStatus(elements.fillFormMessage, `Filling ${pluralize(ids.length, "form")}…`, "info"); - try { - setStatus(elements.fillFormMessage, "Submitting form fill request...", "info"); - const response = await fetch(`${API_BASE_URL}/forms/fill`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); + // Fill each selected form independently so one failure doesn't stop the rest. + const results = []; + const errors = []; + for (const id of ids) { + try { + results.push(await fillOneTemplate(id, inputText, model)); + } catch (error) { + errors.push(`${templateLabel(id)}: ${error.message}`); + } + } - const body = await parseJsonResponse(response); - if (!response.ok) { - throw new Error(extractErrorMessage(body, response.status)); + const lastResult = results[results.length - 1]; + if (lastResult) { + showJson(elements.fillFormResponse, results.length === 1 ? lastResult : results); + if (lastResult.output_pdf_path) { + localStorage.setItem(STORAGE_LAST_OUTPUT_KEY, lastResult.output_pdf_path); + elements.serverPdfPath.value = lastResult.output_pdf_path; } + } + + reportFillOutcome(results, errors); - if (body.output_pdf_path) { - localStorage.setItem(STORAGE_LAST_OUTPUT_KEY, body.output_pdf_path); - elements.serverPdfPath.value = body.output_pdf_path; - await previewFromPath(body.output_pdf_path, { switchToPreview: true }); + // Preview the most recently filled PDF. + if (lastResult && lastResult.output_pdf_path) { + await previewFromPath(lastResult.output_pdf_path, { switchToPreview: true }); + } +} + +// ───────────────────────── Speech-to-text (local Whisper) ───────────────── + +function setSttStatus(message) { + if (elements.sttStatus) { + elements.sttStatus.textContent = message || ""; + } +} + +async function startRecording() { + if (mediaRecorder) { + return; + } + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + setSttStatus("Microphone capture is not available in this environment."); + return; + } + + try { + recordingStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (error) { + setSttStatus("Microphone permission denied."); + return; + } + + recordedChunks = []; + mediaRecorder = new MediaRecorder(recordingStream); + mediaRecorder.addEventListener("dataavailable", (event) => { + if (event.data && event.data.size > 0) { + recordedChunks.push(event.data); } + }); + mediaRecorder.addEventListener("stop", handleRecordingStop); + mediaRecorder.start(); + + elements.sttControls.classList.add("is-recording"); + elements.sttControls.classList.remove("is-paused"); + elements.sttRecordBtn.disabled = true; + elements.sttPauseBtn.disabled = false; + elements.sttStopBtn.disabled = false; + elements.sttPauseBtn.textContent = "Pause"; + setSttStatus("Recording…"); +} - setStatus( - elements.fillFormMessage, - `Form filled (submission id: ${body.id}).`, - "success" - ); - showJson(elements.fillFormResponse, body); +function togglePauseRecording() { + if (!mediaRecorder) { + return; + } + if (mediaRecorder.state === "recording") { + mediaRecorder.pause(); + elements.sttControls.classList.add("is-paused"); + elements.sttControls.classList.remove("is-recording"); + elements.sttPauseBtn.textContent = "Resume"; + setSttStatus("Paused."); + } else if (mediaRecorder.state === "paused") { + mediaRecorder.resume(); + elements.sttControls.classList.add("is-recording"); + elements.sttControls.classList.remove("is-paused"); + elements.sttPauseBtn.textContent = "Pause"; + setSttStatus("Recording…"); + } +} + +function stopRecording() { + if (!mediaRecorder) { + return; + } + // Lock the controls while we finalize capture and transcribe. + elements.sttPauseBtn.disabled = true; + elements.sttStopBtn.disabled = true; + setSttStatus("Finishing capture…"); + mediaRecorder.stop(); +} + +async function handleRecordingStop() { + elements.sttControls.classList.remove("is-recording", "is-paused"); + stopRecordingStream(); + + const chunks = recordedChunks; + const recorder = mediaRecorder; + recordedChunks = []; + mediaRecorder = null; + + const blob = new Blob(chunks, { type: (recorder && recorder.mimeType) || "audio/webm" }); + if (!blob.size) { + resetSttControls(); + setSttStatus("Nothing was recorded."); + return; + } + + try { + setSttStatus("Transcribing…"); + const text = await transcribeAudio(blob); + appendTranscribedText(text); + setSttStatus(text ? "Transcription added." : "No speech detected."); } catch (error) { - setStatus(elements.fillFormMessage, error.message, "error"); + setSttStatus(`Transcription failed: ${error.message}`); + } finally { + resetSttControls(); + } +} + +function resetSttControls() { + elements.sttRecordBtn.disabled = false; + elements.sttPauseBtn.disabled = true; + elements.sttStopBtn.disabled = true; + elements.sttPauseBtn.textContent = "Pause"; + elements.sttControls.classList.remove("is-recording", "is-paused"); +} + +function stopRecordingStream() { + if (recordingStream) { + recordingStream.getTracks().forEach((track) => track.stop()); + recordingStream = null; } } +function appendTranscribedText(text) { + if (!text) { + return; + } + const existing = elements.inputText.value.trim(); + elements.inputText.value = existing ? `${existing} ${text}` : text; + // Let any listeners (and the required-field check) see the new value. + elements.inputText.dispatchEvent(new Event("input")); +} + +// "audio/webm;codecs=opus" -> "webm". Just gives the upload a sensible filename; +// the server decodes by content, not extension. +function audioExtension(mimeType) { + const subtype = (mimeType || "").split("/")[1] || ""; + const withoutCodecs = subtype.split(";")[0].trim(); + return withoutCodecs || "webm"; +} + +// The Whisper ASR service decodes audio with ffmpeg, so we post the recording +// as-is (typically webm/opus) — no client-side transcoding needed. +async function transcribeAudio(blob) { + const formData = new FormData(); + formData.append("audio", blob, `recording.${audioExtension(blob.type)}`); + + const response = await fetch(`${API_BASE_URL}/forms/transcribe`, { + method: "POST", + body: formData, + }); + const body = await parseJsonResponse(response); + if (!response.ok) { + throw new Error(extractErrorMessage(body, response.status)); + } + return (body.text || "").trim(); +} + function handleTemplateActionClick(event) { const button = event.target.closest("button[data-action]"); if (!button) { @@ -564,12 +926,12 @@ function handleTemplateActionClick(event) { } if (button.dataset.action === "use-fill") { - elements.fillTemplateId.value = String(template.id); + selectedFillIds.add(Number(template.id)); + renderFillTemplates(); activateSection("fillFormSection"); - elements.fillTemplateId.focus(); setStatus( elements.fillFormMessage, - `Template ${template.id} loaded into Fill Form.`, + `"${template.name || "Template"}" selected for filling.`, "info" ); } @@ -733,35 +1095,10 @@ function buildFieldsTable(fieldsDict) { return table; } -function loadFieldRows() { - try { - const raw = localStorage.getItem(STORAGE_FIELD_ROWS_KEY); - if (!raw) { - return DEFAULT_FIELD_ROWS.map((row) => ({ ...row })); - } - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return DEFAULT_FIELD_ROWS.map((row) => ({ ...row })); - } - return parsed - .filter((item) => item && typeof item === "object") - .map((item) => ({ - name: typeof item.name === "string" ? item.name : "", - type: normalizeFieldType(item.type), - })); - } catch (_error) { - return DEFAULT_FIELD_ROWS.map((row) => ({ ...row })); - } -} - function normalizeFieldType(value) { return TYPE_VALUE_TO_LABEL[value] ? value : "string"; } -function saveFieldRows() { - localStorage.setItem(STORAGE_FIELD_ROWS_KEY, JSON.stringify(fieldRows)); -} - function syncFieldRowsFromDom() { const rowEls = Array.from(elements.fieldsBuilder.querySelectorAll(".field-row")); fieldRows = rowEls.map((rowEl) => ({ @@ -798,7 +1135,6 @@ function buildFieldRow(row, index) { nameInput.value = row.name || ""; nameInput.addEventListener("input", () => { syncFieldRowsFromDom(); - saveFieldRows(); }); const typeSelect = document.createElement("select"); @@ -812,7 +1148,6 @@ function buildFieldRow(row, index) { typeSelect.value = normalizeFieldType(row.type); typeSelect.addEventListener("change", () => { syncFieldRowsFromDom(); - saveFieldRows(); }); const deleteBtn = document.createElement("button"); @@ -824,7 +1159,6 @@ function buildFieldRow(row, index) { syncFieldRowsFromDom(); const rowIndex = Number(rowEl.dataset.index); fieldRows.splice(rowIndex, 1); - saveFieldRows(); renderFieldRows(); }); @@ -850,11 +1184,7 @@ async function handleMakeFillableClick() { return; } - const templateDirectory = normalizeDirectory(elements.templateDirectory.value); - if (!templateDirectory) { - setStatus(elements.templateFormMessage, "Template directory is required.", "error"); - return; - } + const templateDirectory = DEFAULT_TEMPLATE_DIRECTORY; elements.makeFillableBtn.disabled = true; const previousLabel = elements.makeFillableBtn.textContent; @@ -904,7 +1234,6 @@ async function handleMakeFillableClick() { function handleAddFieldClick() { syncFieldRowsFromDom(); fieldRows.push({ name: "", type: "string" }); - saveFieldRows(); renderFieldRows(); const rows = elements.fieldsBuilder.querySelectorAll(".field-row .field-name"); if (rows.length) { @@ -946,7 +1275,6 @@ function handleRowDrop(event) { const [moved] = fieldRows.splice(dragSourceIndex, 1); fieldRows.splice(targetIndex, 0, moved); dragSourceIndex = null; - saveFieldRows(); renderFieldRows(); } diff --git a/frontend/electron.js b/frontend/electron.js index ded5ee1..9fc7e19 100644 --- a/frontend/electron.js +++ b/frontend/electron.js @@ -30,6 +30,14 @@ function createWindow() { }, }); + // Allow microphone capture for the local speech-to-text recorder. Audio is + // only ever sent to the local Whisper service — nothing leaves the machine. + win.webContents.session.setPermissionRequestHandler( + (webContents, permission, callback) => { + callback(permission === "media"); + } + ); + win.loadFile("index.html"); if (!app.isPackaged) { win.webContents.openDevTools(); diff --git a/frontend/index.html b/frontend/index.html index 0956384..d525015 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -54,6 +54,7 @@

Create Template

or click to select a file

No PDF selected.

+
+ + + +
- + +

Select one or more forms to fill.

+
+ + + + +

diff --git a/frontend/styles.css b/frontend/styles.css index 126c37a..c5b4a69 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -244,6 +244,140 @@ button:hover { background: #e5ecf3; } +.stt-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.stt-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 14px; +} + +.stt-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.stt-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #c2ccd6; + flex-shrink: 0; +} + +.stt-controls.is-recording .stt-dot { + background: var(--error); + animation: stt-pulse 1.1s ease-in-out infinite; +} + +.stt-controls.is-paused .stt-dot { + background: var(--primary); +} + +@keyframes stt-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.35; transform: scale(0.78); } +} + +.stt-status { + color: var(--muted); + font-size: 0.92rem; + min-height: 1.2em; +} + +/* Fill Form: model picker, template tiles, view toggle */ +.helper.error { + color: var(--error); + font-weight: 600; +} + +.template-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 14px; +} + +.template-tile { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + min-height: 112px; + border: 1px solid var(--panel-border); + border-radius: 12px; + background: #fbfcfe; + padding: 16px; + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; +} + +.template-tile:hover { + border-color: #9ab1c7; +} + +.template-tile:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; +} + +.template-tile.selected { + border-color: var(--primary); + background: #eef7ff; + box-shadow: inset 0 0 0 1px var(--primary); +} + +.tile-body { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.tile-title { + font-weight: 700; + color: var(--text); + /* Uniform tiles: clamp long titles to two lines, then ellipsis. */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +} + +.tile-meta { + font-size: 0.85rem; + color: var(--muted); +} + +.tile-preview-btn { + margin-top: auto; /* pin to the bottom so every tile's button lines up */ + align-self: flex-start; + background: transparent; + color: var(--primary-strong); + border: 1px solid var(--panel-border); + border-radius: 8px; + padding: 5px 10px; + font-size: 0.82rem; + font-weight: 600; +} + +.tile-preview-btn:hover { + background: #e5ecf3; +} + +/* Greyed-but-clickable: looks disabled, still fires click to prompt the user. */ +button.is-disabled, +button.is-disabled:hover { + background: #c2ccd6; + cursor: not-allowed; +} + .help-trigger { background: #e5ecf3; color: var(--primary-strong); From 74ac43be132cfae7f05cf86e337d04368ba66d73 Mon Sep 17 00:00:00 2001 From: vharkins <105030530+vharkins1@users.noreply.github.com> Date: Mon, 25 May 2026 08:54:19 -0700 Subject: [PATCH 2/7] feat(api): voice transcription and model-list endpoints - POST /forms/transcribe forwards uploaded audio to the local Whisper sidecar (WHISPER_HOST) and returns the transcript; audio is streamed through and never persisted. Maps connection failures to 503. - GET /forms/models lists the Ollama models the local instance reports, always surfacing the configured default even if not yet pulled. - FormFill gains an optional `model` override, excluded before building the FormSubmission row (it is a runtime hint, not a column). Co-Authored-By: Claude Opus 4.7 --- api/routes/forms.py | 84 +++++++++++++++++++++++++++++++++++++++++--- api/schemas/forms.py | 16 ++++++++- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/api/routes/forms.py b/api/routes/forms.py index 5687412..74069ea 100644 --- a/api/routes/forms.py +++ b/api/routes/forms.py @@ -1,7 +1,15 @@ -from fastapi import APIRouter, Depends +import os + +import requests +from fastapi import APIRouter, Depends, File, UploadFile from sqlmodel import Session from api.deps import get_db -from api.schemas.forms import FormFill, FormFillResponse +from api.schemas.forms import ( + FormFill, + FormFillResponse, + ModelsResponse, + TranscriptionResponse, +) from api.db.repositories import create_form, get_template from api.db.models import FormSubmission from api.errors.base import AppError @@ -23,9 +31,77 @@ def fill_form(form: FormFill, db: Session = Depends(get_db)): user_input=form.input_text, fields=fetched_template.fields, pdf_form_path=fetched_template.pdf_path, + model=form.model, + ) + + # `model` is a runtime override, not a column — keep it out of the DB row. + submission = FormSubmission( + **form.model_dump(exclude={"model"}), output_pdf_path=path ) - - submission = FormSubmission(**form.model_dump(), output_pdf_path=path) return create_form(db, submission) except Exception as e: raise AppError(str(e), status_code=500) + + +@router.get("/models", response_model=ModelsResponse) +def list_models(): + """List the Whisper-independent extraction models available in the local + Ollama instance, plus the configured default. Used by the Fill Form UI's + model picker. Falls back to just the default if Ollama is unreachable.""" + default_model = os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b") + ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/") + + models: list[str] = [] + try: + response = requests.get(f"{ollama_host}/api/tags", timeout=10) + response.raise_for_status() + models = [m["name"] for m in response.json().get("models", []) if m.get("name")] + except requests.exceptions.RequestException: + models = [] + + # Always surface the configured default, even if Ollama hasn't pulled it yet. + if default_model not in models: + models.insert(0, default_model) + + return ModelsResponse(models=models, default=default_model) + + +@router.post("/transcribe", response_model=TranscriptionResponse) +def transcribe(audio: UploadFile = File(...)): + """Forward recorded audio to the local Whisper ASR sidecar and return text. + + Mirrors the Ollama wiring: WHISPER_HOST points at the whisper service + (http://whisper:9000 inside Docker, http://localhost:9000 otherwise). The + audio is streamed straight through to the local STT service and never + persisted — no PII leaves the machine. + """ + whisper_host = os.getenv("WHISPER_HOST", "http://localhost:9000").rstrip("/") + whisper_url = f"{whisper_host}/asr" + + files = { + "audio_file": ( + audio.filename or "audio.wav", + audio.file.read(), + audio.content_type or "audio/wav", + ) + } + params = {"task": "transcribe", "output": "json", "encode": "true"} + + try: + response = requests.post(whisper_url, params=params, files=files, timeout=120) + response.raise_for_status() + except requests.exceptions.ConnectionError: + raise AppError( + f"Could not connect to the speech-to-text service at {whisper_url}. " + "Please ensure the whisper service is running.", + status_code=503, + ) + except requests.exceptions.RequestException as e: + raise AppError(f"Transcription failed: {e}", status_code=502) + + try: + text = (response.json().get("text") or "").strip() + except ValueError: + text = response.text.strip() + + return TranscriptionResponse(text=text) diff --git a/api/schemas/forms.py b/api/schemas/forms.py index bf6957e..f925779 100644 --- a/api/schemas/forms.py +++ b/api/schemas/forms.py @@ -1,8 +1,13 @@ +from typing import Optional + from pydantic import BaseModel, field_validator class FormFill(BaseModel): template_id: int input_text: str + # Optional Ollama model override for this fill; falls back to OLLAMA_MODEL. + # Not persisted (no DB column) — excluded before building FormSubmission. + model: Optional[str] = None @field_validator("input_text") def validate_input_text(cls, value): @@ -18,4 +23,13 @@ class FormFillResponse(BaseModel): output_pdf_path: str class Config: - from_attributes = True \ No newline at end of file + from_attributes = True + + +class TranscriptionResponse(BaseModel): + text: str + + +class ModelsResponse(BaseModel): + models: list[str] + default: str \ No newline at end of file From e1d66610b4b1a5f023a6c1041b7dbcc75b5d8d58 Mon Sep 17 00:00:00 2001 From: vharkins <105030530+vharkins1@users.noreply.github.com> Date: Mon, 25 May 2026 08:54:19 -0700 Subject: [PATCH 3/7] feat(core): per-fill model override and qwen2.5 default - LLM accepts an optional `model`, threaded through Controller and FileManipulator, falling back to OLLAMA_MODEL then qwen2.5:1.5b. - Replaces the hardcoded `mistral` default across llm.py, the Makefile pull-model target (now OLLAMA_MODEL-configurable), and test_model.py. Co-Authored-By: Claude Opus 4.7 --- Makefile | 10 +++++++--- src/controller.py | 4 ++-- src/file_manipulator.py | 3 ++- src/llm.py | 7 +++++-- src/test/test_model.py | 4 +++- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 527da5b..026a721 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,9 @@ .PHONY: help build up down logs shell exec pull-model test clean fireform logs-app logs-ollama logs-frontend super-clean +# The extraction model pulled into Ollama and used by src/llm.py. Override with +# `make pull-model OLLAMA_MODEL=...`. A 1.5B model keeps per-field fills fast. +OLLAMA_MODEL ?= qwen2.5:1.5b + help: @printf '%s\n' \ ' ______ ______ ' \ @@ -21,13 +25,13 @@ help: @echo "make logs-ollama - View Ollama container logs" @echo "make shell - Open Python shell in app container" @echo "make exec - Execute Python script in container" - @echo "make pull-model - Pull Mistral model into Ollama" + @echo "make pull-model - Pull the extraction model ($(OLLAMA_MODEL)) into Ollama" @echo "make test - Run tests" @echo "make clean - Remove containers" @echo "make super-clean - [CAUTION] Use carefully. Cleans up ALL stopped containers, networks, build cache..." # Fix #382 — pull-model is now part of the main setup flow -# Mistral is pulled automatically before you need it +# The extraction model is pulled automatically before you need it fireform: build up pull-model @echo "" @echo "✅ FireForm is ready!" @@ -69,7 +73,7 @@ exec: docker compose exec app python3 src/main.py pull-model: - docker compose exec ollama ollama pull mistral + docker compose exec ollama ollama pull $(OLLAMA_MODEL) # Fix — correct test directory (was src/test/ which doesn't exist) test: diff --git a/src/controller.py b/src/controller.py index 57348bf..0e19290 100644 --- a/src/controller.py +++ b/src/controller.py @@ -4,8 +4,8 @@ class Controller: def __init__(self): self.file_manipulator = FileManipulator() - def fill_form(self, user_input: str, fields: list, pdf_form_path: str): - return self.file_manipulator.fill_form(user_input, fields, pdf_form_path) + def fill_form(self, user_input: str, fields: list, pdf_form_path: str, model: str = None): + return self.file_manipulator.fill_form(user_input, fields, pdf_form_path, model=model) def prepare_fillable(self, pdf_path: str): return self.file_manipulator.prepare_fillable(pdf_path) \ No newline at end of file diff --git a/src/file_manipulator.py b/src/file_manipulator.py index 1c59180..ed8e1ff 100644 --- a/src/file_manipulator.py +++ b/src/file_manipulator.py @@ -34,7 +34,7 @@ def patched_ensure(model_ctx): prepare_form(pdf_path, template_path) return template_path - def fill_form(self, user_input: str, fields: list, pdf_form_path: str): + def fill_form(self, user_input: str, fields: list, pdf_form_path: str, model: str = None): """ It receives the raw data, runs the PDF filling logic, and returns the path to the newly created file. @@ -50,6 +50,7 @@ def fill_form(self, user_input: str, fields: list, pdf_form_path: str): try: self.llm._target_fields = fields self.llm._transcript_text = user_input + self.llm._model = model output_name = self.filler.fill_form(pdf_form=pdf_form_path, llm=self.llm) print("\n----------------------------------") diff --git a/src/llm.py b/src/llm.py index 6af7f05..053b883 100644 --- a/src/llm.py +++ b/src/llm.py @@ -5,10 +5,12 @@ class LLM: - def __init__(self, transcript_text: str=None, target_fields: list=None, json_dict: dict=None): + def __init__(self, transcript_text: str=None, target_fields: list=None, json_dict: dict=None, model: str=None): self._transcript_text = transcript_text self._target_fields = target_fields self._json = json_dict if json_dict is not None else {} + # Optional per-request model override; falls back to OLLAMA_MODEL env. + self._model = model def build_prompt(self, current_field: str, current_type: str = "string"): """ @@ -31,9 +33,10 @@ def main_loop(self): prompt = self.build_prompt(field, field_type if isinstance(field_type, str) else "string") ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/") ollama_url = f"{ollama_host}/api/generate" + ollama_model = self._model or os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b") payload = { - "model": "mistral", + "model": ollama_model, "prompt": prompt, "stream": False, } diff --git a/src/test/test_model.py b/src/test/test_model.py index 7baefba..6de314f 100644 --- a/src/test/test_model.py +++ b/src/test/test_model.py @@ -1,8 +1,10 @@ # test_ollama.py +import os + import ollama try: - response = ollama.chat(model='mistral', messages=[ + response = ollama.chat(model=os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b"), messages=[ {'role': 'user', 'content': 'Say hello in Spanish'} ]) print("Success! Response:", response['message']['content']) From 3fc0cb6c0d2e74e9e2e9edba08440007f4c3d7ac Mon Sep 17 00:00:00 2001 From: vharkins <105030530+vharkins1@users.noreply.github.com> Date: Mon, 25 May 2026 08:54:19 -0700 Subject: [PATCH 4/7] chore: whisper sidecar, persistent DB volume, and tests - Adds the whisper ASR service (faster-whisper small.en) plus WHISPER_HOST and OLLAMA_MODEL wiring; app now depends on it starting. - Persists the SQLite DB in a fireform_db volume so created templates survive container rebuilds. - Adds API tests for the transcribe (incl. service-down 503) and models endpoints and the per-fill model override; gitignores local CLAUDE.md. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 5 ++- docker-compose.yml | 35 +++++++++++++++++++ tests/test_api.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6ef5bae..5ed224e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ src/inputs/*.pdf .codex/ # Electron build artifacts -frontend/release/ \ No newline at end of file +frontend/release/ + +# Local Claude Code instructions +CLAUDE.md \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e7524c5..a9e8e6e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,30 @@ services: retries: 5 start_period: 30s + whisper: + # Multi-arch (arm64 + amd64) Whisper ASR service — runs natively on Apple + # Silicon. Uses the faster-whisper (CTranslate2) engine and bundles ffmpeg, + # so it accepts any audio the browser produces. Model is pulled from + # Hugging Face on first request into the whisper_models volume. + image: onerahmet/openai-whisper-asr-webservice:latest + container_name: fireform-whisper + environment: + - ASR_ENGINE=faster_whisper + - ASR_MODEL=small.en + - ASR_MODEL_PATH=/data/whisper + volumes: + - whisper_models:/data/whisper + ports: + - "127.0.0.1:9000:9000" + networks: + - fireform-network + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:9000/docs')\" || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + app: build: context: . @@ -23,9 +47,14 @@ services: depends_on: ollama: condition: service_healthy + whisper: + condition: service_started command: /bin/sh -c "python3 -m api.db.init_db && python3 -m uvicorn api.main:app --host 0.0.0.0 --port 8000" volumes: - .:/app + # Persist the SQLite DB (~/.fireform) across container rebuilds so created + # templates aren't wiped each time the image is recreated. + - fireform_db:/root/.fireform ports: - "8000:8000" environment: @@ -35,6 +64,8 @@ services: - PYTHONPATH=/app - OLLAMA_HOST=http://ollama:11434 - OLLAMA_TIMEOUT=300 + - OLLAMA_MODEL=qwen2.5:1.5b + - WHISPER_HOST=http://whisper:9000 networks: - fireform-network @@ -56,6 +87,10 @@ services: volumes: ollama_data: driver: local + whisper_models: + driver: local + fireform_db: + driver: local networks: fireform-network: diff --git a/tests/test_api.py b/tests/test_api.py index 8a184cd..9e957a3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -207,6 +207,91 @@ def test_fill_form_validates_body(self, client): resp = client.post("/forms/fill", json={}) assert resp.status_code == 422 + def test_transcribe_success(self, client, monkeypatch): + """Audio is forwarded to the whisper sidecar and its text returned.""" + import io + from unittest.mock import MagicMock + + fake_response = MagicMock() + fake_response.json.return_value = {"text": "structure fire on main street"} + fake_response.raise_for_status.return_value = None + + captured = {} + + def fake_post(url, params=None, files=None, timeout=None): + captured["url"] = url + captured["params"] = params + captured["files"] = files + return fake_response + + monkeypatch.setattr("api.routes.forms.requests.post", fake_post) + + audio = ("audio", ("recording.wav", io.BytesIO(b"RIFFfake"), "audio/wav")) + resp = client.post("/forms/transcribe", files=[audio]) + + assert resp.status_code == 200 + assert resp.json()["text"] == "structure fire on main street" + assert captured["url"].endswith("/asr") + assert "audio_file" in captured["files"] + assert captured["params"]["output"] == "json" + + def test_list_models(self, client, monkeypatch): + """/forms/models lists Ollama models and always includes the default.""" + from unittest.mock import MagicMock + + fake_response = MagicMock() + fake_response.json.return_value = {"models": [{"name": "qwen2.5:3b"}, {"name": "mistral:latest"}]} + fake_response.raise_for_status.return_value = None + monkeypatch.setattr("api.routes.forms.requests.get", lambda *a, **k: fake_response) + monkeypatch.setenv("OLLAMA_MODEL", "qwen2.5:1.5b") + + resp = client.get("/forms/models") + assert resp.status_code == 200 + body = resp.json() + assert body["default"] == "qwen2.5:1.5b" + assert "qwen2.5:1.5b" in body["models"] # default injected even if not pulled + assert "qwen2.5:3b" in body["models"] + + def test_list_models_ollama_down(self, client, monkeypatch): + """If Ollama is unreachable, still return the default alone.""" + import requests + + def boom(*a, **k): + raise requests.exceptions.ConnectionError("down") + + monkeypatch.setattr("api.routes.forms.requests.get", boom) + monkeypatch.setenv("OLLAMA_MODEL", "qwen2.5:1.5b") + + resp = client.get("/forms/models") + assert resp.status_code == 200 + assert resp.json()["models"] == ["qwen2.5:1.5b"] + + def test_fill_form_passes_model_override(self, client, mock_controller): + """A `model` in the request reaches Controller.fill_form but isn't persisted.""" + tpl_id = self._seed_template(client, mock_controller) + resp = client.post("/forms/fill", json={ + "template_id": tpl_id, + "input_text": "John Doe", + "model": "qwen2.5:3b", + }) + assert resp.status_code == 200 + _, kwargs = mock_controller["form_ctrl"].fill_form.call_args + assert kwargs["model"] == "qwen2.5:3b" + + def test_transcribe_service_unavailable(self, client, monkeypatch): + """A down whisper service surfaces as a 503, not a 500.""" + import io + import requests + + def fake_post(*args, **kwargs): + raise requests.exceptions.ConnectionError("no service") + + monkeypatch.setattr("api.routes.forms.requests.post", fake_post) + + audio = ("audio", ("recording.wav", io.BytesIO(b"data"), "audio/wav")) + resp = client.post("/forms/transcribe", files=[audio]) + assert resp.status_code == 503 + # ═══════════════════════════════════════════════════════════════════════════ # End-to-end pipeline From 5e8579375f73aeb12070b5d49f48b0cf4719a615 Mon Sep 17 00:00:00 2001 From: marcvergees Date: Mon, 25 May 2026 11:21:05 -0700 Subject: [PATCH 5/7] fix: :bug: File Not Found Error when submitting a form whose pdf is not found --- src/file_manipulator.py | 3 +-- tests/test_api.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/file_manipulator.py b/src/file_manipulator.py index ed8e1ff..8d1f3a0 100644 --- a/src/file_manipulator.py +++ b/src/file_manipulator.py @@ -43,8 +43,7 @@ def fill_form(self, user_input: str, fields: list, pdf_form_path: str, model: st print(f"[2] PDF template path: {pdf_form_path}") if not os.path.exists(pdf_form_path): - print(f"Error: PDF template not found at {pdf_form_path}") - return None # Or raise an exception + raise FileNotFoundError(f"PDF template not found at {pdf_form_path}") print("[3] Starting extraction and PDF filling process...") try: diff --git a/tests/test_api.py b/tests/test_api.py index 9e957a3..89a9b25 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -202,6 +202,17 @@ def test_fill_form_missing_template(self, client, mock_controller): }) assert resp.status_code == 404 + def test_fill_form_template_file_not_found(self, client, mock_controller): + tpl_id = self._seed_template(client, mock_controller) + mock_controller["form_ctrl"].fill_form.side_effect = FileNotFoundError("PDF template not found") + + resp = client.post("/forms/fill", json={ + "template_id": tpl_id, + "input_text": "some text", + }) + assert resp.status_code == 500 + assert "PDF template not found" in resp.json()["error"] + def test_fill_form_validates_body(self, client): """Missing required fields → 422.""" resp = client.post("/forms/fill", json={}) From 474879d6f1e17e37e8902a35e77300295e5dec94 Mon Sep 17 00:00:00 2001 From: marcvergees Date: Mon, 25 May 2026 11:22:20 -0700 Subject: [PATCH 6/7] fix: :see_no_evil: .gitignore update --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5ed224e..b5a9a90 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ venv node_modules/ frontend/dist/ frontend/out/ +src/inputs/ # macOS .DS_Store From a35722ceb6d6b1c775ac81f6702e02b7be769d69 Mon Sep 17 00:00:00 2001 From: marcvergees Date: Mon, 25 May 2026 11:24:09 -0700 Subject: [PATCH 7/7] refactor: :recycle: Removing outdated Optional[] to new version convetions --- api/schemas/forms.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/schemas/forms.py b/api/schemas/forms.py index f925779..ca99651 100644 --- a/api/schemas/forms.py +++ b/api/schemas/forms.py @@ -1,5 +1,3 @@ -from typing import Optional - from pydantic import BaseModel, field_validator class FormFill(BaseModel): @@ -7,7 +5,7 @@ class FormFill(BaseModel): input_text: str # Optional Ollama model override for this fill; falls back to OLLAMA_MODEL. # Not persisted (no DB column) — excluded before building FormSubmission. - model: Optional[str] = None + model: str | None = None @field_validator("input_text") def validate_input_text(cls, value):