gigafibre-fsm/services/targo-hub/lib/vision.js
louispaulb 0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:12:12 -04:00

291 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
const cfg = require('./config')
const { log, json, parseBody, stripDataUrl } = require('./helpers')
const GEMINI_URL = () => `https://generativelanguage.googleapis.com/v1beta/models/${cfg.AI_MODEL}:generateContent?key=${cfg.AI_API_KEY}`
async function geminiVision (base64Image, prompt, schema, mime = 'image/jpeg') {
const resp = await fetch(GEMINI_URL(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: mime, data: base64Image } }] }],
generationConfig: { temperature: 0.1, maxOutputTokens: 1024, responseMimeType: 'application/json', responseSchema: schema },
}),
})
if (!resp.ok) { const t = await resp.text(); throw new Error(`Gemini API ${resp.status}: ${t.slice(0, 200)}`) }
const data = await resp.json()
const text = (data.candidates?.[0]?.content?.parts?.[0]?.text || '').trim()
log(`Vision response: ${text.slice(0, 300)}`)
let parsed
try { parsed = JSON.parse(text) } catch { const m = text.match(/\{[\s\S]*\}/); if (m) try { parsed = JSON.parse(m[0]) } catch {} }
return parsed
}
function extractBase64 (req, body, label) {
if (!cfg.AI_API_KEY) return { error: 'AI_API_KEY not configured', status: 500 }
if (!body.image) return { error: 'Missing image field (base64)', status: 400 }
const base64 = stripDataUrl(body.image)
log(`Vision ${label}: received image ${Math.round(base64.length * 3 / 4 / 1024)}KB`)
return { base64 }
}
const BARCODE_PROMPT = `Read ALL identifiers on this equipment label photo (may be blurry/tilted).
Extract: barcode text, serial numbers (S/N, SN), MAC addresses (12 hex chars), model numbers (M/N, Model, P/N), IMEI, GPON SN.
Examples: 1608K44D9E79FAFF5, TPLG-A1B2C3D4, 04:18:D6:A1:B2:C3, HWTC87654321.
Try your BEST on every character. Return max 3 most important (serial/MAC first).`
const BARCODE_SCHEMA = {
type: 'object',
properties: { barcodes: { type: 'array', items: { type: 'string' }, maxItems: 3 } },
required: ['barcodes'],
}
async function handleBarcodes (req, res) {
const body = await parseBody(req)
const check = extractBase64(req, body, 'barcode')
if (check.error) return json(res, check.status, { error: check.error })
try {
const result = await extractBarcodes(check.base64)
return json(res, 200, result)
} catch (e) {
log('Vision barcode error:', e.message)
return json(res, 500, { error: 'Vision extraction failed: ' + e.message })
}
}
async function extractBarcodes (base64Image) {
const parsed = await geminiVision(base64Image, BARCODE_PROMPT, BARCODE_SCHEMA)
if (!parsed) return { barcodes: [] }
const arr = Array.isArray(parsed) ? parsed : Array.isArray(parsed.barcodes) ? parsed.barcodes : []
const barcodes = arr.filter(v => typeof v === 'string' && v.trim().length > 3).map(v => v.trim().replace(/\s+/g, '')).slice(0, 3)
log(`Vision: extracted ${barcodes.length} barcode(s): ${barcodes.join(', ')}`)
return { barcodes }
}
const EQUIP_PROMPT = `Read this ISP equipment label (ONT/ONU/router/modem). Return structured JSON.
Extract: brand/manufacturer, model (M/N, P/N), serial (S/N, SN, under barcode), MAC address (12 hex, no separators), GPON SN, HW version, barcodes.
Try your BEST on blurry/angled text. Set missing fields to null.`
const EQUIP_SCHEMA = {
type: 'object',
properties: {
brand: { type: 'string', nullable: true }, model: { type: 'string', nullable: true },
serial_number: { type: 'string', nullable: true }, mac_address: { type: 'string', nullable: true },
gpon_sn: { type: 'string', nullable: true }, hw_version: { type: 'string', nullable: true },
equipment_type: { type: 'string', nullable: true },
barcodes: { type: 'array', items: { type: 'string' }, maxItems: 5 },
},
required: ['serial_number'],
}
// Extraction équipement réutilisable (étiquette modem/ONU → marque/modèle/série/MAC). base64 SANS préfixe data:.
async function extractEquipment (base64) {
const parsed = await geminiVision(base64, EQUIP_PROMPT, EQUIP_SCHEMA)
if (!parsed) return { serial_number: null, barcodes: [] }
if (parsed.mac_address) parsed.mac_address = parsed.mac_address.replace(/[:\-.\s]/g, '').toUpperCase()
if (parsed.serial_number) parsed.serial_number = parsed.serial_number.replace(/\s+/g, '').trim()
log(`Vision equipment: brand=${parsed.brand} model=${parsed.model} sn=${parsed.serial_number} mac=${parsed.mac_address}`)
return parsed
}
async function handleEquipment (req, res) {
const body = await parseBody(req)
const check = extractBase64(req, body, 'equipment')
if (check.error) return json(res, check.status, { error: check.error })
try { return json(res, 200, await extractEquipment(check.base64)) }
catch (e) { log('Vision equipment error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
}
// ─── Invoice / bill OCR ────────────────────────────────────────────────
// We run this on Gemini (not on Ollama) because the ops VM has no GPU —
// ops must not depend on a local vision model. The schema matches what
// the ops InvoiceScanPage expects so switching away from Ollama is a
// drop-in replacement on the frontend.
const INVOICE_PROMPT = `You are an invoice/bill OCR assistant. Extract structured data from this photo of a vendor invoice or bill.
Return ONLY valid JSON that matches the provided schema. No prose, no markdown.
Rules:
- "date" / "due_date" must be ISO YYYY-MM-DD. If the date is MM/DD/YYYY or DD/MM/YYYY and ambiguous, prefer YYYY-MM-DD with the most likely interpretation for Canadian/Québec invoices.
- "currency" is a 3-letter code (CAD, USD, EUR). Default to CAD if not visible.
- "tax_gst" = GST/TPS/HST (Canadian federal tax); "tax_qst" = QST/TVQ (Québec provincial tax).
- "items" is a line-by-line list; keep description as printed, collapse whitespace.
- Missing fields → null for strings, 0 for numbers, [] for items.`
const INVOICE_SCHEMA = {
type: 'object',
properties: {
vendor: { type: 'string', nullable: true },
vendor_address: { type: 'string', nullable: true },
invoice_number: { type: 'string', nullable: true },
date: { type: 'string', nullable: true },
due_date: { type: 'string', nullable: true },
subtotal: { type: 'number', nullable: true },
tax_gst: { type: 'number', nullable: true },
tax_qst: { type: 'number', nullable: true },
total: { type: 'number', nullable: true },
currency: { type: 'string', nullable: true },
items: {
type: 'array',
items: {
type: 'object',
properties: {
description: { type: 'string', nullable: true },
qty: { type: 'number', nullable: true },
rate: { type: 'number', nullable: true },
amount: { type: 'number', nullable: true },
},
},
},
notes: { type: 'string', nullable: true },
},
required: ['vendor', 'total'],
}
// Extraction facture réutilisable (image OU PDF). base64 SANS préfixe data:. mime = 'application/pdf' pour les PDF.
async function extractInvoice (base64, mime = 'image/jpeg') {
const parsed = await geminiVision(base64, INVOICE_PROMPT, INVOICE_SCHEMA, mime)
if (!parsed) return { vendor: null, total: null, items: [] }
for (const k of ['subtotal', 'tax_gst', 'tax_qst', 'total']) {
if (typeof parsed[k] === 'string') parsed[k] = Number(parsed[k].replace(/[^0-9.\-]/g, '')) || 0
}
if (Array.isArray(parsed.items)) {
for (const it of parsed.items) {
for (const k of ['qty', 'rate', 'amount']) {
if (typeof it[k] === 'string') it[k] = Number(it[k].replace(/[^0-9.\-]/g, '')) || 0
}
}
}
log(`Vision invoice: vendor=${parsed.vendor} total=${parsed.total} items=${(parsed.items || []).length}`)
return parsed
}
async function handleInvoice (req, res) {
const body = await parseBody(req)
const check = extractBase64(req, body, 'invoice')
if (check.error) return json(res, check.status, { error: check.error })
try { return json(res, 200, await extractInvoice(check.base64)) }
catch (e) { log('Vision invoice error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
}
// ─── Preuve de paiement (capture d'écran client) ───────────────────────
// Synergie Inbox : un client envoie une capture de son paiement (virement Interac, app bancaire, reçu).
// On EXTRAIT (montant/date/référence/méthode) pour AFFICHER et décider. AUCUNE écriture auto en facturation (F autoritaire).
const PAYMENT_PROMPT = `Tu analyses une CAPTURE D'ÉCRAN de preuve de paiement envoyée par un client d'un fournisseur Internet au Québec
(virement Interac, application bancaire, reçu de carte, confirmation de paiement). Renvoie UNIQUEMENT du JSON selon le schéma.
Règles : "is_payment"=true seulement si l'image montre vraiment un paiement/transfert/reçu. "amount" = nombre (sans symbole). "currency" = CAD par défaut.
"date" = ISO YYYY-MM-DD si visible sinon null. "method" = Interac|Carte|Virement|Comptant|Autre. "reference" = numéro de confirmation/référence si visible.
"payer_name"/"recipient" = noms si visibles. Ne devine pas : champ absent → null. "confidence" = 0.01.0.`
const PAYMENT_SCHEMA = {
type: 'object',
properties: {
is_payment: { type: 'boolean' },
amount: { type: 'number', nullable: true },
currency: { type: 'string', nullable: true },
date: { type: 'string', nullable: true },
method: { type: 'string', nullable: true },
reference: { type: 'string', nullable: true },
payer_name: { type: 'string', nullable: true },
recipient: { type: 'string', nullable: true },
confidence: { type: 'number' },
},
required: ['is_payment', 'confidence'],
}
async function extractPayment (base64) {
const parsed = await geminiVision(base64, PAYMENT_PROMPT, PAYMENT_SCHEMA)
if (!parsed) return { is_payment: false, confidence: 0 }
if (typeof parsed.amount === 'string') parsed.amount = Number(parsed.amount.replace(/[^0-9.\-]/g, '')) || null
parsed.confidence = Math.max(0, Math.min(1, Number(parsed.confidence) || 0))
log(`Vision payment: is_payment=${parsed.is_payment} amount=${parsed.amount} ${parsed.currency || ''} ref=${parsed.reference || '-'} conf=${parsed.confidence}`)
return parsed
}
async function handlePayment (req, res) {
const body = await parseBody(req)
const check = extractBase64(req, body, 'payment')
if (check.error) return json(res, check.status, { error: check.error })
try { return json(res, 200, { ok: true, ...(await extractPayment(check.base64)) }) }
catch (e) { log('Vision payment error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
}
// ─── Field-targeted extraction (for tech mobile form auto-fill) ─────────
// Instead of "read everything on the label", this pulls ONE specific value.
// Used when a tech has selected e.g. "Wi-Fi password" and wants Gemini to
// find only that field on the sticker. Returns {value, confidence}.
const FIELD_CONFIG = {
serial_number: {
desc: 'the device SERIAL NUMBER (labeled S/N, SN, Serial, N/S). Usually 8-20 alphanumeric chars, frequently printed under a Code128 barcode.',
clean: v => v.replace(/\s+/g, '').toUpperCase(),
},
mac_address: {
desc: 'the MAC ADDRESS (12 hexadecimal chars, may be separated by colons, dashes or nothing). Labeled MAC, WAN MAC, LAN MAC, Ethernet, Wi-Fi MAC.',
clean: v => v.replace(/[^0-9A-F]/gi, '').toUpperCase(),
},
gpon_sn: {
desc: 'the GPON SN — a 4-letter manufacturer code followed by 8 hex characters (e.g. HWTC12345678, ZTEG87654321, CIGG1A2B3C4D). Labeled GPON SN, GPON-SN, ONU SN.',
clean: v => v.replace(/\s+/g, '').toUpperCase(),
},
model: {
desc: 'the MODEL number/name (labeled M/N, Model, P/N, Product, Type). Usually short, e.g. "HG8245H", "TL-WR841N", "HS8145V".',
clean: v => v.trim(),
},
wifi_ssid: {
desc: 'the Wi-Fi NETWORK NAME (SSID). Labeled SSID, Wi-Fi name, WLAN SSID, Nom Wi-Fi, Nom du réseau.',
clean: v => v.trim(),
},
wifi_password: {
desc: 'the Wi-Fi PASSWORD / KEY. Labeled WPA, WPA2, WPA Key, Wi-Fi Password, Wireless Password, Clé Wi-Fi, Mot de passe Wi-Fi, Password, Passphrase. Usually 8-20 chars, mixed case with numbers and sometimes symbols.',
clean: v => v.trim(),
},
imei: {
desc: 'the IMEI (15 digits, exactly). Labeled IMEI.',
clean: v => v.replace(/\D/g, ''),
},
generic: {
desc: 'the requested value (see context hint below)',
clean: v => v.trim(),
},
}
const FIELD_SCHEMA = {
type: 'object',
properties: {
value: { type: 'string', nullable: true },
confidence: { type: 'number' },
},
required: ['value', 'confidence'],
}
async function extractField (base64Image, field, context = {}) {
const config = FIELD_CONFIG[field] || FIELD_CONFIG.generic
const eq = context.equipment_type ? `Equipment type hint: ${context.equipment_type}.` : ''
const brand = context.brand ? `Brand hint: ${context.brand}.` : ''
const model = context.model ? `Model hint: ${context.model}.` : ''
const custom = (field === 'generic' && context.hint) ? `Look for: ${context.hint}.` : ''
const prompt = `You are reading an ISP equipment label (ONT, router, modem). Extract ${config.desc}
${eq} ${brand} ${model} ${custom}
Return ONLY JSON matching the schema: {"value": "<the raw extracted text without its label prefix>", "confidence": <0.0-1.0>}.
If you cannot find it with confidence above 0.5, return {"value": null, "confidence": 0.0}.
Do NOT invent data. Prefer returning null over guessing.`
const parsed = await geminiVision(base64Image, prompt, FIELD_SCHEMA)
if (!parsed || !parsed.value) return { value: null, confidence: 0 }
const cleaned = config.clean(parsed.value)
if (!cleaned) return { value: null, confidence: 0 }
return { value: cleaned, confidence: Math.max(0, Math.min(1, Number(parsed.confidence) || 0.5)) }
}
async function handleFieldScan (req, res) {
const body = await parseBody(req)
const check = extractBase64(req, body, 'field-scan')
if (check.error) return json(res, check.status, { error: check.error })
try {
const out = await extractField(check.base64, body.field || 'generic', {
hint: body.hint, equipment_type: body.equipment_type, brand: body.brand, model: body.model,
})
return json(res, 200, { ok: true, ...out })
} catch (e) {
log('Vision field-scan error:', e.message)
return json(res, 500, { error: 'Vision field extraction failed: ' + e.message })
}
}
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, extractEquipment, handleInvoice, extractInvoice, extractField, handleFieldScan, extractPayment, handlePayment }