const Identify = require('../models/identify'); const Bounty = require('../models/bounty'); const BountyEngine = require('../services/bountyEngine'); const { body, validationResult } = require('express-validator'); const logger = require('../utils/logger'); /** * Identify Controller — Vision & identify (Skvadron F) * * Tills modellen når tröskel: svara med klasskandidat + ärlig konfidens */ class IdentifyController { /** * POST /v0/identify — identifiera objekt från bild + OCR */ static async identify(req, res) { try { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Valideringsfel', details: errors.array() }); } const { bild, position, ocr_extraherat, kund_ref } = req.body; const startTime = Date.now(); // Simulerad bildhash const bildHash = bild ? require('crypto').createHash('sha256').update(bild).digest('hex').substring(0, 32) : null; // Kör identify (simulerad tills ML-modell är på plats) const result = Identify.simulateIdentify({ ocr_extraherat: ocr_extraherat || [], position: position || {} }); // Spara försök const attempt = Identify.saveAttempt({ request_id: require('crypto').randomUUID(), bild_hash: bildHash, position_lat: position?.lat, position_lon: position?.lon, land: position?.land, ocr_extraherat: ocr_extraherat || [], kandidater: result.kandidater, vald_kandidat: result.kandidater[0]?.lvx_id, konfidens: result.kandidater[0]?.konfidens, verifieringsniva: result.kandidater[0]?.verifieringsniva, bounty_skapad: !!result.bounty, processing_time_ms: Date.now() - startTime }); // Om konfidens är låg eller modell saknas → skapa bounty let bounty = null; const topCandidate = result.kandidater[0]; if (!topCandidate || topCandidate.konfidens < 0.85 || topCandidate.niva === 'objektklass') { // Skapa bounty för fältverifiering bounty = BountyEngine.createBountyFromIdentify(result, position || {}, kund_ref); // Uppdatera försök med bounty-referens // (i en full implementation skulle detta göras i en transaktion) } // Säkerställ att vi alltid har en bounty om modell saknas if (topCandidate && topCandidate.niva === 'objektklass' && !bounty) { bounty = { skapad: true, bounty_id: `BNTY-A${String(Date.now()).slice(-4)}`, typ: 'modellbestamning', grund: 'Klass identifierad men modell obestämd', kundnotis: 'Du notifieras när objektet är modellbestämt och fältverifierat' }; } logger.info('Identify-anrop', { requestId: attempt.request_id, kandidater: result.kandidater.length, konfidens: topCandidate?.konfidens, bountySkapad: !!bounty }); res.json({ request_id: attempt.request_id, kandidater: result.kandidater.map(k => ({ niva: k.niva, lvx_id: k.lvx_id, namn: k.namn, konfidens: k.konfidens, verifieringsniva: k.verifieringsniva, status: k.status, klass: k.klass, sarskiljning_mot: k.sarskiljning_mot, insikt: k.insikt, individdata_ur_ocr: k.individdata_ur_ocr })), bounty: bounty ? { skapad: true, bounty_id: bounty.bounty_id, typ: bounty.typ, grund: bounty.grund, kundnotis: bounty.kundnotis } : null, konfidens_rapporterad: true, processing_time_ms: result.processing_time_ms }); } catch (error) { logger.error('Fel vid identify', { error: error.message }); res.status(500).json({ error: 'Identify kunde inte slutföras', message: error.message }); } } /** * GET /v0/objects/:lvx_id — hämta komplett post */ static async getObject(req, res) { try { const { lvx_id } = req.params; const object = Identify.findObjectByLvxId(lvx_id); if (!object) { return res.status(404).json({ error: 'Objekt hittades inte' }); } // Hämta relationer const relations = Identify.getRelations(lvx_id); // Hämta succession om det är en modell let succession = null; if (object.type === 'modell') { const successionEdges = Identify.getSuccession(lvx_id); succession = { ersatter: successionEdges.filter(e => e.predikat === 'ersatter').map(e => ({ lvx_id: e.objekt, beskrivning: e.beskrivning })), ersatts_av: successionEdges.filter(e => e.predikat === 'ersatts_av').map(e => ({ lvx_id: e.objekt, beskrivning: e.beskrivning })) }; } res.json({ lvx_id: object.lvx_id, type: object.type, namn: { sv: object.namn_sv, en: object.namn_en }, doman: object.doman, verifieringsniva: object.verifieringsniva, konfidens: object.konfidens, relations: relations.map(r => ({ predikat: r.predikat, subjekt: r.subjekt, objekt: r.objekt, beskrivning: r.beskrivning })), succession }); } catch (error) { logger.error('Fel vid hämtning av objekt', { error: error.message }); res.status(500).json({ error: 'Kunde inte hämta objekt' }); } } /** * GET /v0/objects/:lvx_id/succession — ersättningskedja */ static async getSuccession(req, res) { try { const { lvx_id } = req.params; const edges = Identify.getSuccession(lvx_id); const ersatter = edges.filter(e => e.predikat === 'ersatter').map(e => ({ lvx_id: e.objekt, beskrivning: e.beskrivning })); const ersatts_av = edges.filter(e => e.predikat === 'ersatts_av').map(e => ({ lvx_id: e.objekt, beskrivning: e.beskrivning })); res.json({ lvx_id, ersatter, ersatts_av, kedja: this.buildSuccessionChain(lvx_id, edges) }); } catch (error) { logger.error('Fel vid hämtning av succession', { error: error.message }); res.status(500).json({ error: 'Kunde inte hämta succession' }); } } /** * GET /v0/search — fritextsökning */ static async search(req, res) { try { const { q, domain, limit } = req.query; if (!q) { return res.status(400).json({ error: 'Sökterm krävs (q)' }); } const results = Identify.searchObjects(q, domain, parseInt(limit) || 10); res.json({ query: q, domain: domain || null, count: results.length, results: results.map(r => ({ type: r.type, lvx_id: r.lvx_id, namn: { sv: r.namn_sv, en: r.namn_en }, doman: r.doman, verifieringsniva: r.verifieringsniva, konfidens: r.konfidens })) }); } catch (error) { logger.error('Fel vid sökning', { error: error.message }); res.status(500).json({ error: 'Kunde inte söka' }); } } /** * POST /v0/feedback — fel eller okänt objekt */ static async feedback(req, res) { try { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Valideringsfel', details: errors.array() }); } const { kund_ref, position, bild_hash, ocr_extraherat, kommentar } = req.body; // Spara i feedback-kön const feedback = Bounty.createFeedbackQueue({ kund_ref, position_lat: position?.lat, position_lon: position?.lon, bild_hash, ocr_extraherat, kommentar }); // Skapa bounty automatiskt const bounty = BountyEngine.createBountyFromFeedback({ kund_ref, position_lat: position?.lat, position_lon: position?.lon, ocr_extraherat, kommentar }); logger.info('Feedback mottagen och bounty skapad', { feedbackId: feedback.id, bountyId: bounty.bounty_id }); res.status(201).json({ success: true, feedback_id: feedback.id, bounty: { bounty_id: bounty.bounty_id, titel: bounty.titel, status: bounty.status, poang: bounty.poang }, message: 'Tack för din feedback! En bounty har skapats för att verifiera objektet i fält.' }); } catch (error) { logger.error('Fel vid feedback', { error: error.message }); res.status(500).json({ error: 'Kunde inte behandla feedback', message: error.message }); } } /** * GET /v0/identify/golden-cases — regressionssvit */ static async goldenCases(req, res) { try { const results = Identify.runGoldenCases(); const passed = results.filter(r => r.passed).length; const total = results.length; res.json({ summary: { total, passed, failed: total - passed, pass_rate: total > 0 ? Math.round(passed / total * 1000) / 10 : 0 }, results }); } catch (error) { logger.error('Fel vid golden cases', { error: error.message }); res.status(500).json({ error: 'Kunde inte köra regressionssvit' }); } } /** * GET /v0/identify/calibration — konfidenskalibrering */ static async calibration(req, res) { try { const report = Identify.getCalibrationReport(); res.json({ modell_version: 'v0-simulerad', rapport: report, notering: 'Kalibrering baserad på simulerade förutsägelser. Uppdateras när ML-modell deployas.' }); } catch (error) { logger.error('Fel vid kalibrering', { error: error.message }); res.status(500).json({ error: 'Kunde inte hämta kalibrering' }); } } /** * Hjälp: bygg ersättningskedja */ static buildSuccessionChain(lvxId, edges) { const chain = [lvxId]; // Gå bakåt (ersätter) let current = lvxId; for (let i = 0; i < 10; i++) { const edge = edges.find(e => e.predikat === 'ersatter' && e.subjekt === current); if (!edge) break; chain.unshift(edge.objekt); current = edge.objekt; } // Gå framåt (ersätts_av) current = lvxId; for (let i = 0; i < 10; i++) { const edge = edges.find(e => e.predikat === 'ersatts_av' && e.subjekt === current); if (!edge) break; chain.push(edge.objekt); current = edge.objekt; } return chain; } // Valideringsregler static identifyValidation = [ body('position.lat').optional().isFloat().withMessage('Latitud måste vara ett tal'), body('position.lon').optional().isFloat().withMessage('Longitud måste vara ett tal') ]; static feedbackValidation = [ body('kund_ref').notEmpty().withMessage('Kundreferens krävs'), body('kommentar').notEmpty().withMessage('Kommentar krävs') ]; } module.exports = IdentifyController;