AnthropicClient.java

package org.egothor.methodatlas.ai;

import java.net.URI;
import java.net.http.HttpRequest;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

/**
 * {@link AiProviderClient} implementation for the Anthropic API.
 *
 * <p>
 * This client submits classification requests to the Anthropic
 * <a href="https://docs.anthropic.com/">Claude API</a> and converts the
 * returned response into the internal {@link AiClassSuggestion} model used by
 * the MethodAtlas AI subsystem.
 * </p>
 *
 * <h2>Operational Responsibilities</h2>
 *
 * <ul>
 * <li>constructing Anthropic message API requests</li>
 * <li>injecting the taxonomy-driven classification prompt</li>
 * <li>performing authenticated HTTP calls to the Anthropic service</li>
 * <li>extracting the JSON result embedded in the model response</li>
 * <li>normalizing the result into {@link AiClassSuggestion}</li>
 * </ul>
 *
 * <p>
 * The client uses the {@code /v1/messages} endpoint and relies on the Claude
 * message format, where a system prompt defines classification rules and the
 * user message contains the class source together with the taxonomy
 * specification.
 * </p>
 *
 * <p>
 * Instances of this class are typically created by
 * {@link AiProviderFactory#create(AiOptions)}.
 * </p>
 *
 * <p>
 * This implementation is stateless apart from immutable configuration and is
 * therefore safe for reuse across multiple requests.
 * </p>
 *
 * @see AiProviderClient
 * @see AiSuggestionEngine
 * @see AiProviderFactory
 */
public final class AnthropicClient implements AiProviderClient {
    /**
     * System prompt used to instruct the model to return strictly formatted JSON
     * responses suitable for automated parsing.
     *
     * <p>
     * The prompt enforces deterministic output behavior and prevents the model from
     * returning explanations, markdown formatting, or conversational responses that
     * would break the JSON extraction pipeline.
     * </p>
     */
    private static final String SYSTEM_PROMPT = """
            You are a precise software security classification engine.
            You classify JUnit 5 tests and return strict JSON only.
            Never include markdown fences, explanations, or extra text.
            """;

    private final AiOptions options;
    private final HttpSupport httpSupport;

    /**
     * Creates a new Anthropic client using the supplied runtime configuration.
     *
     * <p>
     * The configuration defines the model identifier, API endpoint, request
     * timeout, and authentication settings used when communicating with the
     * Anthropic service.
     * </p>
     *
     * @param options AI runtime configuration
     */
    public AnthropicClient(AiOptions options) {
        this.options = options;
        this.httpSupport = new HttpSupport(options.timeout());
    }

    /**
     * Determines whether the Anthropic provider can be used in the current runtime
     * environment.
     *
     * <p>
     * The provider is considered available when a non-empty API key can be resolved
     * from {@link AiOptions#resolvedApiKey()}.
     * </p>
     *
     * @return {@code true} if a usable API key is configured
     */
    @Override
    public boolean isAvailable() {
        String key = options.resolvedApiKey();
        return key != null && !key.isBlank();
    }

    /**
     * Submits a classification request to the Anthropic API for the specified test
     * class.
     *
     * <p>
     * The method constructs a message-based request containing:
     * </p>
     *
     * <ul>
     * <li>a system prompt enforcing deterministic JSON output</li>
     * <li>a user prompt containing the class source and taxonomy definition</li>
     * </ul>
     *
     * <p>
     * The response is parsed to extract the first JSON object returned by the
     * model, which is then deserialized into an {@link AiClassSuggestion}.
     * </p>
     *
     * @param fqcn          fully qualified class name being analyzed
     * @param classSource   complete source code of the class
     * @param taxonomyText  taxonomy definition guiding classification
     * @param targetMethods deterministically extracted JUnit test methods that must
     *                      be classified
     *
     * @return normalized AI classification result
     *
     * @throws AiSuggestionException if the provider request fails, the response
     *                               cannot be parsed, or the provider returns
     *                               invalid content
     */
    @Override
    public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText,
            List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
        try {
            String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods, options.confidence());

