diff --git a/bin/.DS_Store b/bin/.DS_Store
index 185d147..aa62338 100644
Binary files a/bin/.DS_Store and b/bin/.DS_Store differ
diff --git a/citations.md b/citations.md
index abaa048..436fca7 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)
@@ -3062,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/pom.xml b/pom.xml
index be2b7ec..add7ac1 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
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..1a4b792 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;
@@ -19,6 +20,7 @@
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
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;
@@ -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();
@@ -90,12 +94,27 @@ 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)
+ .requestMatchers(
+ "/health",
+ "/actuator/**",
+ "/auth/**"
).permitAll()
- // Everything else (non-API) is allowed
- .anyRequest().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 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 {
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..ba47197
--- /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("${main.resources.path:classpath:/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.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
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/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 };
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 3475c68..f1d4d06 100644
--- a/src/test/java/dev/coms4156/project/metadetect/controller/AnalyzeControllerTest.java
+++ b/src/test/java/dev/coms4156/project/metadetect/controller/AnalyzeControllerTest.java
@@ -51,7 +51,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))
@@ -59,7 +59,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 1248dc7..69f21ec 100644
--- a/src/test/java/dev/coms4156/project/metadetect/service/AnalyzeServiceTest.java
+++ b/src/test/java/dev/coms4156/project/metadetect/service/AnalyzeServiceTest.java
@@ -19,6 +19,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;
@@ -53,6 +54,7 @@ class AnalyzeServiceTest {
private AnalysisReportRepository repo;
private SupabaseStorageService storage;
private UserService userService;
+ private LogisticRegressionService logisticRegressionService;
private Clock clock;
private AnalyzeService service;
@@ -70,13 +72,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");
}
@Test
@@ -95,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,
@@ -148,6 +162,8 @@ void submitAnalysis_happyPath_marksCompleted_andReturnsId() throws Exception {
var metadata = new C2paToolInvoker.C2paMetadata(
1, 2, "midjourney", 1, 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);
@@ -170,6 +186,7 @@ void submitAnalysis_happyPath_marksCompleted_andReturnsId() throws Exception {
AnalysisReport last = saved.getAllValues().get(saved.getAllValues().size() - 1);
assertThat(last.getStatus().name()).isEqualTo("DONE");
assertThat(last.getDetails()).contains("\"c2paHasManifest\":1");
+ assertThat(last.getConfidence()).isEqualTo(0.73);
downloadable.delete();
}
@@ -314,6 +331,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"));
@@ -321,7 +339,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");
}
/**
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"
+}