- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained) - Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked) - Commit services/docuseal + services/legacy-db docker-compose configs - Extract client app composables: useOTP, useAddressSearch, catalog data, format utils - Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines - Clean hardcoded credentials from config.js fallback values - Add client portal: catalog, cart, checkout, OTP verification, address search - Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal - Add ops composables: useBestTech, useConversations, usePermissions, useScanner - Add field app: scanner composable, docker/nginx configs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
233 lines
8.1 KiB
JavaScript
233 lines
8.1 KiB
JavaScript
'use strict'
|
|
const cfg = require('./config')
|
|
const { log, json, parseBody } = require('./helpers')
|
|
|
|
/**
|
|
* POST /vision/barcodes
|
|
* Accepts { image: "base64..." } and uses Gemini Flash Vision to extract barcode values.
|
|
* Returns { barcodes: ["VALUE1", "VALUE2", ...] }
|
|
*/
|
|
async function handleBarcodes (req, res) {
|
|
if (!cfg.AI_API_KEY) return json(res, 500, { error: 'AI_API_KEY not configured' })
|
|
|
|
const body = await parseBody(req)
|
|
if (!body.image) return json(res, 400, { error: 'Missing image field (base64)' })
|
|
|
|
// Strip data URI prefix if present
|
|
const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '')
|
|
const sizeKB = Math.round(base64.length * 3 / 4 / 1024)
|
|
log(`Vision: received image ${sizeKB}KB`)
|
|
|
|
try {
|
|
const result = await extractBarcodes(base64)
|
|
return json(res, 200, result)
|
|
} catch (e) {
|
|
log('Vision barcode error:', e.message)
|
|
return json(res, 500, { error: 'Vision extraction failed: ' + e.message })
|
|
}
|
|
}
|
|
|
|
const VISION_PROMPT = `You are reading equipment labels from a photo taken by a field technician. The image may be blurry, tilted, at an angle, or have poor lighting.
|
|
|
|
Your job: find and read ALL identifiers on this device label. This includes:
|
|
|
|
1. TEXT PRINTED BELOW OR NEAR A BARCODE — this is the barcode value (ignore the barcode lines themselves)
|
|
2. Serial numbers — after "S/N", "SN", "Serial", or standalone long alphanumeric strings (8+ chars)
|
|
3. MAC addresses — after "MAC", "MAC ID", "MAC Address" — 12 hex chars, with or without colons/dashes
|
|
4. Model numbers — after "M/N", "Model", "P/N"
|
|
5. Any other identifier: IMEI, GPON SN, PON SN, hardware version
|
|
|
|
Examples of values to extract:
|
|
- 1608K44D9E79FAFF5 (printed under a barcode)
|
|
- TPLG-A1B2C3D4 (serial number)
|
|
- 04:18:D6:A1:B2:C3 (MAC address)
|
|
- ERLite-3 (model number)
|
|
- HWTC87654321 (Huawei serial)
|
|
|
|
Even if blurry, try your BEST to read each character. Return all identifiers found, maximum 3 most important ones (serial and MAC first, model last).`
|
|
|
|
/**
|
|
* Call Gemini Flash Vision to extract barcode/serial number values from an image.
|
|
* Uses the native Gemini REST API with JSON response mode.
|
|
*/
|
|
async function extractBarcodes (base64Image) {
|
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${cfg.AI_MODEL}:generateContent?key=${cfg.AI_API_KEY}`
|
|
|
|
const payload = {
|
|
contents: [{
|
|
parts: [
|
|
{ text: VISION_PROMPT },
|
|
{
|
|
inline_data: {
|
|
mime_type: 'image/jpeg',
|
|
data: base64Image
|
|
}
|
|
}
|
|
]
|
|
}],
|
|
generationConfig: {
|
|
temperature: 0.1,
|
|
maxOutputTokens: 1024,
|
|
responseMimeType: 'application/json',
|
|
responseSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
barcodes: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
maxItems: 3,
|
|
}
|
|
},
|
|
required: ['barcodes'],
|
|
},
|
|
}
|
|
}
|
|
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
|
|
if (!resp.ok) {
|
|
const text = await resp.text()
|
|
throw new Error(`Gemini API ${resp.status}: ${text.slice(0, 200)}`)
|
|
}
|
|
|
|
const data = await resp.json()
|
|
const candidate = data.candidates?.[0]
|
|
const text = (candidate?.content?.parts?.[0]?.text || '').trim()
|
|
const finishReason = candidate?.finishReason || 'unknown'
|
|
|
|
log(`Vision response (finish: ${finishReason}): ${text.slice(0, 300)}`)
|
|
|
|
// Parse response
|
|
let parsed
|
|
try {
|
|
parsed = JSON.parse(text)
|
|
} catch {
|
|
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
|
if (jsonMatch) {
|
|
try { parsed = JSON.parse(jsonMatch[0]) } catch {}
|
|
}
|
|
}
|
|
|
|
if (!parsed) {
|
|
log('Vision: could not parse response')
|
|
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 }
|
|
}
|
|
|
|
// ── POST /vision/equipment ──────────────────────────────────────────────────
|
|
// Full equipment label reading — returns structured fields
|
|
const EQUIP_PROMPT = `You are reading an equipment label from a photo taken by an ISP field technician.
|
|
The device is typically an ONT, ONU, router, modem, decoder, or similar telecom equipment.
|
|
|
|
Read ALL information visible on the label and return structured JSON.
|
|
|
|
Look for:
|
|
- Brand / Manufacturer: "Hisense", "TP-Link", "Huawei", "ZTE", "Nokia", "Sagemcom", etc.
|
|
- Model number: after "Model", "M/N", "P/N", or on a prominent line (e.g. "LTE3415-SHA+", "HG8245H")
|
|
- Serial number: after "S/N", "SN", "Serial", or printed under a barcode
|
|
- MAC address: after "MAC", "MAC ID", "MAC Address" — 12 hex characters (with or without : or - separators). Return WITHOUT separators, just 12 hex chars.
|
|
- GPON SN / PON SN: if present
|
|
- Hardware version: "HW Ver", "H/W"
|
|
- Any other barcode values visible
|
|
|
|
Even if the image is blurry or at an angle, try your BEST to read each character.
|
|
If a field is not visible, set it to null.`
|
|
|
|
async function handleEquipment (req, res) {
|
|
if (!cfg.AI_API_KEY) return json(res, 500, { error: 'AI_API_KEY not configured' })
|
|
|
|
const body = await parseBody(req)
|
|
if (!body.image) return json(res, 400, { error: 'Missing image field (base64)' })
|
|
|
|
const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '')
|
|
log(`Vision equipment: received image ${Math.round(base64.length * 3 / 4 / 1024)}KB`)
|
|
|
|
try {
|
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${cfg.AI_MODEL}:generateContent?key=${cfg.AI_API_KEY}`
|
|
|
|
const payload = {
|
|
contents: [{
|
|
parts: [
|
|
{ text: EQUIP_PROMPT },
|
|
{ inline_data: { mime_type: 'image/jpeg', data: base64 } },
|
|
]
|
|
}],
|
|
generationConfig: {
|
|
temperature: 0.1,
|
|
maxOutputTokens: 1024,
|
|
responseMimeType: 'application/json',
|
|
responseSchema: {
|
|
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'],
|
|
},
|
|
}
|
|
}
|
|
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
|
|
if (!resp.ok) {
|
|
const text = await resp.text()
|
|
throw new Error(`Gemini API ${resp.status}: ${text.slice(0, 200)}`)
|
|
}
|
|
|
|
const data = await resp.json()
|
|
const text = (data.candidates?.[0]?.content?.parts?.[0]?.text || '').trim()
|
|
log(`Vision equipment response: ${text.slice(0, 400)}`)
|
|
|
|
let parsed
|
|
try { parsed = JSON.parse(text) } catch {
|
|
const m = text.match(/\{[\s\S]*\}/)
|
|
if (m) try { parsed = JSON.parse(m[0]) } catch {}
|
|
}
|
|
|
|
if (!parsed) return json(res, 200, { serial_number: null, barcodes: [] })
|
|
|
|
// Clean MAC: remove separators, uppercase
|
|
if (parsed.mac_address) {
|
|
parsed.mac_address = parsed.mac_address.replace(/[:\-.\s]/g, '').toUpperCase()
|
|
}
|
|
// Clean serial
|
|
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 json(res, 200, parsed)
|
|
} catch (e) {
|
|
log('Vision equipment error:', e.message)
|
|
return json(res, 500, { error: 'Vision extraction failed: ' + e.message })
|
|
}
|
|
}
|
|
|
|
module.exports = { handleBarcodes, extractBarcodes, handleEquipment }
|