            MessageRequest payload = new MessageRequest(options.modelName(), SYSTEM_PROMPT,
                    List.of(new ContentMessage("user", List.of(new ContentBlock("text", prompt)))), 0.0, 2_000);

            String requestBody = httpSupport.objectMapper().writeValueAsString(payload);
            URI uri = URI.create(options.baseUrl() + "/v1/messages");

            HttpRequest request = httpSupport.jsonPost(uri, requestBody, options.timeout())
                    .header("x-api-key", options.resolvedApiKey()).header("anthropic-version", "2023-06-01").build();

            String responseBody = httpSupport.postJson(request);
            MessageResponse response = httpSupport.objectMapper().readValue(responseBody, MessageResponse.class);

            if (response.content() == null || response.content().isEmpty()) {
                throw new AiSuggestionException("No content returned by Anthropic");
            }

            String text = response.content().stream().filter(block -> "text".equals(block.type())).map(ResponseBlock::text)
                    .filter(value -> value != null && !value.isBlank()).findFirst()
                    .orElseThrow(() -> new AiSuggestionException("Anthropic returned no text block"));

            String json = JsonText.extractFirstJsonObject(text);
            AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class);
            return normalize(suggestion);

        } catch (Exception e) { // NOPMD
            throw new AiSuggestionException("Anthropic suggestion failed for " + fqcn, e);
        }
    }

    /**
     * Normalizes AI results returned by the provider.
     *
     * <p>
     * This method ensures that collection fields are never {@code null} and removes
     * malformed method entries that do not contain a valid method name.
     * </p>
     *
     * <p>
     * The normalization step protects the rest of the application from
     * provider-side inconsistencies and guarantees that the resulting
     * {@link AiClassSuggestion} object satisfies the expected invariants.
     * </p>
     *
     * @param input raw suggestion returned by the provider
     * @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(method -> method != null && method.methodName() != null && !method.methodName().isBlank())
                .map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(),
                        method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason(),
                        method.confidence()))
                .toList();

        return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(),
                normalizedMethods);
    }

    /**
     * Request payload sent to the Anthropic message API.
     *
     * <p>
     * This record models the JSON structure expected by the {@code /v1/messages}
     * endpoint and is serialized using Jackson before transmission.
     * </p>
     *
     * @param model       model identifier
     * @param system      system prompt controlling model behavior
     * @param messages    list of message objects forming the conversation
     * @param temperature sampling temperature
     * @param maxTokens   maximum token count for the response
     */
    private record MessageRequest(String model, String system, List<ContentMessage> messages, Double temperature,
            @JsonProperty("max_tokens") Integer maxTokens) {
    }

    /**
     * Message container used by the Anthropic message API.
     *
     * @param role    role of the message sender (for example {@code user})
     * @param content message content blocks
     */
    private record ContentMessage(String role, List<ContentBlock> content) {
    }

    /**
     * Individual content block within a message payload.
     *
     * @param type block type (for example {@code text})
     * @param text textual content of the block
     */
    private record ContentBlock(String type, String text) {
    }

    /**
     * Partial response model returned by the Anthropic API.
     *
     * <p>
     * Only the fields required by this client are mapped. Additional fields are
     * ignored to maintain forward compatibility with API changes.
     * </p>
     *
     * @param content list of content blocks in the response
     */
    @JsonIgnoreProperties(ignoreUnknown = true)
    private record MessageResponse(List<ResponseBlock> content) {
    }

    /**
     * Content block returned within a provider response.
     *
     * <p>
     * The client scans these blocks to locate the first text segment containing the
     * JSON classification result.
     * </p>
     *
     * @param type block type (for example {@code text})
     * @param text textual content of the block
     */
    @JsonIgnoreProperties(ignoreUnknown = true)
    private record ResponseBlock(String type, String text) {
    }
}