Files
boc/projects/landvex/api/src/controllers/identifyController.js
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- 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
2026-07-08 19:56:03 +00:00

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;