6de2455917
- Added GLOBAL_MARKETS_TITLE to all translation files - Updated footer with 12 markets (4 active + 8 upcoming) - Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi - Built and deployed to production - CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
365 lines
11 KiB
JavaScript
365 lines
11 KiB
JavaScript
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;
|