diff --git a/database/facial_recognition_tables.sql b/database/facial_recognition_tables.sql new file mode 100644 index 0000000..aa7142b --- /dev/null +++ b/database/facial_recognition_tables.sql @@ -0,0 +1,267 @@ +-- ==================================================================== +-- Facial Recognition System - Database Schema +-- ==================================================================== +-- This schema supports the facial recognition feature for CrimeLinkAnalyzer +-- Created: December 11, 2025 +-- ==================================================================== + +-- Enable required PostgreSQL extension for EXCLUDE constraints +-- The btree_gist extension is required for using EXCLUDE constraints with equality operators +-- This allows us to enforce "one primary photo per criminal" at the database level +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- Drop existing tables if they exist (for clean setup) +DROP TABLE IF EXISTS facial_recognition_logs CASCADE; +DROP TABLE IF EXISTS suspect_photos CASCADE; +DROP TABLE IF EXISTS criminals CASCADE; + +-- ==================================================================== +-- CRIMINALS TABLE +-- ==================================================================== +-- Stores criminal records with biometric data +CREATE TABLE criminals ( + criminal_id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + nic VARCHAR(20) UNIQUE, + alias VARCHAR(255), + date_of_birth DATE, + gender VARCHAR(10) CHECK (gender IN ('Male', 'Female', 'Other')), + address TEXT, + nationality VARCHAR(100) DEFAULT 'Sri Lankan', + + -- Crime information stored as JSONB for flexibility + crime_history JSONB DEFAULT '[]'::jsonb, + + -- Primary photo reference + primary_photo_url VARCHAR(500), + + -- Face embedding - stored as BYTEA (binary) + -- This is the average embedding from all photos + face_embedding BYTEA, + embedding_model VARCHAR(50) DEFAULT 'buffalo_sc', + embedding_dimension INTEGER DEFAULT 512, + + -- Status tracking + status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'archived')), + risk_level VARCHAR(20) DEFAULT 'medium' CHECK (risk_level IN ('low', 'medium', 'high', 'critical')), + + -- Audit fields + created_by INTEGER, -- User ID who created this record + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Metadata + notes TEXT, + last_seen_location VARCHAR(255), + last_seen_date DATE +); + +-- ==================================================================== +-- SUSPECT_PHOTOS TABLE +-- ==================================================================== +-- Stores multiple photos per criminal for better accuracy +CREATE TABLE suspect_photos ( + photo_id SERIAL PRIMARY KEY, + criminal_id INTEGER NOT NULL REFERENCES criminals(criminal_id) ON DELETE CASCADE, + + -- Photo storage + photo_url VARCHAR(500) NOT NULL, + photo_hash VARCHAR(64) UNIQUE, -- SHA-256 hash to prevent duplicates + file_size_bytes INTEGER, + + -- Face detection metadata + face_embedding BYTEA NOT NULL, -- Individual photo embedding + face_confidence DECIMAL(5,2), -- Detection confidence (0-100) + face_bbox JSONB, -- Bounding box coordinates {x, y, width, height} + + -- Photo metadata + is_primary BOOLEAN DEFAULT FALSE, + photo_quality VARCHAR(20) CHECK (photo_quality IN ('low', 'medium', 'high', 'excellent')), + image_width INTEGER, + image_height INTEGER, + + -- Source tracking + source VARCHAR(100), -- e.g., 'manual_upload', 'cctv', 'arrest_record' + source_date DATE, + + -- Audit + uploaded_by INTEGER, -- User ID + uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Constraints + CONSTRAINT only_one_primary_per_criminal + EXCLUDE USING gist (criminal_id WITH =) + WHERE (is_primary = true) +); + +-- ==================================================================== +-- FACIAL_RECOGNITION_LOGS TABLE +-- ==================================================================== +-- Audit trail for all facial recognition requests +CREATE TABLE facial_recognition_logs ( + log_id SERIAL PRIMARY KEY, + + -- Request details + analysis_type VARCHAR(50) DEFAULT 'suspect_match', -- 'suspect_match', 'criminal_registration' + uploaded_image_url VARCHAR(500), + uploaded_image_hash VARCHAR(64), + + -- Face detection results + face_detected BOOLEAN DEFAULT FALSE, + face_count INTEGER DEFAULT 0, + face_quality VARCHAR(20), + + -- Matching results + matches_found INTEGER DEFAULT 0, + best_match_criminal_id INTEGER REFERENCES criminals(criminal_id), + best_match_similarity DECIMAL(5,2), -- Percentage (0-100) + match_threshold DECIMAL(5,2) DEFAULT 75.00, + + -- All matches stored as JSONB for detailed analysis + all_matches JSONB DEFAULT '[]'::jsonb, + + -- Performance metrics + processing_time_ms INTEGER, + model_version VARCHAR(50), + + -- Security & Audit + requested_by INTEGER, -- User ID + user_role VARCHAR(50), + ip_address INET, + user_agent TEXT, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Investigation reference + case_id VARCHAR(100), + investigation_notes TEXT +); + +-- ==================================================================== +-- INDEXES FOR PERFORMANCE +-- ==================================================================== + +-- Criminal table indexes +CREATE INDEX idx_criminal_nic ON criminals(nic); +CREATE INDEX idx_criminal_name ON criminals USING gin(to_tsvector('english', name)); +CREATE INDEX idx_criminal_status ON criminals(status) WHERE status = 'active'; +CREATE INDEX idx_criminal_risk_level ON criminals(risk_level); +CREATE INDEX idx_criminal_created_at ON criminals(created_at DESC); + +-- Suspect photos indexes +CREATE INDEX idx_suspect_photos_criminal_id ON suspect_photos(criminal_id); +CREATE INDEX idx_suspect_photos_primary ON suspect_photos(criminal_id, is_primary) WHERE is_primary = true; +CREATE INDEX idx_suspect_photos_hash ON suspect_photos(photo_hash); + +-- Facial recognition logs indexes +CREATE INDEX idx_fr_logs_created_at ON facial_recognition_logs(created_at DESC); +CREATE INDEX idx_fr_logs_user ON facial_recognition_logs(requested_by); +CREATE INDEX idx_fr_logs_best_match ON facial_recognition_logs(best_match_criminal_id) WHERE best_match_criminal_id IS NOT NULL; +CREATE INDEX idx_fr_logs_case_id ON facial_recognition_logs(case_id) WHERE case_id IS NOT NULL; + +-- ==================================================================== +-- TRIGGERS FOR AUTO-UPDATE +-- ==================================================================== + +-- Automatically update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_criminals_updated_at + BEFORE UPDATE ON criminals + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ==================================================================== +-- UTILITY FUNCTIONS +-- ==================================================================== + +-- Function to calculate average embedding from multiple photos +CREATE OR REPLACE FUNCTION calculate_average_embedding(p_criminal_id INTEGER) +RETURNS BYTEA AS $$ +DECLARE + avg_embedding BYTEA; +BEGIN + -- This will be called from Python after uploading multiple photos + -- Python will handle the actual embedding averaging logic + -- This function is a placeholder for future stored procedure implementation + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Function to search similar faces (placeholder - actual search done in Python) +CREATE OR REPLACE FUNCTION search_similar_faces( + p_embedding BYTEA, + p_threshold DECIMAL DEFAULT 0.75, + p_limit INTEGER DEFAULT 10 +) +RETURNS TABLE ( + criminal_id INTEGER, + name VARCHAR, + similarity DECIMAL +) AS $$ +BEGIN + -- Actual similarity search is performed in Python using numpy + -- This is a placeholder for documentation + RETURN QUERY SELECT NULL::INTEGER, NULL::VARCHAR, NULL::DECIMAL LIMIT 0; +END; +$$ LANGUAGE plpgsql; + +-- ==================================================================== +-- INITIAL DATA / SEED DATA +-- ==================================================================== + +-- Insert sample criminal record for testing +INSERT INTO criminals ( + name, + nic, + date_of_birth, + gender, + crime_history, + status, + risk_level, + notes +) VALUES ( + 'Test Suspect One', + '199012345678', + '1990-05-15', + 'Male', + '[{"crime_type": "Theft", "date": "2023-03-10", "status": "Convicted", "sentence": "2 years"}]'::jsonb, + 'active', + 'medium', + 'Sample criminal record for testing facial recognition system' +); + +-- ==================================================================== +-- PERMISSIONS & SECURITY +-- ==================================================================== + +-- Grant appropriate permissions (adjust based on your user roles) +-- GRANT SELECT, INSERT, UPDATE ON criminals TO crimelink_app_user; +-- GRANT SELECT, INSERT ON suspect_photos TO crimelink_app_user; +-- GRANT INSERT ON facial_recognition_logs TO crimelink_app_user; + +-- ==================================================================== +-- COMMENTS FOR DOCUMENTATION +-- ==================================================================== + +COMMENT ON TABLE criminals IS 'Stores criminal records with biometric face embeddings for facial recognition'; +COMMENT ON COLUMN criminals.face_embedding IS 'Average face embedding vector stored as binary data (512-dimensional float32 array)'; +COMMENT ON COLUMN criminals.crime_history IS 'JSON array of crime records: [{crime_type, date, status, sentence}]'; + +COMMENT ON TABLE suspect_photos IS 'Multiple photos per criminal for improved recognition accuracy'; +COMMENT ON COLUMN suspect_photos.face_embedding IS 'Individual face embedding for this specific photo'; +COMMENT ON COLUMN suspect_photos.photo_hash IS 'SHA-256 hash to prevent duplicate photo uploads'; + +COMMENT ON TABLE facial_recognition_logs IS 'Audit trail for all facial recognition analysis requests'; +COMMENT ON COLUMN facial_recognition_logs.all_matches IS 'JSON array of all matches: [{criminal_id, similarity, confidence}]'; + +-- ==================================================================== +-- END OF SCHEMA +-- ==================================================================== diff --git a/src/main/java/com/crimeLink/analyzer/config/RestTemplateConfig.java b/src/main/java/com/crimeLink/analyzer/config/RestTemplateConfig.java new file mode 100644 index 0000000..0c21599 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/config/RestTemplateConfig.java @@ -0,0 +1,23 @@ +package com.crimeLink.analyzer.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +/** + * Configuration for RestTemplate bean used to communicate with ML microservices. + * Part of the hybrid monolith + microservices architecture. + */ +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + // Set timeout for ML service calls (30 seconds for heavy processing) + factory.setConnectTimeout(10000); // 10 seconds connection timeout + factory.setReadTimeout(30000); // 30 seconds read timeout (ML processing can be slow) + return new RestTemplate(factory); + } +} diff --git a/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java b/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java index 9ca56a9..ac4771b 100644 --- a/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java +++ b/src/main/java/com/crimeLink/analyzer/config/SecurityConfig.java @@ -46,6 +46,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/health").permitAll() .requestMatchers("/api/admin/health").permitAll() + .requestMatchers("/api/facial/health").permitAll() // ML service health check + .requestMatchers("/api/call-analysis/health").permitAll() // ML service health check + + // ML Service endpoints - Investigator role only + .requestMatchers("/api/call-analysis/**").hasRole("Investigator") + .requestMatchers("/api/facial/**").hasRole("Investigator") + .requestMatchers("/api/database/**").permitAll() .requestMatchers("/api/test").permitAll() .requestMatchers("/api/debug/**").permitAll() // 🔍 Debug endpoints diff --git a/src/main/java/com/crimeLink/analyzer/controller/CallAnalysisController.java b/src/main/java/com/crimeLink/analyzer/controller/CallAnalysisController.java index d625885..d489d95 100644 --- a/src/main/java/com/crimeLink/analyzer/controller/CallAnalysisController.java +++ b/src/main/java/com/crimeLink/analyzer/controller/CallAnalysisController.java @@ -1,114 +1,160 @@ package com.crimeLink.analyzer.controller; -import com.crimeLink.analyzer.dto.CallAnalysisResultDTO; import com.crimeLink.analyzer.service.CallAnalysisService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.util.HashMap; +import java.util.Arrays; +import java.util.List; import java.util.Map; +/** + * REST Controller for Call Analysis operations. + * Acts as API Gateway layer, routing requests to the Python ML microservice. + * + * Architecture Pattern: Hybrid Monolith + Microservices + * - Spring Boot handles authentication, authorization, and request routing + * - Python FastAPI handles ML inference (call record analysis, NLP) + * + * Endpoints: + * - POST /api/call-analysis/analyze - Analyze single call record PDF + * - POST /api/call-analysis/analyze/batch - Analyze multiple call record PDFs + * - GET /api/call-analysis/health - Check ML service health + */ @RestController -@RequestMapping("/api/investigator/call-analysis") -@PreAuthorize("hasAnyRole('Investigator', 'OIC', 'Admin')") +@RequestMapping("/api/call-analysis") +@RequiredArgsConstructor +@Slf4j public class CallAnalysisController { - @Autowired - private CallAnalysisService callAnalysisService; + private final CallAnalysisService callAnalysisService; /** - * Upload PDF file for call record analysis + * Analyze a single call record PDF. + * * @param file PDF file containing call records - * @return Analysis ID for tracking results + * @return Analysis results with crime indicators, entity graph, etc. */ - @PostMapping("/upload") - public ResponseEntity> uploadCallRecords(@RequestParam("file") MultipartFile file) { + @PostMapping("/analyze") + public ResponseEntity analyzeCallRecord( + @RequestParam("file") MultipartFile file) { + try { - // Validate file - if (file.isEmpty()) { - Map error = new HashMap<>(); - error.put("error", "No file provided"); - return ResponseEntity.badRequest().body(error); - } + log.info("Call record analysis requested: {}", file.getOriginalFilename()); - if (!file.getOriginalFilename().toLowerCase().endsWith(".pdf")) { - Map error = new HashMap<>(); - error.put("error", "Only PDF files are supported"); - return ResponseEntity.badRequest().body(error); + // Validate file + ResponseEntity validationError = validatePdfFile(file, 10 * 1024 * 1024); // 10MB + if (validationError != null) { + return validationError; } - // Send to Python service - String analysisId = callAnalysisService.analyzeCallRecords(file); - - Map response = new HashMap<>(); - response.put("analysis_id", analysisId); - response.put("status", "processing"); - response.put("message", "Analysis started successfully"); - - return ResponseEntity.ok(response); + // Forward to ML service + JsonNode result = callAnalysisService.analyzeCallRecord(file); + + return ResponseEntity.ok(result); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("error", "Failed to process file: " + e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } catch (RuntimeException e) { + log.error("Call record analysis failed: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); } } /** - * Get analysis results by ID - * @param analysisId Analysis ID returned from upload - * @return Complete analysis results including network graph and criminal matches + * Analyze multiple call record PDFs in batch. + * + * @param files Array of PDF files containing call records + * @return Batch analysis results */ - @GetMapping("/results/{analysisId}") - public ResponseEntity getAnalysisResults(@PathVariable String analysisId) { + @PostMapping("/analyze/batch") + public ResponseEntity analyzeBatch( + @RequestParam("files") MultipartFile[] files) { + try { - CallAnalysisResultDTO result = callAnalysisService.getAnalysisResults(analysisId); + log.info("Batch call analysis requested: {} files", files.length); + + // Validate files + if (files.length == 0) { + return ResponseEntity.badRequest() + .body(Map.of("error", "No files provided")); + } - if (result == null) { - return ResponseEntity.notFound().build(); + for (MultipartFile file : files) { + ResponseEntity validationError = validatePdfFile(file, 10 * 1024 * 1024); // 10MB + if (validationError != null) { + return validationError; + } } + // Forward to ML service + List fileList = Arrays.asList(files); + JsonNode result = callAnalysisService.analyzeBatch(fileList); + return ResponseEntity.ok(result); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } catch (RuntimeException e) { + log.error("Batch call analysis failed: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); } } /** - * Get all analysis history - * @return List of all analyses + * Health check endpoint for the call analysis ML service. + * Public endpoint for monitoring. + * + * @return Health status */ - @GetMapping("/history") - public ResponseEntity> getAnalysisHistory() { - try { - Map history = callAnalysisService.getAllAnalyses(); - return ResponseEntity.ok(history); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("error", "Failed to retrieve history: " + e.getMessage()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + @GetMapping("/health") + public ResponseEntity checkHealth() { + JsonNode health = callAnalysisService.checkHealth(); + + String status = health.has("status") ? health.get("status").asText() : "unknown"; + + if ("healthy".equals(status) || "ok".equals(status)) { + return ResponseEntity.ok(health); + } else { + return ResponseEntity.status(503).body(health); } } /** - * Check Python service health - * @return Health status of call analysis service + * Validate PDF file for call analysis. + * Checks: file not empty, content type is PDF, file size within limit. + * + * @param file The file to validate + * @param maxSizeBytes Maximum allowed file size in bytes + * @return ResponseEntity with error if validation fails, null if valid */ - @GetMapping("/health") - public ResponseEntity> checkServiceHealth() { - try { - Map health = callAnalysisService.checkPythonServiceHealth(); - return ResponseEntity.ok(health); - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("status", "unhealthy"); - error.put("error", e.getMessage()); - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error); + private ResponseEntity validatePdfFile(MultipartFile file, long maxSizeBytes) { + if (file == null || file.isEmpty()) { + log.warn("Validation failed: Empty file"); + return ResponseEntity.badRequest() + .body(Map.of("error", "No file provided")); } + + String contentType = file.getContentType(); + if (contentType == null || !contentType.equals("application/pdf")) { + log.warn("Validation failed: Invalid content type '{}' for file '{}'", + contentType, file.getOriginalFilename()); + return ResponseEntity.badRequest() + .body(Map.of("error", "Invalid file type: " + file.getOriginalFilename() + + ". Only PDF files are allowed.")); + } + + long fileSize = file.getSize(); + if (fileSize > maxSizeBytes) { + log.warn("Validation failed: File size {} exceeds limit {} for file '{}'", + fileSize, maxSizeBytes, file.getOriginalFilename()); + return ResponseEntity.badRequest() + .body(Map.of("error", "File size exceeds maximum limit of " + + (maxSizeBytes / (1024 * 1024)) + "MB: " + file.getOriginalFilename())); + } + + return null; // Validation passed } } diff --git a/src/main/java/com/crimeLink/analyzer/controller/FacialRecognitionController.java b/src/main/java/com/crimeLink/analyzer/controller/FacialRecognitionController.java new file mode 100644 index 0000000..ff00d2e --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/controller/FacialRecognitionController.java @@ -0,0 +1,252 @@ +package com.crimeLink.analyzer.controller; + +import com.crimeLink.analyzer.service.FacialRecognitionService; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +/** + * REST Controller for Facial Recognition operations. + * Acts as API Gateway layer, routing requests to the Python ML microservice. + * + * Architecture Pattern: Hybrid Monolith + Microservices + * - Spring Boot handles authentication, authorization, and request routing + * - Python FastAPI handles ML inference (facial recognition) + * + * Endpoints: + * - POST /api/facial/analyze - Analyze suspect image for matches + * - POST /api/facial/register - Register new criminal face + * - GET /api/facial/criminals - List registered criminals + * - GET /api/facial/history - Get recognition history + * - GET /api/facial/health - Check ML service health + */ +@RestController +@RequestMapping("/api/facial") +@RequiredArgsConstructor +@Slf4j +public class FacialRecognitionController { + + private final FacialRecognitionService facialRecognitionService; + + /** + * Analyze a suspect image for facial recognition matches. + * Requires authentication - user ID is extracted from JWT token. + * + * @param image The image file to analyze (multipart) + * @param threshold Similarity threshold (0-100), default 45 + * @param caseId Optional case ID for linking to investigation + * @return Analysis results with matched criminals + */ + @PostMapping("/analyze") + public ResponseEntity analyzeImage( + @RequestParam("image") MultipartFile image, + @RequestParam(value = "threshold", required = false, defaultValue = "45") Float threshold, + @RequestParam(value = "case_id", required = false) String caseId) { + + try { + // Get authenticated user ID from security context + String userId = getCurrentUserId(); + log.info("Facial recognition analysis requested by user: {}", userId); + + // Validate image + ResponseEntity validationError = validateImageFile(image, 10 * 1024 * 1024); // 10MB + if (validationError != null) { + return validationError; + } + + // Validate threshold range + if (threshold != null && (threshold < 0 || threshold > 100)) { + log.warn("Validation failed: Threshold {} out of range [0-100]", threshold); + return ResponseEntity.badRequest() + .body(Map.of("error", "Threshold must be between 0 and 100")); + } + + // Forward to ML service + JsonNode result = facialRecognitionService.analyzeImage(image, threshold, userId, caseId); + + return ResponseEntity.ok(result); + + } catch (RuntimeException e) { + log.error("Facial recognition analysis failed: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); + } + } + + /** + * Register a new criminal with their photo for facial recognition. + * Requires authentication. + * + * @param photo Photo of the criminal + * @param criminalId Optional existing criminal ID to link + * @param name Criminal's name + * @param nic National ID Card number + * @param riskLevel Risk level (high, medium, low) + * @return Registration result with criminal details + */ + @PostMapping("/register") + public ResponseEntity registerCriminal( + @RequestParam("photo") MultipartFile photo, + @RequestParam(value = "criminal_id", required = false) String criminalId, + @RequestParam("name") String name, + @RequestParam("nic") String nic, + @RequestParam(value = "risk_level", required = false, defaultValue = "medium") String riskLevel) { + + try { + log.info("Criminal registration requested: {} ({})", name, nic); + + // Validate required text fields + ResponseEntity nameValidation = validateRequiredText(name, "name"); + if (nameValidation != null) { + return nameValidation; + } + + ResponseEntity nicValidation = validateRequiredText(nic, "nic"); + if (nicValidation != null) { + return nicValidation; + } + + // Validate photo + ResponseEntity photoValidation = validateImageFile(photo, 10 * 1024 * 1024); // 10MB + if (photoValidation != null) { + return photoValidation; + } + + // Forward to ML service + JsonNode result = facialRecognitionService.registerCriminal( + photo, criminalId, name, nic, riskLevel); + + return ResponseEntity.ok(result); + + } catch (RuntimeException e) { + log.error("Criminal registration failed: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); + } + } + + /** + * Get list of all registered criminals with face embeddings. + * + * @return List of criminals + */ + @GetMapping("/criminals") + public ResponseEntity getCriminals() { + try { + JsonNode result = facialRecognitionService.getCriminals(); + return ResponseEntity.ok(result); + } catch (RuntimeException e) { + log.error("Failed to fetch criminals: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); + } + } + + /** + * Get facial recognition history/audit logs. + * + * @param limit Maximum number of records (default 50) + * @return Recognition history + */ + @GetMapping("/history") + public ResponseEntity getRecognitionHistory( + @RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) { + try { + JsonNode result = facialRecognitionService.getRecognitionHistory(limit); + return ResponseEntity.ok(result); + } catch (RuntimeException e) { + log.error("Failed to fetch history: {}", e.getMessage()); + return ResponseEntity.internalServerError() + .body(Map.of("error", e.getMessage())); + } + } + + /** + * Health check endpoint for the facial recognition ML service. + * Public endpoint for monitoring. + * + * @return Health status + */ + @GetMapping("/health") + public ResponseEntity checkHealth() { + JsonNode health = facialRecognitionService.checkHealth(); + + String status = health.has("status") ? health.get("status").asText() : "unknown"; + + if ("healthy".equals(status)) { + return ResponseEntity.ok(health); + } else { + return ResponseEntity.status(503).body(health); + } + } + + /** + * Validate image file for facial recognition. + * Checks: file not empty, content type is image/*, file size within limit. + * + * @param file The file to validate + * @param maxSizeBytes Maximum allowed file size in bytes + * @return ResponseEntity with error if validation fails, null if valid + */ + private ResponseEntity validateImageFile(MultipartFile file, long maxSizeBytes) { + if (file == null || file.isEmpty()) { + log.warn("Validation failed: Empty image file"); + return ResponseEntity.badRequest() + .body(Map.of("error", "No image provided")); + } + + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + log.warn("Validation failed: Invalid content type '{}' for file '{}'", + contentType, file.getOriginalFilename()); + return ResponseEntity.badRequest() + .body(Map.of("error", "Invalid file type. Please upload an image.")); + } + + long fileSize = file.getSize(); + if (fileSize > maxSizeBytes) { + log.warn("Validation failed: Image size {} exceeds limit {} for file '{}'", + fileSize, maxSizeBytes, file.getOriginalFilename()); + return ResponseEntity.badRequest() + .body(Map.of("error", "File size exceeds maximum limit of " + + (maxSizeBytes / (1024 * 1024)) + "MB: " + file.getOriginalFilename())); + } + + return null; // Validation passed + } + + /** + * Validate required text field. + * Checks: not null, not blank after trimming. + * + * @param value The value to validate + * @param fieldName Name of the field (for error message) + * @return ResponseEntity with error if validation fails, null if valid + */ + private ResponseEntity validateRequiredText(String value, String fieldName) { + if (value == null || value.trim().isEmpty()) { + log.warn("Validation failed: Required field '{}' is missing or empty", fieldName); + return ResponseEntity.badRequest() + .body(Map.of("error", fieldName + " is required")); + } + return null; // Validation passed + } + + /** + * Extract current user ID from security context. + */ + private String getCurrentUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) { + return auth.getName(); + } + return "unknown"; + } +} diff --git a/src/main/java/com/crimeLink/analyzer/dto/CallAnalysisResultDTO.java b/src/main/java/com/crimeLink/analyzer/dto/CallAnalysisResultDTO.java deleted file mode 100644 index 37d8f9e..0000000 --- a/src/main/java/com/crimeLink/analyzer/dto/CallAnalysisResultDTO.java +++ /dev/null @@ -1,387 +0,0 @@ -package com.crimeLink.analyzer.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import java.util.Map; - -public class CallAnalysisResultDTO { - - @JsonProperty("analysis_id") - private String analysisId; - - private String status; - private String timestamp; - - @JsonProperty("file_name") - private String fileName; - - @JsonProperty("total_calls") - private int totalCalls; - - @JsonProperty("unique_numbers") - private List uniqueNumbers; - - @JsonProperty("call_frequency") - private Map callFrequency; - - @JsonProperty("time_pattern") - private Map timePattern; - - @JsonProperty("common_contacts") - private List commonContacts; - - @JsonProperty("network_graph") - private NetworkGraph networkGraph; - - @JsonProperty("criminal_matches") - private List criminalMatches; - - @JsonProperty("risk_score") - private int riskScore; - - // Inner classes for nested structures - - public static class CommonContact { - private String phone; - private int count; - - public String getPhone() { - return phone; - } - - public void setPhone(String phone) { - this.phone = phone; - } - - public int getCount() { - return count; - } - - public void setCount(int count) { - this.count = count; - } - } - - public static class NetworkGraph { - private List nodes; - private List edges; - - @JsonProperty("total_nodes") - private int totalNodes; - - @JsonProperty("total_edges") - private int totalEdges; - - private double density; - - public static class Node { - private String id; - private String label; - private String type; - private int size; - private double centrality; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getLabel() { - return label; - } - - public void setLabel(String label) { - this.label = label; - } - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public int getSize() { - return size; - } - - public void setSize(int size) { - this.size = size; - } - - public double getCentrality() { - return centrality; - } - - public void setCentrality(double centrality) { - this.centrality = centrality; - } - } - - public static class Edge { - private String source; - private String target; - private int weight; - private String label; - - public String getSource() { - return source; - } - - public void setSource(String source) { - this.source = source; - } - - public String getTarget() { - return target; - } - - public void setTarget(String target) { - this.target = target; - } - - public int getWeight() { - return weight; - } - - public void setWeight(int weight) { - this.weight = weight; - } - - public String getLabel() { - return label; - } - - public void setLabel(String label) { - this.label = label; - } - } - - public List getNodes() { - return nodes; - } - - public void setNodes(List nodes) { - this.nodes = nodes; - } - - public List getEdges() { - return edges; - } - - public void setEdges(List edges) { - this.edges = edges; - } - - public int getTotalNodes() { - return totalNodes; - } - - public void setTotalNodes(int totalNodes) { - this.totalNodes = totalNodes; - } - - public int getTotalEdges() { - return totalEdges; - } - - public void setTotalEdges(int totalEdges) { - this.totalEdges = totalEdges; - } - - public double getDensity() { - return density; - } - - public void setDensity(double density) { - this.density = density; - } - } - - public static class CriminalMatch { - private String phone; - - @JsonProperty("criminal_id") - private String criminalId; - - private String name; - private String nic; - - @JsonProperty("crime_history") - private List crimeHistory; - - public static class Crime { - @JsonProperty("crime_type") - private String crimeType; - - private String date; - private String status; - - public String getCrimeType() { - return crimeType; - } - - public void setCrimeType(String crimeType) { - this.crimeType = crimeType; - } - - public String getDate() { - return date; - } - - public void setDate(String date) { - this.date = date; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - } - - public String getPhone() { - return phone; - } - - public void setPhone(String phone) { - this.phone = phone; - } - - public String getCriminalId() { - return criminalId; - } - - public void setCriminalId(String criminalId) { - this.criminalId = criminalId; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getNic() { - return nic; - } - - public void setNic(String nic) { - this.nic = nic; - } - - public List getCrimeHistory() { - return crimeHistory; - } - - public void setCrimeHistory(List crimeHistory) { - this.crimeHistory = crimeHistory; - } - } - - // Getters and Setters - - public String getAnalysisId() { - return analysisId; - } - - public void setAnalysisId(String analysisId) { - this.analysisId = analysisId; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - - public String getTimestamp() { - return timestamp; - } - - public void setTimestamp(String timestamp) { - this.timestamp = timestamp; - } - - public String getFileName() { - return fileName; - } - - public void setFileName(String fileName) { - this.fileName = fileName; - } - - public int getTotalCalls() { - return totalCalls; - } - - public void setTotalCalls(int totalCalls) { - this.totalCalls = totalCalls; - } - - public List getUniqueNumbers() { - return uniqueNumbers; - } - - public void setUniqueNumbers(List uniqueNumbers) { - this.uniqueNumbers = uniqueNumbers; - } - - public Map getCallFrequency() { - return callFrequency; - } - - public void setCallFrequency(Map callFrequency) { - this.callFrequency = callFrequency; - } - - public Map getTimePattern() { - return timePattern; - } - - public void setTimePattern(Map timePattern) { - this.timePattern = timePattern; - } - - public List getCommonContacts() { - return commonContacts; - } - - public void setCommonContacts(List commonContacts) { - this.commonContacts = commonContacts; - } - - public NetworkGraph getNetworkGraph() { - return networkGraph; - } - - public void setNetworkGraph(NetworkGraph networkGraph) { - this.networkGraph = networkGraph; - } - - public List getCriminalMatches() { - return criminalMatches; - } - - public void setCriminalMatches(List criminalMatches) { - this.criminalMatches = criminalMatches; - } - - public int getRiskScore() { - return riskScore; - } - - public void setRiskScore(int riskScore) { - this.riskScore = riskScore; - } -} diff --git a/src/main/java/com/crimeLink/analyzer/entity/CallAnalysisRecord.java b/src/main/java/com/crimeLink/analyzer/entity/CallAnalysisRecord.java deleted file mode 100644 index 0adb79a..0000000 --- a/src/main/java/com/crimeLink/analyzer/entity/CallAnalysisRecord.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.crimeLink.analyzer.entity; - -import jakarta.persistence.*; -import java.time.LocalDateTime; - -@Entity -@Table(name = "call_analysis_records") -public class CallAnalysisRecord { - - @Id - @Column(name = "analysis_id", length = 255) - private String analysisId; - - @Column(name = "file_name", length = 500) - private String fileName; - - @Column(name = "uploaded_by", length = 100) - private String uploadedBy; - - @Column(name = "total_calls") - private Integer totalCalls; - - @Column(name = "unique_numbers_count") - private Integer uniqueNumbersCount; - - @Column(name = "criminal_matches_count") - private Integer criminalMatchesCount; - - @Column(name = "risk_score") - private Integer riskScore; - - @Column(name = "status", length = 50) - private String status; // processing, completed, failed - - @Column(name = "analysis_data", columnDefinition = "TEXT") - private String analysisData; // JSON string of full analysis - - @Column(name = "created_at") - private LocalDateTime createdAt; - - @Column(name = "completed_at") - private LocalDateTime completedAt; - - @PrePersist - protected void onCreate() { - if (createdAt == null) { - createdAt = LocalDateTime.now(); - } - if (status == null) { - status = "processing"; - } - } - - // Constructors - public CallAnalysisRecord() {} - - public CallAnalysisRecord(String analysisId, String fileName, String uploadedBy) { - this.analysisId = analysisId; - this.fileName = fileName; - this.uploadedBy = uploadedBy; - } - - // Getters and Setters - public String getAnalysisId() { - return analysisId; - } - - public void setAnalysisId(String analysisId) { - this.analysisId = analysisId; - } - - public String getFileName() { - return fileName; - } - - public void setFileName(String fileName) { - this.fileName = fileName; - } - - public String getUploadedBy() { - return uploadedBy; - } - - public void setUploadedBy(String uploadedBy) { - this.uploadedBy = uploadedBy; - } - - public Integer getTotalCalls() { - return totalCalls; - } - - public void setTotalCalls(Integer totalCalls) { - this.totalCalls = totalCalls; - } - - public Integer getUniqueNumbersCount() { - return uniqueNumbersCount; - } - - public void setUniqueNumbersCount(Integer uniqueNumbersCount) { - this.uniqueNumbersCount = uniqueNumbersCount; - } - - public Integer getCriminalMatchesCount() { - return criminalMatchesCount; - } - - public void setCriminalMatchesCount(Integer criminalMatchesCount) { - this.criminalMatchesCount = criminalMatchesCount; - } - - public Integer getRiskScore() { - return riskScore; - } - - public void setRiskScore(Integer riskScore) { - this.riskScore = riskScore; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - - public String getAnalysisData() { - return analysisData; - } - - public void setAnalysisData(String analysisData) { - this.analysisData = analysisData; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(LocalDateTime createdAt) { - this.createdAt = createdAt; - } - - public LocalDateTime getCompletedAt() { - return completedAt; - } - - public void setCompletedAt(LocalDateTime completedAt) { - this.completedAt = completedAt; - } -} diff --git a/src/main/java/com/crimeLink/analyzer/repository/CallAnalysisRepository.java b/src/main/java/com/crimeLink/analyzer/repository/CallAnalysisRepository.java deleted file mode 100644 index 9a617a9..0000000 --- a/src/main/java/com/crimeLink/analyzer/repository/CallAnalysisRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.crimeLink.analyzer.repository; - -import com.crimeLink.analyzer.entity.CallAnalysisRecord; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface CallAnalysisRepository extends JpaRepository { - - List findByUploadedByOrderByCreatedAtDesc(String uploadedBy); - - List findByStatusOrderByCreatedAtDesc(String status); - - List findTop10ByOrderByCreatedAtDesc(); -} diff --git a/src/main/java/com/crimeLink/analyzer/service/CallAnalysisService.java b/src/main/java/com/crimeLink/analyzer/service/CallAnalysisService.java index fc2e07a..6ca0687 100644 --- a/src/main/java/com/crimeLink/analyzer/service/CallAnalysisService.java +++ b/src/main/java/com/crimeLink/analyzer/service/CallAnalysisService.java @@ -1,114 +1,176 @@ package com.crimeLink.analyzer.service; -import com.crimeLink.analyzer.dto.CallAnalysisResultDTO; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; -import java.util.HashMap; -import java.util.Map; +import java.io.IOException; +import java.util.List; +/** + * Service for communicating with the Call Analysis ML microservice. + * Acts as a proxy/gateway layer, forwarding requests from the Spring Boot + * monolith to the Python FastAPI microservice. + * + * Architecture: Frontend -> Spring Boot (this service) -> Python ML Service + */ @Service +@Slf4j public class CallAnalysisService { - @Value("${python.call-analysis.url:http://localhost:5001}") - private String pythonServiceUrl; - private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${python.call-analysis.url:http://localhost:5001}") + private String callAnalysisServiceUrl; - public CallAnalysisService() { - this.restTemplate = new RestTemplate(); + public CallAnalysisService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + this.objectMapper = new ObjectMapper(); } /** - * Send PDF file to Python service for analysis - * @param file PDF file - * @return Analysis ID + * Analyze a single call record PDF. + * + * @param file PDF file containing call records + * @return JSON response with analysis results */ - public String analyzeCallRecords(MultipartFile file) throws Exception { - String url = pythonServiceUrl + "/analyze"; - - // Prepare multipart request - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.MULTIPART_FORM_DATA); - - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", new ByteArrayResource(file.getBytes()) { - @Override - public String getFilename() { - return file.getOriginalFilename(); + public JsonNode analyzeCallRecord(MultipartFile file) { + log.info("Forwarding call record analysis to ML service: {}", file.getOriginalFilename()); + + String url = callAnalysisServiceUrl + "/analyze"; + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + + // Extract file bytes with explicit error handling + byte[] fileBytes; + try { + fileBytes = file.getBytes(); + } catch (IOException e) { + log.error("Failed to read file bytes from uploaded file '{}': {}", + file.getOriginalFilename(), e.getMessage()); + throw new RuntimeException("Failed to read uploaded file contents", e); } - }); - - HttpEntity> requestEntity = new HttpEntity<>(body, headers); - - // Call Python service - ResponseEntity response = restTemplate.postForEntity(url, requestEntity, Map.class); - - if (response.getStatusCode() == HttpStatus.OK) { - Map responseBody = response.getBody(); - return (String) responseBody.get("analysis_id"); - } else { - throw new Exception("Failed to analyze call records: " + response.getStatusCode()); + + body.add("file", new ByteArrayResource(fileBytes) { + @Override + public String getFilename() { + return file.getOriginalFilename(); + } + }); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + requestEntity, + String.class + ); + + log.info("ML service responded with status: {}", response.getStatusCode()); + return objectMapper.readTree(response.getBody()); + + } catch (RestClientException e) { + log.error("Failed to communicate with call analysis service: {}", e.getMessage()); + throw new RuntimeException("Call analysis service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to process response from ML service: {}", e.getMessage()); + throw new RuntimeException("Failed to process ML service response: " + e.getMessage(), e); } } /** - * Get analysis results from Python service - * @param analysisId Analysis ID - * @return Analysis results DTO + * Analyze multiple call record PDFs in batch. + * + * @param files List of PDF files + * @return JSON response with batch analysis results */ - public CallAnalysisResultDTO getAnalysisResults(String analysisId) throws Exception { - String url = pythonServiceUrl + "/results/" + analysisId; - - ResponseEntity response = restTemplate.getForEntity( - url, - CallAnalysisResultDTO.class - ); - - if (response.getStatusCode() == HttpStatus.OK) { - return response.getBody(); - } else if (response.getStatusCode() == HttpStatus.NOT_FOUND) { - return null; - } else { - throw new Exception("Failed to retrieve analysis results"); - } - } + public JsonNode analyzeBatch(List files) { + log.info("Forwarding batch call analysis to ML service: {} files", files.size()); + + String url = callAnalysisServiceUrl + "/analyze/batch"; + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + + for (MultipartFile file : files) { + // Extract file bytes with explicit error handling + byte[] fileBytes; + try { + fileBytes = file.getBytes(); + } catch (IOException e) { + log.error("Failed to read file bytes from uploaded file '{}': {}", + file.getOriginalFilename(), e.getMessage()); + throw new RuntimeException("Failed to read uploaded file: " + file.getOriginalFilename(), e); + } + + final String originalFilename = file.getOriginalFilename(); + body.add("files", new ByteArrayResource(fileBytes) { + @Override + public String getFilename() { + return originalFilename; + } + }); + } - /** - * Get all analysis history - * @return List of analyses - */ - public Map getAllAnalyses() throws Exception { - String url = pythonServiceUrl + "/results"; + HttpEntity> requestEntity = new HttpEntity<>(body, headers); - ResponseEntity response = restTemplate.getForEntity(url, Map.class); + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + requestEntity, + String.class + ); - if (response.getStatusCode() == HttpStatus.OK) { - return response.getBody(); - } else { - throw new Exception("Failed to retrieve analysis history"); + log.info("Batch analysis completed successfully"); + return objectMapper.readTree(response.getBody()); + + } catch (RestClientException e) { + log.error("Failed to communicate with call analysis service: {}", e.getMessage()); + throw new RuntimeException("Call analysis service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to process files: {}", e.getMessage()); + throw new RuntimeException("Failed to process files: " + e.getMessage(), e); } } /** - * Check if Python service is healthy - * @return Health status + * Check health status of the call analysis ML service. + * + * @return Health status JSON */ - public Map checkPythonServiceHealth() throws Exception { - String url = pythonServiceUrl + "/health"; - - ResponseEntity response = restTemplate.getForEntity(url, Map.class); - - if (response.getStatusCode() == HttpStatus.OK) { - return response.getBody(); - } else { - throw new Exception("Python service is not responding"); + public JsonNode checkHealth() { + String url = callAnalysisServiceUrl + "/health"; + + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + return objectMapper.readTree(response.getBody()); + } catch (RestClientException e) { + log.warn("Call analysis service health check failed: {}", e.getMessage()); + return objectMapper.createObjectNode() + .put("status", "unhealthy") + .put("error", e.getMessage()); + } catch (IOException e) { + return objectMapper.createObjectNode() + .put("status", "unhealthy") + .put("error", "Invalid response"); } } } diff --git a/src/main/java/com/crimeLink/analyzer/service/FacialRecognitionService.java b/src/main/java/com/crimeLink/analyzer/service/FacialRecognitionService.java new file mode 100644 index 0000000..4516348 --- /dev/null +++ b/src/main/java/com/crimeLink/analyzer/service/FacialRecognitionService.java @@ -0,0 +1,248 @@ +package com.crimeLink.analyzer.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Map; + +/** + * Service for communicating with the Facial Recognition ML microservice. + * Acts as a proxy/gateway layer, forwarding requests from the Spring Boot + * monolith to the Python FastAPI microservice. + * + * Architecture: Frontend -> Spring Boot (this service) -> Python ML Service + */ +@Service +@Slf4j +public class FacialRecognitionService { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${python.facial-recognition.url:http://localhost:5002}") + private String facialRecognitionServiceUrl; + + public FacialRecognitionService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + this.objectMapper = new ObjectMapper(); + } + + /** + * Analyze a suspect image for facial recognition matches. + * Forwards the request to Python ML service and returns the response. + * + * @param image The image file to analyze + * @param threshold Similarity threshold (0-100) + * @param userId User ID making the request (for audit logging) + * @param caseId Optional case ID for linking analysis to investigation + * @return JSON response from ML service containing matches + */ + public JsonNode analyzeImage(MultipartFile image, Float threshold, String userId, String caseId) { + log.info("Forwarding facial recognition request to ML service for user: {}", userId); + + String url = facialRecognitionServiceUrl + "/analyze"; + + try { + // Build multipart request + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + + // Add image file with explicit error handling + byte[] imageBytes; + try { + imageBytes = image.getBytes(); + } catch (IOException e) { + log.error("Failed to read image bytes from uploaded file '{}': {}", + image.getOriginalFilename(), e.getMessage()); + throw new RuntimeException("Failed to read uploaded image contents", e); + } + + body.add("image", new ByteArrayResource(imageBytes) { + @Override + public String getFilename() { + return image.getOriginalFilename(); + } + }); + + // Add optional parameters + if (threshold != null) { + body.add("threshold", threshold.toString()); + } + if (userId != null) { + body.add("user_id", userId); + } + if (caseId != null) { + body.add("case_id", caseId); + } + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + log.debug("Sending request to: {}", url); + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + requestEntity, + String.class + ); + + log.info("ML service responded with status: {}", response.getStatusCode()); + return objectMapper.readTree(response.getBody()); + + } catch (RestClientException e) { + log.error("Failed to communicate with facial recognition service: {}", e.getMessage()); + throw new RuntimeException("Facial recognition service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to process response from ML service: {}", e.getMessage()); + throw new RuntimeException("Failed to process ML service response: " + e.getMessage(), e); + } + } + + /** + * Register a new criminal with their photo for facial recognition. + * + * @param photo Photo of the criminal + * @param criminalId Existing criminal ID to link + * @param name Criminal's name + * @param nic National ID Card number + * @param riskLevel Risk level (high, medium, low) + * @return JSON response from ML service + */ + public JsonNode registerCriminal(MultipartFile photo, String criminalId, String name, + String nic, String riskLevel) { + log.info("Forwarding criminal registration to ML service: {} ({})", name, nic); + + String url = facialRecognitionServiceUrl + "/register"; + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + + // Extract photo bytes with explicit error handling + byte[] photoBytes; + try { + photoBytes = photo.getBytes(); + } catch (IOException e) { + log.error("Failed to read photo bytes from uploaded file '{}': {}", + photo.getOriginalFilename(), e.getMessage()); + throw new RuntimeException("Failed to read uploaded photo contents", e); + } + + body.add("photo", new ByteArrayResource(photoBytes) { + @Override + public String getFilename() { + return photo.getOriginalFilename(); + } + }); + + if (criminalId != null) body.add("criminal_id", criminalId); + body.add("name", name); + body.add("nic", nic); + if (riskLevel != null) body.add("risk_level", riskLevel); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + requestEntity, + String.class + ); + + log.info("Criminal registered successfully"); + return objectMapper.readTree(response.getBody()); + + } catch (RestClientException e) { + log.error("Failed to register criminal: {}", e.getMessage()); + throw new RuntimeException("Facial recognition service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to process response from ML service: {}", e.getMessage()); + throw new RuntimeException("Failed to process ML service response: " + e.getMessage(), e); + } + } + + /** + * Get list of all registered criminals with face embeddings. + * + * @return JSON array of criminals + */ + public JsonNode getCriminals() { + log.debug("Fetching criminals list from ML service"); + + String url = facialRecognitionServiceUrl + "/criminals"; + + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + return objectMapper.readTree(response.getBody()); + } catch (RestClientException e) { + log.error("Failed to fetch criminals: {}", e.getMessage()); + throw new RuntimeException("Facial recognition service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to parse response: {}", e.getMessage()); + throw new RuntimeException("Invalid response from service: " + e.getMessage(), e); + } + } + + /** + * Get facial recognition history/audit logs. + * + * @param limit Maximum number of records + * @return JSON array of recognition history + */ + public JsonNode getRecognitionHistory(Integer limit) { + log.debug("Fetching recognition history from ML service"); + + String url = facialRecognitionServiceUrl + "/history"; + if (limit != null) { + url += "?limit=" + limit; + } + + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + return objectMapper.readTree(response.getBody()); + } catch (RestClientException e) { + log.error("Failed to fetch history: {}", e.getMessage()); + throw new RuntimeException("Facial recognition service unavailable: " + e.getMessage(), e); + } catch (IOException e) { + log.error("Failed to parse response: {}", e.getMessage()); + throw new RuntimeException("Invalid response from service: " + e.getMessage(), e); + } + } + + /** + * Check health status of the facial recognition ML service. + * + * @return Health status JSON + */ + public JsonNode checkHealth() { + String url = facialRecognitionServiceUrl + "/health"; + + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + return objectMapper.readTree(response.getBody()); + } catch (RestClientException e) { + log.warn("Facial recognition service health check failed: {}", e.getMessage()); + return objectMapper.createObjectNode() + .put("status", "unhealthy") + .put("error", e.getMessage()); + } catch (IOException e) { + return objectMapper.createObjectNode() + .put("status", "unhealthy") + .put("error", "Invalid response"); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8a3bce8..22ee178 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -38,3 +38,8 @@ jwt.refresh-expiration=${REFRESH_TOKEN_EXPIRATION:604800000} python.call-analysis.url=${PYTHON_CALL_ANALYSIS_URL:http://localhost:5001} python.facial-recognition.url=${PYTHON_FACIAL_RECOGNITION_URL:http://localhost:5002} +# File Upload Configuration +spring.servlet.multipart.enabled=true +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=50MB +