diff --git a/apps/field/infra/nginx.conf b/apps/field/infra/nginx.conf index 3f4b9ba..404db16 100644 --- a/apps/field/infra/nginx.conf +++ b/apps/field/infra/nginx.conf @@ -16,6 +16,15 @@ server { proxy_set_header X-Forwarded-Proto https; } + # Ollama Vision API proxy — for bill/invoice OCR + location /ollama/ { + proxy_pass http://ollama:11434/; + proxy_set_header Host $host; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + client_max_body_size 20m; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; diff --git a/apps/field/src/api/ocr.js b/apps/field/src/api/ocr.js new file mode 100644 index 0000000..896ba96 --- /dev/null +++ b/apps/field/src/api/ocr.js @@ -0,0 +1,83 @@ +import { authFetch } from './auth' + +const OLLAMA_URL = '/ollama/api/generate' + +const OCR_PROMPT = `You are an invoice/bill OCR assistant. Extract the following fields from this image of a bill or invoice. Return ONLY valid JSON, no markdown, no explanation. + +{ + "vendor": "company name on the bill", + "vendor_address": "full address if visible", + "invoice_number": "invoice/bill number", + "date": "YYYY-MM-DD format", + "due_date": "YYYY-MM-DD if visible, null otherwise", + "subtotal": 0.00, + "tax_gst": 0.00, + "tax_qst": 0.00, + "total": 0.00, + "currency": "CAD", + "items": [ + { "description": "line item description", "qty": 1, "rate": 0.00, "amount": 0.00 } + ], + "notes": "any other relevant text (account number, payment terms, etc.)" +} + +If a field is not visible, set it to null. Always return valid JSON.` + +/** + * Send an image to Ollama Vision for bill/invoice OCR. + * @param {string} base64Image — base64 encoded image (no data: prefix) + * @returns {object} Parsed invoice data + */ +export async function ocrBill (base64Image) { + // Strip data:image/...;base64, prefix if present + const clean = base64Image.replace(/^data:image\/[^;]+;base64,/, '') + + const res = await authFetch(OLLAMA_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'llama3.2-vision:11b', + prompt: OCR_PROMPT, + images: [clean], + stream: false, + options: { + temperature: 0.1, + num_predict: 2048, + }, + }), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error('OCR failed: ' + (text || res.status)) + } + + const data = await res.json() + const raw = data.response || '' + + // Extract JSON from response (model might wrap it in markdown) + const jsonMatch = raw.match(/\{[\s\S]*\}/) + if (!jsonMatch) throw new Error('No JSON in OCR response') + + try { + return JSON.parse(jsonMatch[0]) + } catch (e) { + throw new Error('Invalid JSON from OCR: ' + e.message) + } +} + +/** + * Check if Ollama is running and the vision model is available. + */ +export async function checkOllamaStatus () { + try { + const res = await authFetch('/ollama/api/tags') + if (!res.ok) return { online: false, error: 'HTTP ' + res.status } + const data = await res.json() + const models = (data.models || []).map(m => m.name) + const hasVision = models.some(m => m.includes('llama3.2-vision')) + return { online: true, models, hasVision } + } catch (e) { + return { online: false, error: e.message } + } +} diff --git a/apps/ops/infra/nginx.conf b/apps/ops/infra/nginx.conf index 7addf57..129958a 100644 --- a/apps/ops/infra/nginx.conf +++ b/apps/ops/infra/nginx.conf @@ -17,6 +17,15 @@ server { proxy_set_header X-Forwarded-Proto https; } + # Ollama Vision API proxy — for bill/invoice OCR + location /ollama/ { + proxy_pass http://ollama:11434/; + proxy_set_header Host $host; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + client_max_body_size 20m; + } + # SPA fallback — all routes serve index.html location / { try_files $uri $uri/ /index.html; diff --git a/apps/ops/src/api/erp.js b/apps/ops/src/api/erp.js index d3c5fcc..42e34a4 100644 --- a/apps/ops/src/api/erp.js +++ b/apps/ops/src/api/erp.js @@ -39,6 +39,18 @@ export async function searchDocs (doctype, text, { filters = {}, fields = ['name return data.message || [] } +// Create a new document +export async function createDoc (doctype, data) { + const res = await authFetch(BASE_URL + '/api/resource/' + doctype, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) throw new Error('Create failed: ' + res.status) + const json = await res.json() + return json.data +} + // Update a document (partial update) export async function updateDoc (doctype, name, data) { const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), { diff --git a/apps/ops/src/api/ocr.js b/apps/ops/src/api/ocr.js new file mode 100644 index 0000000..896ba96 --- /dev/null +++ b/apps/ops/src/api/ocr.js @@ -0,0 +1,83 @@ +import { authFetch } from './auth' + +const OLLAMA_URL = '/ollama/api/generate' + +const OCR_PROMPT = `You are an invoice/bill OCR assistant. Extract the following fields from this image of a bill or invoice. Return ONLY valid JSON, no markdown, no explanation. + +{ + "vendor": "company name on the bill", + "vendor_address": "full address if visible", + "invoice_number": "invoice/bill number", + "date": "YYYY-MM-DD format", + "due_date": "YYYY-MM-DD if visible, null otherwise", + "subtotal": 0.00, + "tax_gst": 0.00, + "tax_qst": 0.00, + "total": 0.00, + "currency": "CAD", + "items": [ + { "description": "line item description", "qty": 1, "rate": 0.00, "amount": 0.00 } + ], + "notes": "any other relevant text (account number, payment terms, etc.)" +} + +If a field is not visible, set it to null. Always return valid JSON.` + +/** + * Send an image to Ollama Vision for bill/invoice OCR. + * @param {string} base64Image — base64 encoded image (no data: prefix) + * @returns {object} Parsed invoice data + */ +export async function ocrBill (base64Image) { + // Strip data:image/...;base64, prefix if present + const clean = base64Image.replace(/^data:image\/[^;]+;base64,/, '') + + const res = await authFetch(OLLAMA_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'llama3.2-vision:11b', + prompt: OCR_PROMPT, + images: [clean], + stream: false, + options: { + temperature: 0.1, + num_predict: 2048, + }, + }), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error('OCR failed: ' + (text || res.status)) + } + + const data = await res.json() + const raw = data.response || '' + + // Extract JSON from response (model might wrap it in markdown) + const jsonMatch = raw.match(/\{[\s\S]*\}/) + if (!jsonMatch) throw new Error('No JSON in OCR response') + + try { + return JSON.parse(jsonMatch[0]) + } catch (e) { + throw new Error('Invalid JSON from OCR: ' + e.message) + } +} + +/** + * Check if Ollama is running and the vision model is available. + */ +export async function checkOllamaStatus () { + try { + const res = await authFetch('/ollama/api/tags') + if (!res.ok) return { online: false, error: 'HTTP ' + res.status } + const data = await res.json() + const models = (data.models || []).map(m => m.name) + const hasVision = models.some(m => m.includes('llama3.2-vision')) + return { online: true, models, hasVision } + } catch (e) { + return { online: false, error: e.message } + } +} diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue index 3aa0d0a..1a043fb 100644 --- a/apps/ops/src/layouts/MainLayout.vue +++ b/apps/ops/src/layouts/MainLayout.vue @@ -263,6 +263,7 @@ const navItems = [ { path: '/tickets', icon: 'confirmation_number', label: 'Tickets' }, { path: '/equipe', icon: 'groups', label: 'Équipe' }, { path: '/rapports', icon: 'bar_chart', label: 'Rapports' }, + { path: '/ocr', icon: 'document_scanner', label: 'OCR Factures' }, ] const currentNav = computed(() => navItems.find(n => n.path === route.path) || navItems.find(n => route.path.startsWith(n.path) && n.path !== '/')) diff --git a/apps/ops/src/pages/OcrPage.vue b/apps/ops/src/pages/OcrPage.vue new file mode 100644 index 0000000..2aed480 --- /dev/null +++ b/apps/ops/src/pages/OcrPage.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/apps/ops/src/router/index.js b/apps/ops/src/router/index.js index c8f7679..c1fa86d 100644 --- a/apps/ops/src/router/index.js +++ b/apps/ops/src/router/index.js @@ -12,6 +12,7 @@ const routes = [ { path: 'tickets', component: () => import('src/pages/TicketsPage.vue') }, { path: 'equipe', component: () => import('src/pages/EquipePage.vue') }, { path: 'rapports', component: () => import('src/pages/RapportsPage.vue') }, + { path: 'ocr', component: () => import('src/pages/OcrPage.vue') }, ], }, // Dispatch V2 — full-screen immersive UI with its own header/sidebar