From a99c67c84472fcebb20a437327e05f5d91a28bcb Mon Sep 17 00:00:00 2001 From: Jalen Stephens <108702328+Jalen-Stephens@users.noreply.github.com> Date: Mon, 1 Dec 2025 00:51:50 -0500 Subject: [PATCH 1/6] feat(ml): add logistic regression loader/inference and feature CSV helpers for AI scoring --- citations.md | 65 ++++++++++- .../metadetect/config/SecurityConfig.java | 17 ++- .../coms4156/project/metadetect/dto/Dtos.java | 4 +- .../metadetect/service/AnalyzeService.java | 34 +++++- .../metadetect/service/FeatureExtractor.java | 52 ++++++++- .../service/LogisticRegressionService.java | 76 +++++++++++++ .../metadetect/service/ModelLoader.java | 104 ++++++++++++++++++ src/main/resources/application.properties | 2 +- src/main/resources/model/model.json | 5 + .../controller/AnalyzeControllerTest.java | 6 +- .../service/AnalyzeServiceTest.java | 31 +++++- 11 files changed, 378 insertions(+), 18 deletions(-) create mode 100644 src/main/java/dev/coms4156/project/metadetect/service/LogisticRegressionService.java create mode 100644 src/main/java/dev/coms4156/project/metadetect/service/ModelLoader.java create mode 100644 src/main/resources/model/model.json diff --git a/citations.md b/citations.md index 60815e2..2547a25 100644 --- a/citations.md +++ b/citations.md @@ -173,6 +173,70 @@ Prompts and questions provided to ChatGPT included: --- +### **Commit / Ticket Reference** + +* **Commit:** `feat(ml): add logistic regression loader/inference and feature CSV helpers for AI scoring` +* **Ticket:** `#49 — Implement Demoable Client + Pooler Stability` +* **Date:** December 1, 2025 +* **Team Member:** Jalen Stephens + +--- + +### **AI Tool Information** + +* **Tool Used:** OpenAI ChatGPT (GPT-5) via Codex CLI +* **Access Method:** Local Codex CLI session (sandboxed, no paid API usage) +* **Configuration:** Default model settings +* **Cost:** $0 (educational access) + +--- + +### **Purpose of AI Assistance** + +* Implemented logistic regression model loader and inference service for the confidence score pipeline. +* Added CSV header/row helpers in `FeatureExtractor` to support offline dataset generation. +* Wired `AnalyzeService` to compute and persist ML confidence plus C2PA usage flags and model version. +* Added placeholder `model.json` and model path configuration for runtime loading. + +--- + +### **Prompts / Interaction Summary** + +* “Implement logistic regression inference with pretrained weights + bias in Java” +* “Add model loader for JSON weights and hook into AnalyzeService” +* “Add CSV scaffolding helpers to FeatureExtractor for offline training” +* “Fix OpenCV native load errors on macOS/Java 17+” +* “Fill out the commit citation entry using the standard template” + +--- + +### **Resulting Artifacts** + +* `src/main/java/dev/coms4156/project/metadetect/service/ModelLoader.java` +* `src/main/java/dev/coms4156/project/metadetect/service/LogisticRegressionService.java` +* `src/main/java/dev/coms4156/project/metadetect/service/FeatureExtractor.java` +* `src/main/java/dev/coms4156/project/metadetect/service/AnalyzeService.java` +* `src/main/resources/model/model.json` +* `src/main/resources/application.properties` (model path) +* Updated controller/service tests for the new confidence response shape. + +--- + +### **Verification** + +* `./mvnw -q -DskipTests compile` +* `mvn spring-boot:run` locally with `env.pooler.sh` sourced (startup success) + +--- + +### **Attribution Statement** + +> Portions of this commit were generated with assistance from OpenAI ChatGPT (GPT-5) on December 1, 2025. All AI-generated content was reviewed, verified, and finalized by the development team. + +--- + +--- + ### **Purpose of AI Assistance** The AI assistant helped diagnose Supabase pooler exhaustion by reviewing how Spring transactions were scoped around long-running storage calls. Guidance focused on: @@ -2850,4 +2914,3 @@ AI assistance was used to design, debug, and generate the complete `FeatureExtra > Portions of this commit or configuration were generated with assistance from OpenAI ChatGPT (GPT-5) on November 23 2025. All AI-generated content was reviewed, validated, and finalized by the development team. --- - diff --git a/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java b/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java index 0440a60..c387fae 100644 --- a/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java +++ b/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java @@ -8,6 +8,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -18,6 +19,7 @@ import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; @@ -54,6 +56,8 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce // Everything else under /api/** requires auth .anyRequest().authenticated() ) + .exceptionHandling(e -> e.authenticationEntryPoint( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults())); return http.build(); @@ -93,9 +97,18 @@ public SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exce "/webjars/**" ).permitAll() - // Everything else (non-API) is allowed - .anyRequest().permitAll() + // Public non-API endpoints (health/auth pages used by tests + clients) + .requestMatchers( + "/health", + "/actuator/**", + "/auth/**" + ).permitAll() + + // Everything else (non-API) requires authentication + .anyRequest().authenticated() ); + http.exceptionHandling(e -> e.authenticationEntryPoint( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); return http.build(); } diff --git a/src/main/java/dev/coms4156/project/metadetect/dto/Dtos.java b/src/main/java/dev/coms4156/project/metadetect/dto/Dtos.java index 37b6e23..0c1e87a 100644 --- a/src/main/java/dev/coms4156/project/metadetect/dto/Dtos.java +++ b/src/main/java/dev/coms4156/project/metadetect/dto/Dtos.java @@ -67,7 +67,9 @@ public record AnalysisManifestResponse( public record AnalyzeConfidenceResponse( String analysisId, String status, - Double score // nullable until we implement a real scorer + Double confidenceScore, + boolean c2paUsed, + String modelVersion ) { } /** diff --git a/src/main/java/dev/coms4156/project/metadetect/service/AnalyzeService.java b/src/main/java/dev/coms4156/project/metadetect/service/AnalyzeService.java index 457774a..ff2a85c 100644 --- a/src/main/java/dev/coms4156/project/metadetect/service/AnalyzeService.java +++ b/src/main/java/dev/coms4156/project/metadetect/service/AnalyzeService.java @@ -2,6 +2,7 @@ import static dev.coms4156.project.metadetect.model.AnalysisReport.ReportStatus; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import dev.coms4156.project.metadetect.c2pa.C2paToolInvoker; import dev.coms4156.project.metadetect.dto.Dtos; @@ -9,6 +10,7 @@ import dev.coms4156.project.metadetect.model.AnalysisReport.ReportStatus; import dev.coms4156.project.metadetect.model.Image; import dev.coms4156.project.metadetect.repository.AnalysisReportRepository; +import dev.coms4156.project.metadetect.service.LogisticRegressionService.InferenceResult; import dev.coms4156.project.metadetect.service.errors.MissingStoragePathException; import dev.coms4156.project.metadetect.service.errors.NotFoundException; import java.io.File; @@ -44,6 +46,7 @@ public class AnalyzeService { private final AnalysisReportRepository analysisRepo; private final SupabaseStorageService storage; private final UserService userService; + private final LogisticRegressionService logisticRegressionService; private final Clock clock; // Lightweight mapper for error JSON assembly. @@ -64,12 +67,14 @@ public AnalyzeService(C2paToolInvoker c2paToolInvoker, AnalysisReportRepository analysisRepo, SupabaseStorageService storage, UserService userService, + LogisticRegressionService logisticRegressionService, Clock clock) { this.c2paToolInvoker = c2paToolInvoker; this.imageService = imageService; this.analysisRepo = analysisRepo; this.storage = storage; this.userService = userService; + this.logisticRegressionService = logisticRegressionService; this.clock = clock; } @@ -163,7 +168,9 @@ public Dtos.AnalyzeConfidenceResponse getConfidence(UUID analysisId) { return new Dtos.AnalyzeConfidenceResponse( report.getId().toString(), report.getStatus().name(), - report.getConfidence() // null until a real scorer exists + report.getConfidence(), + deriveC2paUsed(report.getDetails()), + logisticRegressionService.getModelVersion() ); } @@ -207,11 +214,17 @@ private void runExtractionAndFinalize(UUID analysisId, String storagePath) { // 2) Run C2PA extraction into ML-ready metadata C2paToolInvoker.C2paMetadata meta = c2paToolInvoker.extractMetadata(tempFile); - // 3) Serialize metadata and mark COMPLETED + // 3) Compute logistic-regression score using OpenCV + C2PA features + InferenceResult inference = logisticRegressionService.predict( + tempFile.getAbsolutePath(), + meta + ); + + // 4) Serialize metadata and mark COMPLETED with a confidence score String json = objectMapper.writeValueAsString(meta); // The details field now stores the C2PA metadata schema, not raw manifest JSON. - markCompleted(analysisId, json, /*confidence*/ null); + markCompleted(analysisId, json, inference.confidenceScore()); } catch (IOException ioe) { // IO-level failures (download, JSON serialization) are genuine failures. @@ -298,6 +311,21 @@ private Instant now() { return Instant.now(clock); } + private boolean deriveC2paUsed(String detailsJson) { + if (!StringUtils.hasText(detailsJson)) { + return false; + } + try { + JsonNode node = objectMapper.readTree(detailsJson); + int hasManifest = node.path("c2paHasManifest").asInt(0); + int errorFlag = node.path("c2paErrorFlag").asInt(0); + return hasManifest == 1 && errorFlag == 0; + } catch (Exception e) { + // If parsing fails, default to false so the field is conservative. + return false; + } + } + /** Truncates a string to a maximum length, null-safe. */ private static String truncate(String s, int max) { if (s == null) { diff --git a/src/main/java/dev/coms4156/project/metadetect/service/FeatureExtractor.java b/src/main/java/dev/coms4156/project/metadetect/service/FeatureExtractor.java index 1c12bc5..1acf0f4 100644 --- a/src/main/java/dev/coms4156/project/metadetect/service/FeatureExtractor.java +++ b/src/main/java/dev/coms4156/project/metadetect/service/FeatureExtractor.java @@ -3,6 +3,7 @@ import dev.coms4156.project.metadetect.c2pa.C2paToolInvoker.C2paMetadata; import java.util.ArrayList; import java.util.List; +import nu.pattern.OpenCV; import org.opencv.core.Core; import org.opencv.core.CvType; import org.opencv.core.Mat; @@ -12,6 +13,7 @@ import org.opencv.core.Size; import org.opencv.imgcodecs.Imgcodecs; import org.opencv.imgproc.Imgproc; +import org.springframework.stereotype.Component; /** @@ -28,11 +30,18 @@ * NOTE: C2PA metadata is obtained separately via C2paToolInvoker. This class does * not call C2PA directly, but is designed to combine its results into the final feature vector. */ +@Component public class FeatureExtractor { static { - // Load native OpenCV library - System.loadLibrary(Core.NATIVE_LIBRARY_NAME); + // Load native OpenCV library packaged with the OpenPnp artifact. + // loadShared() extracts platform binaries to a temp dir; fallback to System.loadLibrary + // helps in environments where shared loading is restricted. + try { + OpenCV.loadShared(); + } catch (UnsatisfiedLinkError e) { + System.loadLibrary(Core.NATIVE_LIBRARY_NAME); + } } /** @@ -247,4 +256,43 @@ public double saturationEntropy(Mat img) { return entropy; } + + /** + * Produces a CSV header matching the extractAllFeatures() order plus the label column. + */ + public static String csvHeader() { + return String.join(",", + "laplacianVariance", + "noiseStd", + "edgeDensity", + "frequencyRatio", + "saturationEntropy", + "width", + "height", + "aspectRatio", + "c2paHasManifest", + "c2paManifestCount", + "c2paClaimGeneratorIsAi", + "c2paErrorFlag", + "label"); + } + + /** + * Simple CSV row builder to help offline dataset generation for the Python trainer. + * + * @param features ordered feature vector from {@link #extractAllFeatures(String, C2paMetadata)} + * @param label integer label (e.g., 1=AI generated, 0=human) + * @return CSV string containing all features followed by the label + */ + public static String toCsvRow(double[] features, int label) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < features.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(features[i]); + } + sb.append(',').append(label); + return sb.toString(); + } } diff --git a/src/main/java/dev/coms4156/project/metadetect/service/LogisticRegressionService.java b/src/main/java/dev/coms4156/project/metadetect/service/LogisticRegressionService.java new file mode 100644 index 0000000..8d1c0d4 --- /dev/null +++ b/src/main/java/dev/coms4156/project/metadetect/service/LogisticRegressionService.java @@ -0,0 +1,76 @@ +package dev.coms4156.project.metadetect.service; + +import dev.coms4156.project.metadetect.c2pa.C2paToolInvoker.C2paMetadata; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Performs logistic regression inference against the feature vector produced by + * {@link FeatureExtractor}. The model weights and bias are loaded once from JSON via + * {@link ModelLoader}. + */ +@Service +public class LogisticRegressionService { + + private static final Logger log = LoggerFactory.getLogger(LogisticRegressionService.class); + + private final FeatureExtractor featureExtractor; + private final ModelLoader modelLoader; + + public LogisticRegressionService(FeatureExtractor featureExtractor, ModelLoader modelLoader) { + this.featureExtractor = featureExtractor; + this.modelLoader = modelLoader; + } + + /** + * Generates an AI confidence score for the given image. + * + * @param imagePath path to the downloaded image on disk + * @param c2pa pre-extracted C2PA metadata (never null in current pipeline) + * @return inference result containing the probability, c2pa usage flag, and model version + */ + public InferenceResult predict(String imagePath, C2paMetadata c2pa) { + ModelLoader.ModelParameters model = modelLoader.loadModel(); + double[] features = featureExtractor.extractAllFeatures(imagePath, c2pa); + double z = dot(model.weights(), features) + model.bias(); + double probability = sigmoid(z); + boolean c2paUsed = c2pa != null + && c2pa.getc2paHasManifest() == 1 + && c2pa.getc2paErrorFlag() == 0; + + return new InferenceResult(probability, c2paUsed, model.version()); + } + + /** Returns the loaded model version to surface in responses. */ + public String getModelVersion() { + return modelLoader.loadModel().version(); + } + + private double dot(double[] weights, double[] features) { + int len = Math.min(weights.length, features.length); + if (weights.length != features.length) { + log.warn("Model/feature length mismatch (w={}, f={}); truncating to {}", weights.length, + features.length, len); + } + + double sum = 0.0; + for (int i = 0; i < len; i++) { + sum += weights[i] * features[i]; + } + return sum; + } + + /** Stable sigmoid implementation to avoid overflow for large magnitudes. */ + private double sigmoid(double z) { + if (z >= 0) { + double exp = Math.exp(-z); + return 1.0 / (1.0 + exp); + } + double exp = Math.exp(z); + return exp / (1.0 + exp); + } + + /** Immutable inference result. */ + public record InferenceResult(double confidenceScore, boolean c2paUsed, String modelVersion) { } +} diff --git a/src/main/java/dev/coms4156/project/metadetect/service/ModelLoader.java b/src/main/java/dev/coms4156/project/metadetect/service/ModelLoader.java new file mode 100644 index 0000000..46f6494 --- /dev/null +++ b/src/main/java/dev/coms4156/project/metadetect/service/ModelLoader.java @@ -0,0 +1,104 @@ +package dev.coms4156.project.metadetect.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; + +/** + * Loads logistic regression model parameters (weights + bias) from a JSON file. + * The location is configurable via {@code metadetect.model.path} and defaults + * to {@code classpath:model/model.json}. The loader caches the parsed model to + * avoid repeated disk I/O. + */ +@Component +public class ModelLoader { + + private static final Logger log = LoggerFactory.getLogger(ModelLoader.class); + + private final ResourceLoader resourceLoader; + private final ObjectMapper objectMapper; + private final String modelLocation; + private volatile ModelParameters cached; + + /** + * Constructs a loader for logistic regression model parameters. + * + * @param resourceLoader resource resolver used to locate the model.json file + * @param objectMapper JSON mapper for parsing the weights/bias file + * @param modelLocation configurable location (e.g., classpath:model/model.json) + */ + public ModelLoader(ResourceLoader resourceLoader, + ObjectMapper objectMapper, + @Value("${metadetect.model.path:classpath:model/model.json}") + String modelLocation) { + this.resourceLoader = resourceLoader; + this.objectMapper = objectMapper; + this.modelLocation = modelLocation; + } + + /** + * Loads the configured model, caching the result for subsequent calls. + * + * @return immutable model parameters + */ + public ModelParameters loadModel() { + if (cached != null) { + return cached; + } + + synchronized (this) { + if (cached == null) { + cached = readModel(); + } + return cached; + } + } + + private ModelParameters readModel() { + Resource resource = resolve(modelLocation); + if (!resource.exists()) { + throw new IllegalStateException("Model file not found at " + modelLocation); + } + + try (InputStream is = resource.getInputStream()) { + ModelJson raw = objectMapper.readValue(is, ModelJson.class); + if (raw.weights == null || raw.weights.length == 0) { + throw new IllegalStateException("Model weights are missing or empty"); + } + if (Arrays.stream(raw.weights).anyMatch(d -> Double.isNaN(d) || Double.isInfinite(d))) { + throw new IllegalStateException("Model weights contain invalid values"); + } + double bias = Double.isNaN(raw.bias) || Double.isInfinite(raw.bias) ? 0.0 : raw.bias; + String version = raw.version == null || raw.version.isBlank() ? "v1" : raw.version; + log.info("Loaded logistic regression model: {} ({} weights)", version, raw.weights.length); + return new ModelParameters(raw.weights, bias, version); + } catch (IOException ioe) { + throw new IllegalStateException("Failed to read model from " + modelLocation, ioe); + } + } + + private Resource resolve(String location) { + if (location.startsWith("classpath:") || location.startsWith("file:")) { + return resourceLoader.getResource(location); + } + // Default to file: for bare paths + return resourceLoader.getResource("file:" + location); + } + + /** Immutable holder for model parameters. */ + public record ModelParameters(double[] weights, double bias, String version) { } + + /** Internal mapping of the JSON schema. */ + private static final class ModelJson { + public double[] weights; + public double bias; + public String version; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 68a82b0..3272031 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -36,6 +36,7 @@ metadetect.supabase.jwtSecret=${SUPABASE_JWT_SECRET} metadetect.supabase.storageBucket=metadetect-images metadetect.supabase.signedUrlTtlSeconds=900 +metadetect.model.path=classpath:model/model.json # allow up to 25 MB (pick your size) spring.servlet.multipart.max-file-size=25MB @@ -45,4 +46,3 @@ spring.servlet.multipart.max-request-size=25MB server.tomcat.max-swallow-size=-1 - diff --git a/src/main/resources/model/model.json b/src/main/resources/model/model.json new file mode 100644 index 0000000..62bf7e7 --- /dev/null +++ b/src/main/resources/model/model.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "weights": [0.18, -0.07, 0.14, 0.05, 0.09, -0.02, -0.03, 0.12, 0.25, 0.12, 0.35, -0.2], + "bias": -0.3 +} diff --git a/src/test/java/dev/coms4156/project/metadetect/controller/AnalyzeControllerTest.java b/src/test/java/dev/coms4156/project/metadetect/controller/AnalyzeControllerTest.java index 56af358..73496ed 100644 --- a/src/test/java/dev/coms4156/project/metadetect/controller/AnalyzeControllerTest.java +++ b/src/test/java/dev/coms4156/project/metadetect/controller/AnalyzeControllerTest.java @@ -50,7 +50,7 @@ void submit_returnsAcceptedWithBody() throws Exception { void getStatus_returnsDtoFromService() throws Exception { UUID analysisId = UUID.randomUUID(); var response = - new Dtos.AnalyzeConfidenceResponse(analysisId.toString(), "COMPLETED", 0.97d); + new Dtos.AnalyzeConfidenceResponse(analysisId.toString(), "COMPLETED", 0.97d, true, "v1"); when(analyzeService.getConfidence(analysisId)).thenReturn(response); mvc.perform(MockMvcRequestBuilders.get("/api/analyze/" + analysisId)) @@ -58,7 +58,9 @@ void getStatus_returnsDtoFromService() throws Exception { .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.analysisId").value(analysisId.toString())) .andExpect(jsonPath("$.status").value("COMPLETED")) - .andExpect(jsonPath("$.score").value(0.97d)); + .andExpect(jsonPath("$.confidenceScore").value(0.97d)) + .andExpect(jsonPath("$.c2paUsed").value(true)) + .andExpect(jsonPath("$.modelVersion").value("v1")); verify(analyzeService, times(1)).getConfidence(analysisId); } diff --git a/src/test/java/dev/coms4156/project/metadetect/service/AnalyzeServiceTest.java b/src/test/java/dev/coms4156/project/metadetect/service/AnalyzeServiceTest.java index c477d65..bb582f8 100644 --- a/src/test/java/dev/coms4156/project/metadetect/service/AnalyzeServiceTest.java +++ b/src/test/java/dev/coms4156/project/metadetect/service/AnalyzeServiceTest.java @@ -20,6 +20,7 @@ import dev.coms4156.project.metadetect.model.AnalysisReport; import dev.coms4156.project.metadetect.model.Image; import dev.coms4156.project.metadetect.repository.AnalysisReportRepository; +import dev.coms4156.project.metadetect.service.LogisticRegressionService.InferenceResult; import dev.coms4156.project.metadetect.service.SupabaseStorageService; import dev.coms4156.project.metadetect.service.errors.ForbiddenException; import dev.coms4156.project.metadetect.service.errors.MissingStoragePathException; @@ -52,6 +53,7 @@ class AnalyzeServiceTest { private AnalysisReportRepository repo; private SupabaseStorageService storage; private UserService userService; + private LogisticRegressionService logisticRegressionService; private Clock clock; private AnalyzeService service; @@ -67,13 +69,23 @@ void setUp() { repo = mock(AnalysisReportRepository.class); storage = mock(SupabaseStorageService.class); userService = mock(UserService.class); + logisticRegressionService = mock(LogisticRegressionService.class); clock = Clock.fixed(fixedNow, ZoneOffset.UTC); - service = new AnalyzeService(c2pa, imageService, repo, storage, userService, clock); + service = new AnalyzeService( + c2pa, + imageService, + repo, + storage, + userService, + logisticRegressionService, + clock + ); when(userService.getCurrentUserIdOrThrow()).thenReturn(userId); // Bearer required by storage for signed URL generation. when(userService.getCurrentBearerOrThrow()).thenReturn("bearer-token"); + when(logisticRegressionService.getModelVersion()).thenReturn("v1"); } /** Creates an owned Image with the provided storage path. */ @@ -102,8 +114,11 @@ void submitAnalysis_happyPath_marksCompleted_andReturnsId() throws Exception { when(storage.createSignedUrl(eq("u/i/file.png"), anyString())) .thenReturn(downloadable.toURI().toURL().toString()); - String manifest = "{\"c2pa\":\"ok\"}"; - when(c2pa.extractManifest(any(File.class))).thenReturn(manifest); + C2paToolInvoker.C2paMetadata metadata = + new C2paToolInvoker.C2paMetadata(1, 1, "gen", 0, 0, null); + when(c2pa.extractMetadata(any(File.class))).thenReturn(metadata); + when(logisticRegressionService.predict(anyString(), any())) + .thenReturn(new InferenceResult(0.73, true, "v1")); UUID analysisId = UUID.randomUUID(); AnalysisReport pending = new AnalysisReport(imageId); @@ -125,7 +140,8 @@ void submitAnalysis_happyPath_marksCompleted_andReturnsId() throws Exception { verify(repo, atLeast(1)).save(saved.capture()); AnalysisReport last = saved.getAllValues().get(saved.getAllValues().size() - 1); assertThat(last.getStatus().name()).isEqualTo("DONE"); - assertThat(last.getDetails()).isEqualTo(manifest); + assertThat(last.getDetails()).contains("\"c2paHasManifest\":1"); + assertThat(last.getConfidence()).isEqualTo(0.73); downloadable.delete(); } @@ -185,7 +201,7 @@ void submitAnalysis_c2paFailure_marksFailed() throws Exception { when(storage.createSignedUrl(eq("a/b/c.png"), anyString())) .thenReturn(downloadable.toURI().toURL().toString()); - when(c2pa.extractManifest(any(File.class))).thenThrow(new RuntimeException("boom")); + when(c2pa.extractMetadata(any(File.class))).thenThrow(new RuntimeException("boom")); UUID analysisId = UUID.randomUUID(); AnalysisReport pending = new AnalysisReport(imageId); @@ -264,6 +280,7 @@ void getConfidence_success_returnsStatusAndScore_andChecksOwnership() { report.setId(analysisId); report.setStatus(AnalysisReport.ReportStatus.PENDING); report.setConfidence(null); + report.setDetails("{\"c2paHasManifest\":1,\"c2paErrorFlag\":0}"); when(repo.findById(analysisId)).thenReturn(Optional.of(report)); when(imageService.getById(userId, imageId)).thenReturn(ownedImage("x")); @@ -271,7 +288,9 @@ void getConfidence_success_returnsStatusAndScore_andChecksOwnership() { Dtos.AnalyzeConfidenceResponse out = service.getConfidence(analysisId); assertThat(out.analysisId()).isEqualTo(analysisId.toString()); assertThat(out.status()).isEqualTo("PENDING"); - assertThat(out.score()).isNull(); + assertThat(out.confidenceScore()).isNull(); + assertThat(out.c2paUsed()).isTrue(); + assertThat(out.modelVersion()).isEqualTo("v1"); } /** From 69682fa9535d7f84b7346b2be546e09d88a054c2 Mon Sep 17 00:00:00 2001 From: Jalen Stephens <108702328+Jalen-Stephens@users.noreply.github.com> Date: Mon, 1 Dec 2025 01:01:01 -0500 Subject: [PATCH 2/6] UI Chnages to take in account for confidence score generation --- src/main/resources/static/compose.css | 37 ++++++++++++++++++ src/main/resources/static/compose.js | 56 ++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/main/resources/static/compose.css b/src/main/resources/static/compose.css index 3721069..f675436 100644 --- a/src/main/resources/static/compose.css +++ b/src/main/resources/static/compose.css @@ -250,6 +250,43 @@ button.primary:not(:disabled):hover { gap: 8px; } +.ai-flags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + font-size: 0.78rem; + letter-spacing: 0.02em; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.pill.ai-strong { + background: linear-gradient(135deg, rgba(236, 72, 153, 0.25), rgba(124, 58, 237, 0.25)); + border-color: rgba(236, 72, 153, 0.55); + color: #ffe4f5; +} + +.pill.ai-soft { + background: rgba(56, 189, 248, 0.14); + border-color: rgba(56, 189, 248, 0.5); + color: #dbeafe; +} + +.pill.score { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.2); + color: #e5e7eb; + font-weight: 600; +} + .post-body .timestamp { font-size: 0.8rem; color: var(--muted); diff --git a/src/main/resources/static/compose.js b/src/main/resources/static/compose.js index 34dfdb0..1ce17d1 100644 --- a/src/main/resources/static/compose.js +++ b/src/main/resources/static/compose.js @@ -85,6 +85,26 @@ return data; }; + const buildAiBadges = (image) => { + const wrap = document.createElement('div'); + wrap.className = 'ai-flags'; + + if (typeof image.confidenceScore === 'number') { + const scorePill = document.createElement('span'); + scorePill.className = 'pill score'; + scorePill.textContent = `Score: ${image.confidenceScore.toFixed(2)}`; + wrap.appendChild(scorePill); + } + + if (image.aiTag) { + const tagPill = document.createElement('span'); + tagPill.className = `pill ${image.aiTagClass || 'ai'}`; + tagPill.textContent = image.aiTag; + wrap.appendChild(tagPill); + } + return wrap; + }; + const buildCard = (image) => { const card = document.createElement('article'); card.className = 'post-card'; @@ -97,10 +117,11 @@ img.src = image.signedUrl || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="400" height="300"/%3E'; media.appendChild(img); - if (image.isAiGenerated) { + // Overlay banner only for strong AI cases + if (image.aiTagClass === 'ai-strong') { const banner = document.createElement('span'); banner.className = 'ai-banner'; - banner.textContent = 'AI generated'; + banner.textContent = image.aiTag || 'AI generated'; media.appendChild(banner); } @@ -115,6 +136,8 @@ caption.className = 'caption'; caption.textContent = image.note || 'No caption yet.'; + const aiFlags = buildAiBadges(image); + const labelsWrap = document.createElement('div'); labelsWrap.className = 'labels'; if (Array.isArray(image.labels) && image.labels.length) { @@ -134,7 +157,7 @@ del.addEventListener('click', () => deleteImage(image.id)); actions.appendChild(del); - body.append(ts, caption, labelsWrap, actions); + body.append(ts, caption, aiFlags, labelsWrap, actions); card.append(media, body); return card; }; @@ -173,9 +196,30 @@ const result = await requestJson(`/api/analyze/${analysisId}`); if (result?.status && result.status !== 'PENDING') { const statusString = result.status.toUpperCase(); - const aiDetected = statusString === 'DONE' - || (typeof result.score === 'number' && result.score > 0.5); - return { isAiGenerated: aiDetected }; + const score = typeof result.confidenceScore === 'number' + ? result.confidenceScore + : (typeof result.score === 'number' ? result.score : null); + + let aiTag = null; + let aiTagClass = null; + const isDone = statusString === 'DONE'; + if (typeof score === 'number') { + if (score >= 0.75) { + aiTag = 'AI generated'; + aiTagClass = 'ai-strong'; + } else if (score >= 0.5) { + aiTag = 'Likely AI generated'; + aiTagClass = 'ai-soft'; + } + } + + const aiDetected = isDone && ((typeof score === 'number' && score >= 0.5) || statusString === 'DONE'); + return { + isAiGenerated: aiDetected, + confidenceScore: typeof score === 'number' ? Math.min(Math.max(score, 0), 1) : null, + aiTag, + aiTagClass + }; } if (attempt >= 3) { return { isAiGenerated: false }; From 8d68c248f1d1376916a5e0679d687ccc70bd6ffa Mon Sep 17 00:00:00 2001 From: Jalen Stephens <108702328+Jalen-Stephens@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:28:12 -0500 Subject: [PATCH 3/6] test: add coverage for model loader and logistic regression service --- citations.md | 44 +++++++++ .../metadetect/config/SecurityConfig.java | 2 +- .../service/AnalyzeServiceTest.java | 2 + .../LogisticRegressionServiceTest.java | 93 +++++++++++++++++++ .../metadetect/service/ModelLoaderTest.java | 85 +++++++++++++++++ src/test/resources/model/test-model.json | 5 + 6 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/test/java/dev/coms4156/project/metadetect/service/LogisticRegressionServiceTest.java create mode 100644 src/test/java/dev/coms4156/project/metadetect/service/ModelLoaderTest.java create mode 100644 src/test/resources/model/test-model.json diff --git a/citations.md b/citations.md index abaa048..1c5bd63 100644 --- a/citations.md +++ b/citations.md @@ -1,3 +1,47 @@ +### **Commit / Ticket Reference** +- **Commit:** test: add coverage for model loader and logistic regression service +- **Ticket:** none +- **Date:** 2026-02-17 +- **Team Member:** Jalen Stephens + +--- + +### **AI Tool Information** +- **Tool Used:** OpenAI ChatGPT (GPT-5) via Codex CLI +- **Access Method:** Local Codex CLI (sandboxed; no paid API calls) +- **Configuration:** Default model settings +- **Cost:** $0 (course-provided access) + +--- + +### **Purpose of AI Assistance** +Added unit tests to raise branch/instruction coverage for model loading and logistic regression inference, including cache validation, path resolution, invalid weight handling, C2PA flag behavior, and sigmoid branches. + +--- + +### **Prompts / Interaction Summary** +- “give me a commit message and fill out a template in citations.md for the work we did” +- “can you write test for these to increase branch and instruction coverage please” + +--- + +### **Resulting Artifacts** +- `src/test/java/dev/coms4156/project/metadetect/service/ModelLoaderTest.java` +- `src/test/java/dev/coms4156/project/metadetect/service/LogisticRegressionServiceTest.java` +- `src/test/resources/model/test-model.json` + +--- + +### **Verification** +- `./mvnw -q -Dtest=ModelLoaderTest,LogisticRegressionServiceTest test` + +--- + +### **Attribution Statement** +> Portions of this work were generated with assistance from OpenAI ChatGPT (GPT-5) on 2026-02-17. All AI-generated content was reviewed and finalized by the development team. + +--- + ### **Commit / Ticket Reference** - **Commit:** fix(storage): encode Supabase paths and normalize project base URL - **Ticket:** N/A (prod bugfix) diff --git a/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java b/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java index c387fae..8b8cbd6 100644 --- a/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java +++ b/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java @@ -19,8 +19,8 @@ import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; diff --git a/src/test/java/dev/coms4156/project/metadetect/service/AnalyzeServiceTest.java b/src/test/java/dev/coms4156/project/metadetect/service/AnalyzeServiceTest.java index 7748ebc..69f21ec 100644 --- a/src/test/java/dev/coms4156/project/metadetect/service/AnalyzeServiceTest.java +++ b/src/test/java/dev/coms4156/project/metadetect/service/AnalyzeServiceTest.java @@ -107,6 +107,8 @@ void runExtractionAndFinalize_success_marksCompleted() throws Exception { var metadata = new C2paToolInvoker.C2paMetadata(1, 1, "gen", 0, 0, null); when(c2pa.extractMetadata(any(File.class))).thenReturn(metadata); + when(logisticRegressionService.predict(anyString(), any())) + .thenReturn(new InferenceResult(0.42, true, "v1")); callPrivate( service, diff --git a/src/test/java/dev/coms4156/project/metadetect/service/LogisticRegressionServiceTest.java b/src/test/java/dev/coms4156/project/metadetect/service/LogisticRegressionServiceTest.java new file mode 100644 index 0000000..f795bcb --- /dev/null +++ b/src/test/java/dev/coms4156/project/metadetect/service/LogisticRegressionServiceTest.java @@ -0,0 +1,93 @@ +package dev.coms4156.project.metadetect.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +import dev.coms4156.project.metadetect.c2pa.C2paToolInvoker; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LogisticRegressionServiceTest { + + private FeatureExtractor featureExtractor; + private ModelLoader modelLoader; + private LogisticRegressionService service; + + @BeforeEach + void setup() { + featureExtractor = mock(FeatureExtractor.class); + modelLoader = mock(ModelLoader.class); + service = new LogisticRegressionService(featureExtractor, modelLoader); + reset(featureExtractor, modelLoader); + } + + @Test + void predict_happyPath_usesFeaturesAndModelAndFlagsC2pa() { + when(modelLoader.loadModel()).thenReturn( + new ModelLoader.ModelParameters(new double[] {0.5, 0.5}, 0.0, "v42") + ); + when(featureExtractor.extractAllFeatures(eq("img.jpg"), any())) + .thenReturn(new double[] {1.0, 2.0}); + + var c2pa = new C2paToolInvoker.C2paMetadata(1, 1, "gen", 0, 0, null); + + LogisticRegressionService.InferenceResult result = service.predict("img.jpg", c2pa); + + assertThat(result.modelVersion()).isEqualTo("v42"); + assertThat(result.c2paUsed()).isTrue(); + assertThat(result.confidenceScore()).isCloseTo(0.8176, within(1e-3)); + } + + @Test + void predict_truncatesWhenWeightsAndFeaturesDiffer_andMarksC2paFalseOnErrorFlag() { + when(modelLoader.loadModel()).thenReturn( + new ModelLoader.ModelParameters(new double[] {2.0, 2.0, 5.0}, -1.0, "v2") + ); + when(featureExtractor.extractAllFeatures(eq("img2.jpg"), any())) + .thenReturn(new double[] {1.0, 1.0}); + + var c2pa = new C2paToolInvoker.C2paMetadata(1, 1, "gen", 0, 1, "err"); + + LogisticRegressionService.InferenceResult result = service.predict("img2.jpg", c2pa); + + assertThat(result.c2paUsed()).isFalse(); // error flag disables + assertThat(result.modelVersion()).isEqualTo("v2"); + // dot = (2*1)+(2*1)=4, bias=-1 -> z=3 => sigmoid ~0.9526 + assertThat(result.confidenceScore()).isCloseTo(0.9526, within(1e-3)); + } + + @Test + void predict_handlesNegativeZ_viaSigmoidLowerBranch() { + when(modelLoader.loadModel()).thenReturn( + new ModelLoader.ModelParameters(new double[] {-10.0}, 0.0, "v-neg") + ); + when(featureExtractor.extractAllFeatures(eq("img3.jpg"), any())) + .thenReturn(new double[] {1.0}); + + LogisticRegressionService.InferenceResult result = service.predict( + "img3.jpg", + new C2paToolInvoker.C2paMetadata(0, 0, null, 0, 0, null) + ); + + assertThat(result.c2paUsed()).isFalse(); + assertThat(result.confidenceScore()).isCloseTo(0.0000454, within(1e-6)); + assertThat(result.modelVersion()).isEqualTo("v-neg"); + } + + @Test + void getModelVersion_delegatesToModelLoader() { + when(modelLoader.loadModel()).thenReturn( + new ModelLoader.ModelParameters(new double[] {1.0}, 0.0, "v-get") + ); + + assertThat(service.getModelVersion()).isEqualTo("v-get"); + } + + private static org.assertj.core.data.Offset within(double tolerance) { + return org.assertj.core.data.Offset.offset(tolerance); + } +} diff --git a/src/test/java/dev/coms4156/project/metadetect/service/ModelLoaderTest.java b/src/test/java/dev/coms4156/project/metadetect/service/ModelLoaderTest.java new file mode 100644 index 0000000..bc619db --- /dev/null +++ b/src/test/java/dev/coms4156/project/metadetect/service/ModelLoaderTest.java @@ -0,0 +1,85 @@ +package dev.coms4156.project.metadetect.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.DefaultResourceLoader; + +class ModelLoaderTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final DefaultResourceLoader resourceLoader = new DefaultResourceLoader(); + + @Test + void loadModel_fromClasspath_parsesAndCaches() { + ModelLoader loader = new ModelLoader( + resourceLoader, + mapper, + "classpath:model/test-model.json" + ); + + ModelLoader.ModelParameters first = loader.loadModel(); + ModelLoader.ModelParameters second = loader.loadModel(); + + assertThat(first).isSameAs(second); + assertThat(first.weights()).containsExactly(0.1, 0.2, 0.3); + assertThat(first.bias()).isEqualTo(0.5); + assertThat(first.version()).isEqualTo("v-test"); + } + + @Test + void loadModel_withBareFilePath_defaultsToFilePrefixAndNormalizesBiasAndVersion() + throws IOException { + Path tmp = Files.createTempFile("model-", ".json"); + Files.writeString(tmp, """ + {"weights":[0.7],"bias":1e309,"version":" "} + """); + + ModelLoader loader = new ModelLoader( + resourceLoader, + mapper, + tmp.toAbsolutePath().toString() + ); + + ModelLoader.ModelParameters params = loader.loadModel(); + assertThat(params.weights()).containsExactly(0.7); + assertThat(params.bias()).isZero(); // NaN bias coerced to 0.0 + assertThat(params.version()).isEqualTo("v1"); // blank version defaults + } + + @Test + void loadModel_missingFile_throwsIllegalState() { + ModelLoader loader = new ModelLoader( + resourceLoader, + mapper, + "/does/not/exist/model.json" + ); + + assertThatThrownBy(loader::loadModel) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Model file not found"); + } + + @Test + void loadModel_invalidWeights_throwsIllegalState() throws IOException { + Path tmp = Files.createTempFile("model-invalid-", ".json"); + Files.writeString(tmp, """ + {"weights":[1.0, 1e309],"bias":0.0,"version":"vbad"} + """); + + ModelLoader loader = new ModelLoader( + resourceLoader, + mapper, + "file:" + tmp.toAbsolutePath() + ); + + assertThatThrownBy(loader::loadModel) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("invalid values"); + } +} diff --git a/src/test/resources/model/test-model.json b/src/test/resources/model/test-model.json new file mode 100644 index 0000000..af6ceca --- /dev/null +++ b/src/test/resources/model/test-model.json @@ -0,0 +1,5 @@ +{ + "weights": [0.1, 0.2, 0.3], + "bias": 0.5, + "version": "v-test" +} From e3a845222d0d56cc355dafc1dfb6b2066c96dd81 Mon Sep 17 00:00:00 2001 From: Jalen Stephens <108702328+Jalen-Stephens@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:42:42 -0500 Subject: [PATCH 4/6] fixed security for swagger ui and added service to feature for compile issue --- .../project/metadetect/config/SecurityConfig.java | 8 +++++++- .../project/metadetect/service/FeatureExtractor.java | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java b/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java index 8b8cbd6..1a4b792 100644 --- a/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java +++ b/src/main/java/dev/coms4156/project/metadetect/config/SecurityConfig.java @@ -94,7 +94,13 @@ public SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exce "/js/**", "/images/**", "/fonts/**", - "/webjars/**" + "/webjars/**", + + // Swagger / OpenAPI docs + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**", + "/api-docs/**" ).permitAll() // Public non-API endpoints (health/auth pages used by tests + clients) diff --git a/src/main/java/dev/coms4156/project/metadetect/service/FeatureExtractor.java b/src/main/java/dev/coms4156/project/metadetect/service/FeatureExtractor.java index 5b2f65e..29ee7ed 100644 --- a/src/main/java/dev/coms4156/project/metadetect/service/FeatureExtractor.java +++ b/src/main/java/dev/coms4156/project/metadetect/service/FeatureExtractor.java @@ -13,6 +13,7 @@ import org.opencv.core.Size; import org.opencv.imgcodecs.Imgcodecs; import org.opencv.imgproc.Imgproc; +import org.springframework.stereotype.Service; /** @@ -29,6 +30,7 @@ * NOTE: C2PA metadata is obtained separately via C2paToolInvoker. This class does * not call C2PA directly, but is designed to combine its results into the final feature vector. */ +@Service public class FeatureExtractor { static { From 17b5eb5c605dd5052b919c065293d87826a55bb3 Mon Sep 17 00:00:00 2001 From: ikeschmack Date: Mon, 1 Dec 2025 18:59:42 -0500 Subject: [PATCH 5/6] [feat] added codehaus to pom.xml --- .DS_Store | Bin 6148 -> 10244 bytes pom.xml | 11 +++++++++++ 2 files changed, 11 insertions(+) diff --git a/.DS_Store b/.DS_Store index 185d147b194e3a6c5f6d6346e0bbdd7fff3fec51..aa62338e5b3ab1631b8998608eed44854efa7399 100644 GIT binary patch literal 10244 zcmeHM&x;gC6n-^3?)3VL2_#XGga$7`MAv9mB$siW1s4(zPA~_R-RYTOc1TaxFf+Ro zSQZBSaZvvh!GjxOF1hC9B?s{;cnJi_HK+K!s+!rV+8M#iYNQIPUU$9k^?UWz>uRc+ zh{WQ0{VY*TL=h^>;z2YM3hTM-v21RI7 zNNp<27DH%r^v4EQGIFS;HYZ^=AHsMRW``n_cRW8BA-=F)NthPEwhmFS>W@mkWpGAD3Kx{jK`W+RoX4cu|5W6-It(jn*l#y<=rL zwi{u`0PW}B)Km=KbJ}tzlIbWr9>hh|ho}MU27Gj2B`L*y7k52c^E|>*oU}X^=sQ}b%d|vSsX|LG zTKwzz$zcqshxuX>CK>cPW}HuxPTK1A=jWe`+)RGoY#}N{2clb*H94sCtNr!fVzqmN z?|zUnO-9$%L95;Xio$l+8w^o24>q;`tm?UcWyyaQV)$-p-k5gu{H-rsUWo zXT~{4+Ccw|)|*Y@%(BsQbPc(2iOTdnE+*l;-? z*7xi0m9cZV_O%Yjw^#Ad5yxJ>&#HB=i?y;reV2cg^EUqu){Xn+m+;`|?dYGp-X48z zJYbW#JR z?F^cXu({7||KoA*WfE&c2@C`V0t118z`$NGFr%HseEz>Q{r~@7gc5uN1_A@`i2;!> zSIUcUJ~~$L>^w^@xGotv)Dk@?d;T*Z W?Ee$c{(nB@3j6=Qy#sys{r?-4T_5ZK delta 217 zcmZn(XfcprU|?W$DortDU=RQ@Ie-{Mvv5sJ6q~50D9Q(t2aD-3&Aaau{2h>L{34SWccNY(Lpp+-YLkt;Osd9D>YX lLl^|OfwU{gbsH1EGf(DM@dUY*feGSnkXINs$Mei#1_0O(F2eu- diff --git a/pom.xml b/pom.xml index b65ebbf..21404ad 100644 --- a/pom.xml +++ b/pom.xml @@ -226,6 +226,17 @@ + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + false + + + + org.apache.maven.plugins From 611d45766540001060e04b3228be3a714beac72a Mon Sep 17 00:00:00 2001 From: ikeschmack Date: Mon, 1 Dec 2025 19:34:22 -0500 Subject: [PATCH 6/6] [feat] Complete implementation of logistic regression ML model for confidence score --- citations.md | 73 +++++++++++++++++++ .../metadetect/service/ModelLoader.java | 2 +- src/main/resources/model.json | 18 +++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/model.json diff --git a/citations.md b/citations.md index 1c5bd63..436fca7 100644 --- a/citations.md +++ b/citations.md @@ -3106,3 +3106,76 @@ Expanded CI coverage and optional live E2E hook: > Portions of this work were generated with assistance from OpenAI ChatGPT (GPT-5) on 2026-02-17. All AI-generated content was reviewed and finalized by the development team. --- + +### **Commit / Ticket Reference** +- **Commit:** [feat] Implemented and Trained Logistic Regression Model +- **Ticket:** (#66) Implementation of Logistic Regression ML Model for Confidence Score +- **Date:** 12/1/2025 +- **Team Member:** Isaac Schmidt + +--- + +### **AI Tool Information** +- **Tool Used:** OpenAI ChatGPT (GPT-5.1) +- **Access Method:** ChatGPT Web (.edu academic access) +- **Configuration:** Default model settings +- **Cost:** $0 (no paid API calls) + +--- + +### **Purpose of AI Assistance** +AI assistance was used to design, structure, and validate the machine-learning component of the MetaDetect system. This included help with: +- Creating a feature extraction–based ML pipeline for AI-image detection +- Designing the workflow for offline model training (without including Python code in the repository) +- Advising on the correct model type, dataset preparation, cross-validation strategy, and model export +- Generating the Java inference architecture (ModelLoader, LogisticRegressionModel, AnalyzeService integration) +- Debugging dataset preparation issues and ensuring compatibility between training-time features and runtime inference + +--- + +### **Prompts / Interaction Summary** +Key interactions included: +- Requesting recommendations for ML models appropriate for OpenCV feature vectors +- Asking how to train a logistic regression model offline and export weights for Java inference +- Debugging DatasetBuilder and CSV formatting issues to generate valid ML training data +- Setting up cross-validation for model evaluation +- Requesting a final AnalyzeService integration that correctly combines C2PA overrides with ML fallback +- Asking how and where `model.json` should be loaded in the service layer +- Requesting fixes and refactoring for ModelLoader, LogisticRegressionService, and FeatureExtractor interactions +- Clarifying model runtime behavior, including how C2PA features interact with ML predictions + +--- + +### **Resulting Artifacts** +The following deliverables were created or refined with AI assistance: +- **DatasetBuilder.java** — Generates ML-ready feature CSVs from raw images and metadata +- **train_model.py (offline use only)** — Script used externally to train the logistic regression model +- **export_model.py (offline use only)** — Exports trained LR weights to a Java-readable `model.json` +- **model.json** — Serialized logistic regression weights and bias used in production +- **LogisticRegressionModel.java** — Runtime inference implementation compatible with exported weights +- **ModelLoader.java** — Loads `model.json` from classpath and constructs the inference model +- **LogisticRegressionService.java** — Bridges feature extraction and ML prediction +- **Updated AnalyzeService.java** — Integrates C2PA logic + ML fallback with clear override hierarchy +- Various debugging utilities, architectural recommendations, and corrections to CSV parsing logic + +--- + +### **Verification** +AI-assisted work was validated by: +- Manual inspection and testing of DatasetBuilder output +- Successful cross-validation runs on ~80,000 training samples +- Confirming stable and consistent LR validation metrics across folds +- Verifying that exported weights from Python produced correct inference behavior in Java +- Manually testing AnalyzeService end-to-end with multiple categories of images: + - Images with valid AI manifests + - Images with valid camera manifests + - Images with no C2PA manifest + - Images with corrupted or tampered manifests +- Ensuring the Java inference pipeline correctly loads model.json from classpath and returns deterministic probability scores + +--- + +### **Attribution Statement** +> Portions of this commit or configuration were generated with assistance from OpenAI ChatGPT (GPT-5) on 12/1/2025. All AI-generated content was reviewed, verified, and finalized by the development team. + +--- diff --git a/src/main/java/dev/coms4156/project/metadetect/service/ModelLoader.java b/src/main/java/dev/coms4156/project/metadetect/service/ModelLoader.java index 46f6494..ba47197 100644 --- a/src/main/java/dev/coms4156/project/metadetect/service/ModelLoader.java +++ b/src/main/java/dev/coms4156/project/metadetect/service/ModelLoader.java @@ -36,7 +36,7 @@ public class ModelLoader { */ public ModelLoader(ResourceLoader resourceLoader, ObjectMapper objectMapper, - @Value("${metadetect.model.path:classpath:model/model.json}") + @Value("${main.resources.path:classpath:/model.json}") String modelLocation) { this.resourceLoader = resourceLoader; this.objectMapper = objectMapper; diff --git a/src/main/resources/model.json b/src/main/resources/model.json new file mode 100644 index 0000000..5467124 --- /dev/null +++ b/src/main/resources/model.json @@ -0,0 +1,18 @@ +{ + "type": "logistic_regression", + "weights": [ + -0.0011461266328084906, + 0.9583668568941945, + -27.119188030942635, + 0.009850658339311183, + 0.23904156848789526, + 0.008135962914517462, + 0.002454576012358591, + -1.7857086451154074, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "bias": -13.851754449616177 +} \ No newline at end of file