ManualConsumeEngine.java
package org.egothor.methodatlas.ai;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Handles the consume phase of the manual AI workflow.
*
* <p>
* For each test class this engine looks for a response file
* {@code <fqcn>.response.txt} in the configured response directory. If the file
* is present its content is parsed as an AI classification result and the
* extracted suggestions are returned normally. If the file is absent an empty
* suggestion is returned, which results in blank AI columns for that class in
* the final CSV.
* </p>
*
* <p>
* This engine implements {@link AiSuggestionEngine} so it can be used as a
* drop-in replacement for network-based engines in the standard scan loop. The
* {@code classSource} parameter passed to {@link #suggestForClass} is ignored
* because the AI has already processed the source during the prepare phase.
* </p>
*
* <h2>Response file format</h2>
*
* <p>
* The response file may contain free-form text (for example the operator may
* have copied the AI response verbatim from the chat window). The engine
* extracts the first JSON object found in the file using
* {@link JsonText#extractFirstJsonObject} and deserializes it into an
* {@link AiClassSuggestion}. Any surrounding prose or formatting is silently
* discarded.
* </p>
*
* @see ManualPrepareEngine
* @see AiSuggestionEngine
* @see JsonText
*/
public final class ManualConsumeEngine implements AiSuggestionEngine {
private final Path responseDir;
private final ObjectMapper mapper;
/**
* Creates a new consume engine that reads response files from the given
* directory.
*
* @param responseDir path to the directory containing operator-saved response
* files
*/
public ManualConsumeEngine(Path responseDir) {
this.responseDir = responseDir;
this.mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
/**
* Returns AI classification results for the specified class by reading the
* corresponding response file.
*
* <p>
* The response file is looked up as {@code <fileStem>.response.txt} in the
* configured response directory, where {@code fileStem} is the dot-separated
* path identifier computed from the source file's location relative to the scan
* root (e.g. {@code module-a.src.test.java.com.acme.FooTest}). If the file does
* not exist an empty {@link AiClassSuggestion} is returned so the caller emits
* blank AI columns rather than failing.
* </p>
*
* @param fileStem dot-separated path stem used to locate the response file
* ({@code <fileStem>.response.txt})
* @param fqcn fully qualified class name; included in the returned
* suggestion for identification
* @param classSource ignored — the AI already saw the source during the
* prepare phase
* @param targetMethods ignored — method classification is read from the
* response file
* @return parsed and normalized suggestion, or an empty suggestion when no
* response file exists
* @throws AiSuggestionException if the response file exists but cannot be read
* or does not contain a valid JSON object
*/
@Override
public AiClassSuggestion suggestForClass(String fileStem, String fqcn, String classSource,
List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
Path responseFile = responseDir.resolve(fileStem + ".response.txt");
if (!Files.exists(responseFile)) {
return new AiClassSuggestion(fqcn, null, List.of(), null, List.of()); // fqcn used for className
}
try {
String responseText = Files.readString(responseFile, StandardCharsets.UTF_8);
String json = JsonText.extractFirstJsonObject(responseText);
AiClassSuggestion raw = mapper.readValue(json, AiClassSuggestion.class);
return normalize(raw);
} catch (IOException e) {
throw new AiSuggestionException("Failed to read response file: " + responseFile, e);
}
}
/**
* Normalizes a raw provider response into the application's internal result
* invariants.
*
* <p>
* Ensures that collection-valued fields are never {@code null} and removes
* malformed method entries that do not contain a valid method name.
* </p>
*
* @param input raw suggestion deserialized from the operator-saved response file
* @return normalized suggestion instance
*/
private static AiClassSuggestion normalize(AiClassSuggestion input) {
List<AiMethodSuggestion> methods = input.methods() == null ? List.of() : input.methods();
List<String> classTags = input.classTags() == null ? List.of() : input.classTags();
List<AiMethodSuggestion> normalizedMethods = methods.stream()
.filter(m -> m != null && m.methodName() != null && !m.methodName().isBlank())
.map(m -> new AiMethodSuggestion(m.methodName(), m.securityRelevant(),
m.displayName(), m.tags() == null ? List.of() : m.tags(), m.reason(), m.confidence()))
.toList();
return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags,
input.classReason(), normalizedMethods);
}
}