| 1 | package org.egothor.methodatlas.ai; | |
| 2 | ||
| 3 | import java.net.URI; | |
| 4 | import java.net.http.HttpRequest; | |
| 5 | import java.util.List; | |
| 6 | ||
| 7 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | |
| 8 | import com.fasterxml.jackson.annotation.JsonProperty; | |
| 9 | ||
| 10 | /** | |
| 11 | * {@link AiProviderClient} implementation for the Anthropic API. | |
| 12 | * | |
| 13 | * <p> | |
| 14 | * This client submits classification requests to the Anthropic | |
| 15 | * <a href="https://docs.anthropic.com/">Claude API</a> and converts the | |
| 16 | * returned response into the internal {@link AiClassSuggestion} model used by | |
| 17 | * the MethodAtlas AI subsystem. | |
| 18 | * </p> | |
| 19 | * | |
| 20 | * <h2>Operational Responsibilities</h2> | |
| 21 | * | |
| 22 | * <ul> | |
| 23 | * <li>constructing Anthropic message API requests</li> | |
| 24 | * <li>injecting the taxonomy-driven classification prompt</li> | |
| 25 | * <li>performing authenticated HTTP calls to the Anthropic service</li> | |
| 26 | * <li>extracting the JSON result embedded in the model response</li> | |
| 27 | * <li>normalizing the result into {@link AiClassSuggestion}</li> | |
| 28 | * </ul> | |
| 29 | * | |
| 30 | * <p> | |
| 31 | * The client uses the {@code /v1/messages} endpoint and relies on the Claude | |
| 32 | * message format, where a system prompt defines classification rules and the | |
| 33 | * user message contains the class source together with the taxonomy | |
| 34 | * specification. | |
| 35 | * </p> | |
| 36 | * | |
| 37 | * <p> | |
| 38 | * Instances of this class are typically created by | |
| 39 | * {@link AiProviderFactory#create(AiOptions)}. | |
| 40 | * </p> | |
| 41 | * | |
| 42 | * <p> | |
| 43 | * This implementation is stateless apart from immutable configuration and is | |
| 44 | * therefore safe for reuse across multiple requests. | |
| 45 | * </p> | |
| 46 | * | |
| 47 | * @see AiProviderClient | |
| 48 | * @see AiSuggestionEngine | |
| 49 | * @see AiProviderFactory | |
| 50 | */ | |
| 51 | public final class AnthropicClient implements AiProviderClient { | |
| 52 | /** | |
| 53 | * System prompt used to instruct the model to return strictly formatted JSON | |
| 54 | * responses suitable for automated parsing. | |
| 55 | * | |
| 56 | * <p> | |
| 57 | * The prompt enforces deterministic output behavior and prevents the model from | |
| 58 | * returning explanations, markdown formatting, or conversational responses that | |
| 59 | * would break the JSON extraction pipeline. | |
| 60 | * </p> | |
| 61 | */ | |
| 62 | private static final String SYSTEM_PROMPT = """ | |
| 63 | You are a precise software security classification engine. | |
| 64 | You classify JUnit 5 tests and return strict JSON only. | |
| 65 | Never include markdown fences, explanations, or extra text. | |
| 66 | """; | |
| 67 | ||
| 68 | private final AiOptions options; | |
| 69 | private final HttpSupport httpSupport; | |
| 70 | ||
| 71 | /** | |
| 72 | * Creates a new Anthropic client with no rate-limit notification. | |
| 73 | * | |
| 74 | * <p>Rate-limit pauses caused by HTTP 429 responses are handled | |
| 75 | * transparently. Use | |
| 76 | * {@link #AnthropicClient(AiOptions, RateLimitListener)} when callers | |
| 77 | * need to be notified of such pauses.</p> | |
| 78 | * | |
| 79 | * @param options AI runtime configuration | |
| 80 | */ | |
| 81 | public AnthropicClient(AiOptions options) { | |
| 82 | this(options, (w, a, m) -> {}); | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * Creates a new Anthropic client that notifies {@code rateLimitListener} | |
| 87 | * before each rate-limit sleep. | |
| 88 | * | |
| 89 | * @param options AI runtime configuration | |
| 90 | * @param rateLimitListener callback invoked before each HTTP 429 | |
| 91 | * pause; must not be {@code null} | |
| 92 | * @see RateLimitListener | |
| 93 | */ | |
| 94 | public AnthropicClient(AiOptions options, RateLimitListener rateLimitListener) { | |
| 95 | this.options = options; | |
| 96 | this.httpSupport = new HttpSupport(options.timeout(), options.maxRetries(), rateLimitListener); | |
| 97 | } | |
| 98 | ||
| 99 | /** | |
| 100 | * Determines whether the Anthropic provider can be used in the current runtime | |
| 101 | * environment. | |
| 102 | * | |
| 103 | * <p> | |
| 104 | * The provider is considered available when a non-empty API key can be resolved | |
| 105 | * from {@link AiOptions#resolvedApiKey()}. | |
| 106 | * </p> | |
| 107 | * | |
| 108 | * @return {@code true} if a usable API key is configured | |
| 109 | */ | |
| 110 | @Override | |
| 111 | public boolean isAvailable() { | |
| 112 | String key = options.resolvedApiKey(); | |
| 113 |
5
1. isAvailable : removed conditional - replaced equality check with true → SURVIVED 2. isAvailable : replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::isAvailable → KILLED 3. isAvailable : removed conditional - replaced equality check with false → KILLED 4. isAvailable : removed conditional - replaced equality check with true → KILLED 5. isAvailable : removed conditional - replaced equality check with false → KILLED |
return key != null && !key.isBlank(); |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Submits a classification request to the Anthropic API for the specified test | |
| 118 | * class. | |
| 119 | * | |
| 120 | * <p> | |
| 121 | * The method constructs a message-based request containing: | |
| 122 | * </p> | |
| 123 | * | |
| 124 | * <ul> | |
| 125 | * <li>a system prompt enforcing deterministic JSON output</li> | |
| 126 | * <li>a user prompt containing the class source and taxonomy definition</li> | |
| 127 | * </ul> | |
| 128 | * | |
| 129 | * <p> | |
| 130 | * The response is parsed to extract the first JSON object returned by the | |
| 131 | * model, which is then deserialized into an {@link AiClassSuggestion}. | |
| 132 | * </p> | |
| 133 | * | |
| 134 | * @param fqcn fully qualified class name being analyzed | |
| 135 | * @param classSource complete source code of the class | |
| 136 | * @param taxonomyText taxonomy definition guiding classification | |
| 137 | * @param targetMethods deterministically extracted JUnit test methods that must | |
| 138 | * be classified | |
| 139 | * | |
| 140 | * @return normalized AI classification result | |
| 141 | * | |
| 142 | * @throws AiSuggestionException if the provider request fails, the response | |
| 143 | * cannot be parsed, or the provider returns | |
| 144 | * invalid content | |
| 145 | */ | |
| 146 | @Override | |
| 147 | public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText, | |
| 148 | List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException { | |
| 149 | try { | |
| 150 | String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods, options.confidence()); | |
| 151 | ||
| 152 | MessageRequest payload = new MessageRequest(options.modelName(), SYSTEM_PROMPT, | |
| 153 | List.of(new ContentMessage("user", List.of(new ContentBlock("text", prompt)))), 0.0, 2_000); | |
| 154 | ||
| 155 | String requestBody = httpSupport.objectMapper().writeValueAsString(payload); | |
| 156 | URI uri = URI.create(options.baseUrl() + "/v1/messages"); | |
| 157 | ||
| 158 | HttpRequest request = httpSupport.jsonPost(uri, requestBody, options.timeout()) | |
| 159 | .header("x-api-key", options.resolvedApiKey()).header("anthropic-version", "2023-06-01").build(); | |
| 160 | ||
| 161 | String responseBody = httpSupport.postJson(request); | |
| 162 | MessageResponse response = httpSupport.objectMapper().readValue(responseBody, MessageResponse.class); | |
| 163 | ||
| 164 |
4
1. suggestForClass : removed conditional - replaced equality check with false → KILLED 2. suggestForClass : removed conditional - replaced equality check with true → KILLED 3. suggestForClass : removed conditional - replaced equality check with true → KILLED 4. suggestForClass : removed conditional - replaced equality check with false → KILLED |
if (response.content() == null || response.content().isEmpty()) { |
| 165 | throw new AiSuggestionException("No content returned by Anthropic"); | |
| 166 | } | |
| 167 | ||
| 168 |
2
1. lambda$suggestForClass$1 : replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$1 → SURVIVED 2. lambda$suggestForClass$1 : replaced boolean return with false for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$1 → KILLED |
String text = response.content().stream().filter(block -> "text".equals(block.type())).map(ResponseBlock::text) |
| 169 |
5
1. lambda$suggestForClass$2 : removed conditional - replaced equality check with true → SURVIVED 2. lambda$suggestForClass$2 : removed conditional - replaced equality check with true → SURVIVED 3. lambda$suggestForClass$2 : replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$2 → SURVIVED 4. lambda$suggestForClass$2 : removed conditional - replaced equality check with false → KILLED 5. lambda$suggestForClass$2 : removed conditional - replaced equality check with false → KILLED |
.filter(value -> value != null && !value.isBlank()).findFirst() |
| 170 |
1
1. lambda$suggestForClass$3 : replaced return value with null for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$3 → KILLED |
.orElseThrow(() -> new AiSuggestionException("Anthropic returned no text block")); |
| 171 | ||
| 172 | String json = JsonText.extractFirstJsonObject(text); | |
| 173 | AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class); | |
| 174 |
1
1. suggestForClass : replaced return value with null for org/egothor/methodatlas/ai/AnthropicClient::suggestForClass → KILLED |
return normalize(suggestion); |
| 175 | ||
| 176 | } catch (Exception e) { // NOPMD | |
| 177 | throw new AiSuggestionException("Anthropic suggestion failed for " + fqcn, e); | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * Normalizes AI results returned by the provider. | |
| 183 | * | |
| 184 | * <p> | |
| 185 | * This method ensures that collection fields are never {@code null} and removes | |
| 186 | * malformed method entries that do not contain a valid method name. | |
| 187 | * </p> | |
| 188 | * | |
| 189 | * <p> | |
| 190 | * The normalization step protects the rest of the application from | |
| 191 | * provider-side inconsistencies and guarantees that the resulting | |
| 192 | * {@link AiClassSuggestion} object satisfies the expected invariants. | |
| 193 | * </p> | |
| 194 | * | |
| 195 | * @param input raw suggestion returned by the provider | |
| 196 | * @return normalized suggestion instance | |
| 197 | */ | |
| 198 | private static AiClassSuggestion normalize(AiClassSuggestion input) { | |
| 199 |
2
1. normalize : removed conditional - replaced equality check with false → SURVIVED 2. normalize : removed conditional - replaced equality check with true → KILLED |
List<AiMethodSuggestion> methods = input.methods() == null ? List.of() : input.methods(); |
| 200 |
2
1. normalize : removed conditional - replaced equality check with true → SURVIVED 2. normalize : removed conditional - replaced equality check with false → KILLED |
List<String> classTags = input.classTags() == null ? List.of() : input.classTags(); |
| 201 | ||
| 202 | List<AiMethodSuggestion> normalizedMethods = methods.stream() | |
| 203 |
7
1. lambda$normalize$4 : removed conditional - replaced equality check with true → SURVIVED 2. lambda$normalize$4 : removed conditional - replaced equality check with false → KILLED 3. lambda$normalize$4 : removed conditional - replaced equality check with true → KILLED 4. lambda$normalize$4 : removed conditional - replaced equality check with false → KILLED 5. lambda$normalize$4 : removed conditional - replaced equality check with true → KILLED 6. lambda$normalize$4 : removed conditional - replaced equality check with false → KILLED 7. lambda$normalize$4 : replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::lambda$normalize$4 → KILLED |
.filter(method -> method != null && method.methodName() != null && !method.methodName().isBlank()) |
| 204 |
1
1. lambda$normalize$5 : replaced return value with null for org/egothor/methodatlas/ai/AnthropicClient::lambda$normalize$5 → KILLED |
.map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(), |
| 205 |
2
1. lambda$normalize$5 : removed conditional - replaced equality check with true → SURVIVED 2. lambda$normalize$5 : removed conditional - replaced equality check with false → KILLED |
method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason(), |
| 206 | method.confidence(), method.interactionScore())) | |
| 207 | .toList(); | |
| 208 | ||
| 209 |
1
1. normalize : replaced return value with null for org/egothor/methodatlas/ai/AnthropicClient::normalize → KILLED |
return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(), |
| 210 | normalizedMethods); | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Request payload sent to the Anthropic message API. | |
| 215 | * | |
| 216 | * <p> | |
| 217 | * This record models the JSON structure expected by the {@code /v1/messages} | |
| 218 | * endpoint and is serialized using Jackson before transmission. | |
| 219 | * </p> | |
| 220 | * | |
| 221 | * @param model model identifier | |
| 222 | * @param system system prompt controlling model behavior | |
| 223 | * @param messages list of message objects forming the conversation | |
| 224 | * @param temperature sampling temperature | |
| 225 | * @param maxTokens maximum token count for the response | |
| 226 | */ | |
| 227 | private record MessageRequest(String model, String system, List<ContentMessage> messages, Double temperature, | |
| 228 | @JsonProperty("max_tokens") Integer maxTokens) { | |
| 229 | } | |
| 230 | ||
| 231 | /** | |
| 232 | * Message container used by the Anthropic message API. | |
| 233 | * | |
| 234 | * @param role role of the message sender (for example {@code user}) | |
| 235 | * @param content message content blocks | |
| 236 | */ | |
| 237 | private record ContentMessage(String role, List<ContentBlock> content) { | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * Individual content block within a message payload. | |
| 242 | * | |
| 243 | * @param type block type (for example {@code text}) | |
| 244 | * @param text textual content of the block | |
| 245 | */ | |
| 246 | private record ContentBlock(String type, String text) { | |
| 247 | } | |
| 248 | ||
| 249 | /** | |
| 250 | * Partial response model returned by the Anthropic API. | |
| 251 | * | |
| 252 | * <p> | |
| 253 | * Only the fields required by this client are mapped. Additional fields are | |
| 254 | * ignored to maintain forward compatibility with API changes. | |
| 255 | * </p> | |
| 256 | * | |
| 257 | * @param content list of content blocks in the response | |
| 258 | */ | |
| 259 | @JsonIgnoreProperties(ignoreUnknown = true) | |
| 260 | private record MessageResponse(List<ResponseBlock> content) { | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * Content block returned within a provider response. | |
| 265 | * | |
| 266 | * <p> | |
| 267 | * The client scans these blocks to locate the first text segment containing the | |
| 268 | * JSON classification result. | |
| 269 | * </p> | |
| 270 | * | |
| 271 | * @param type block type (for example {@code text}) | |
| 272 | * @param text textual content of the block | |
| 273 | */ | |
| 274 | @JsonIgnoreProperties(ignoreUnknown = true) | |
| 275 | private record ResponseBlock(String type, String text) { | |
| 276 | } | |
| 277 | } | |
Mutations | ||
| 113 |
1.1 2.2 3.3 4.4 5.5 |
|
| 164 |
1.1 2.2 3.3 4.4 |
|
| 168 |
1.1 2.2 |
|
| 169 |
1.1 2.2 3.3 4.4 5.5 |
|
| 170 |
1.1 |
|
| 174 |
1.1 |
|
| 199 |
1.1 2.2 |
|
| 200 |
1.1 2.2 |
|
| 203 |
1.1 2.2 3.3 4.4 5.5 6.6 7.7 |
|
| 204 |
1.1 |
|
| 205 |
1.1 2.2 |
|
| 209 |
1.1 |