PromptBuilder.java

/*******************************************************************************
 * Copyright (c) 2025 Carsten Hammer.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Carsten Hammer
 *******************************************************************************/
package org.sandbox.jdt.triggerpattern.llm;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
 * Builds prompts combining DSL context,
 * existing categories, and commit diff information.
 */
public class PromptBuilder {

	/**
	 * Holds data for a single commit to be included in a batch prompt.
	 *
	 * @param commitHash    the commit hash
	 * @param commitMessage the commit message
	 * @param diff          the commit diff
	 */
	public record CommitData(String commitHash, String commitMessage, String diff) {
	}

	private static final String DSL_EXPLANATION_RESOURCE = "/dsl-explanation.md"; //$NON-NLS-1$
	private static final String EXISTING_PLUGINS_RESOURCE = "/existing-java-plugins.md"; //$NON-NLS-1$
	private static final String ECLIPSE_API_CONTEXT_RESOURCE = "/eclipse-api-context.md"; //$NON-NLS-1$
	private static final String MINING_EXAMPLES_RESOURCE = "/mining-examples.md"; //$NON-NLS-1$

	private String dslExplanation;
	private String existingPluginsContext;
	private String eclipseApiContext;
	private String miningExamples;
	private String typeContext;
	private String errorFeedback;

	public PromptBuilder() {
		this.dslExplanation = loadResource(DSL_EXPLANATION_RESOURCE);
		this.existingPluginsContext = loadResource(EXISTING_PLUGINS_RESOURCE);
		this.eclipseApiContext = loadResource(ECLIPSE_API_CONTEXT_RESOURCE);
		this.miningExamples = loadResource(MINING_EXAMPLES_RESOURCE);
	}

	/**
	 * Sets optional type context to include in prompts.
	 *
	 * @param typeContext the type context section
	 */
	public void setTypeContext(String typeContext) {
		this.typeContext = typeContext;
	}

	/**
	 * Sets optional error feedback to include in prompts.
	 *
	 * @param errorFeedback the error feedback section
	 */
	public void setErrorFeedback(String errorFeedback) {
		this.errorFeedback = errorFeedback;
	}

	/**
	 * Builds a complete prompt for LLM evaluation.
	 *
	 * @param dslContext       existing DSL rules context
	 * @param categoriesJson   existing categories as JSON
	 * @param diff             the commit diff
	 * @param commitMessage    the commit message
	 * @return the complete prompt string
	 */
	public String buildPrompt(String dslContext, String categoriesJson,
			String diff, String commitMessage) {
		return buildPrompt(dslContext, categoriesJson, diff, commitMessage, null);
	}

	/**
	 * Builds a complete prompt for LLM evaluation with optional previously discovered rules.
	 *
	 * @param dslContext        existing DSL rules context
	 * @param categoriesJson    existing categories as JSON
	 * @param diff              the commit diff
	 * @param commitMessage     the commit message
	 * @param previousResults   JSON array of previously discovered rules (from evaluations.json), or null
	 * @return the complete prompt string
	 */
	public String buildPrompt(String dslContext, String categoriesJson,
			String diff, String commitMessage, String previousResults) {
		StringBuilder sb = new StringBuilder();
		sb.append("You are an expert in Eclipse JDT code transformations and the TriggerPattern DSL.\n\n"); //$NON-NLS-1$
		sb.append("## DSL Explanation\n"); //$NON-NLS-1$
		sb.append(dslExplanation).append("\n\n"); //$NON-NLS-1$
		sb.append("## Existing DSL Rules\n"); //$NON-NLS-1$
		sb.append(dslContext != null ? dslContext : "(none)").append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$
		sb.append("## Existing Categories\n"); //$NON-NLS-1$
		sb.append(categoriesJson != null ? categoriesJson : "[]").append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$
		appendExistingPluginsSection(sb);
		appendPreviousResultsSection(sb, previousResults);
		appendOptionalSections(sb);
		sb.append("## Commit to Analyze\n\n"); //$NON-NLS-1$
		sb.append("### Commit Message\n"); //$NON-NLS-1$
		sb.append(commitMessage).append("\n\n"); //$NON-NLS-1$
		sb.append("### Diff\n```\n"); //$NON-NLS-1$
		sb.append(diff).append("\n```\n\n"); //$NON-NLS-1$
		appendTaskSection(sb);
		return sb.toString();
	}

	/**
	 * Builds a batch prompt for evaluating multiple commits in a single API call.
	 *
	 * @param dslContext     existing DSL rules context
	 * @param categoriesJson existing categories as JSON
	 * @param commits        list of commits to evaluate
	 * @return the complete batch prompt string
	 */
	public String buildBatchPrompt(String dslContext, String categoriesJson,
			List<CommitData> commits) {
		return buildBatchPrompt(dslContext, categoriesJson, commits, null);
	}

	/**
	 * Builds a batch prompt with optional previously discovered rules.
	 *
	 * @param dslContext       existing DSL rules context
	 * @param categoriesJson   existing categories as JSON
	 * @param commits          list of commits to evaluate
	 * @param previousResults  JSON array of previously discovered rules, or null
	 * @return the complete batch prompt string
	 */
	public String buildBatchPrompt(String dslContext, String categoriesJson,
			List<CommitData> commits, String previousResults) {
		StringBuilder sb = new StringBuilder();
		sb.append("You are an expert in Eclipse JDT code transformations and the TriggerPattern DSL.\n\n"); //$NON-NLS-1$
		sb.append("## DSL Explanation\n"); //$NON-NLS-1$
		sb.append(dslExplanation).append("\n\n"); //$NON-NLS-1$
		sb.append("## Existing DSL Rules\n"); //$NON-NLS-1$
		sb.append(dslContext != null ? dslContext : "(none)").append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$
		sb.append("## Existing Categories\n"); //$NON-NLS-1$
		sb.append(categoriesJson != null ? categoriesJson : "[]").append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$
		appendExistingPluginsSection(sb);
		appendPreviousResultsSection(sb, previousResults);
		appendOptionalSections(sb);
		sb.append("## Commits to Analyze\n\n"); //$NON-NLS-1$
		for (int i = 0; i < commits.size(); i++) {
			CommitData cd = commits.get(i);
			sb.append("### Commit ").append(i).append(" (").append(cd.commitHash()).append(")\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
			sb.append("#### Message\n"); //$NON-NLS-1$
			sb.append(cd.commitMessage()).append("\n\n"); //$NON-NLS-1$
			sb.append("#### Diff\n```\n"); //$NON-NLS-1$
			sb.append(cd.diff()).append("\n```\n\n"); //$NON-NLS-1$
		}
		sb.append("## Task\n"); //$NON-NLS-1$
		sb.append("Analyze each commit above and determine whether its code change\n"); //$NON-NLS-1$
		sb.append("can be generalized into a reusable TriggerPattern DSL rule.\n\n"); //$NON-NLS-1$
		sb.append("Return a JSON array with exactly ").append(commits.size()); //$NON-NLS-1$
		sb.append(" evaluation objects, one per commit, in the same order as presented.\n"); //$NON-NLS-1$
		sb.append("Each object has the same schema as before:\n\n"); //$NON-NLS-1$
		appendJsonSchema(sb, true);
		appendTrafficLightMeanings(sb);
		return sb.toString();
	}

	private void appendExistingPluginsSection(StringBuilder sb) {
		sb.append("## Existing Java-Based Cleanup Plugins\n\n"); //$NON-NLS-1$
		sb.append(existingPluginsContext).append("\n\n"); //$NON-NLS-1$
	}

	private static void appendPreviousResultsSection(StringBuilder sb, String previousResults) {
		if (previousResults != null && !previousResults.isBlank()) {
			sb.append("## Previously Discovered Rules\n\n"); //$NON-NLS-1$
			sb.append("The following rules have already been discovered in prior mining runs. "); //$NON-NLS-1$
			sb.append("Do NOT re-propose identical rules. If you see a similar pattern, "); //$NON-NLS-1$
			sb.append("acknowledge it with `\"previouslyProposed\": \"<rule summary>\"` and explain "); //$NON-NLS-1$
			sb.append("how your proposal differs or improves on it.\n\n"); //$NON-NLS-1$
			sb.append(previousResults).append("\n\n"); //$NON-NLS-1$
		}
	}

	private void appendOptionalSections(StringBuilder sb) {
		if (eclipseApiContext != null && !eclipseApiContext.startsWith("(Resource not available")) { //$NON-NLS-1$
			sb.append("## Eclipse API Context\n\n"); //$NON-NLS-1$
			sb.append(eclipseApiContext).append("\n\n"); //$NON-NLS-1$
		}
		if (miningExamples != null && !miningExamples.startsWith("(Resource not available")) { //$NON-NLS-1$
			sb.append("## Mining Examples\n\n"); //$NON-NLS-1$
			sb.append(miningExamples).append("\n\n"); //$NON-NLS-1$
		}
		if (typeContext != null && !typeContext.isBlank()) {
			sb.append(typeContext);
		}
		if (errorFeedback != null && !errorFeedback.isBlank()) {
			sb.append(errorFeedback);
		}
	}

	private void appendTaskSection(StringBuilder sb) {
		sb.append("## Task\n"); //$NON-NLS-1$
		sb.append("Analyze this commit and determine whether the code change\n"); //$NON-NLS-1$
		sb.append("can be generalized into a reusable TriggerPattern DSL rule.\n\n"); //$NON-NLS-1$
		sb.append("IMPORTANT: The dslRule field must contain plain DSL text only.\n"); //$NON-NLS-1$
		sb.append("Never use <trigger>, <import>, <pattern>, or any XML tags.\n"); //$NON-NLS-1$
		sb.append("Never use isType() — use instanceof($var, \"TypeName\") instead.\n\n"); //$NON-NLS-1$
		sb.append("Respond with a JSON object:\n\n"); //$NON-NLS-1$
		appendJsonSchema(sb, false);
		appendTrafficLightMeanings(sb);
	}

	private static void appendJsonSchema(StringBuilder sb, boolean asArray) {
		if (asArray) {
			sb.append("[\n  {\n"); //$NON-NLS-1$
		} else {
			sb.append("{\n"); //$NON-NLS-1$
		}
		String indent = asArray ? "    " : "  "; //$NON-NLS-1$ //$NON-NLS-2$
		sb.append(indent).append("\"relevant\": true/false,\n"); //$NON-NLS-1$
		sb.append(indent).append("\"irrelevantReason\": \"reason if not relevant\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"isDuplicate\": true/false,\n"); //$NON-NLS-1$
		sb.append(indent).append("\"duplicateOf\": \"name of existing rule if duplicate\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"reusability\": 1-10,\n"); //$NON-NLS-1$
		sb.append(indent).append("\"codeImprovement\": 1-10,\n"); //$NON-NLS-1$
		sb.append(indent).append("\"implementationEffort\": 1-10,\n"); //$NON-NLS-1$
		sb.append(indent).append("\"trafficLight\": \"GREEN|YELLOW|RED|NOT_APPLICABLE\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"category\": \"category name\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"isNewCategory\": true/false,\n"); //$NON-NLS-1$
		sb.append(indent).append("\"categoryReason\": \"why this category\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"canImplementInCurrentDsl\": true/false,\n"); //$NON-NLS-1$
		sb.append(indent).append("\"dslRule\": \"raw .sandbox-hint rule (NO <trigger> tags, NO <import> tags, NO XML)\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"targetHintFile\": \"suggested .sandbox-hint filename\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"languageChangeNeeded\": \"what DSL change would be needed\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"dslRuleAfterChange\": \"DSL rule after language extension\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"existsAsJavaPlugin\": true/false,\n"); //$NON-NLS-1$
		sb.append(indent).append("\"replacesPlugin\": \"name of Java plugin this would replace, or null\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"previouslyProposed\": \"summary of similar prior rule, or null\",\n"); //$NON-NLS-1$
		sb.append(indent).append("\"sourceVersion\": 11,\n"); //$NON-NLS-1$
		sb.append(indent).append("\"summary\": \"brief summary of the analysis\"\n"); //$NON-NLS-1$
		if (asArray) {
			sb.append("  },\n  ...\n]\n\n"); //$NON-NLS-1$
		} else {
			sb.append("}\n\n"); //$NON-NLS-1$
		}
	}

	private static void appendTrafficLightMeanings(StringBuilder sb) {
		sb.append("Traffic light meanings:\n"); //$NON-NLS-1$
		sb.append("- GREEN: Directly implementable as a DSL rule\n"); //$NON-NLS-1$
		sb.append("- YELLOW: Implementable with minor DSL extensions\n"); //$NON-NLS-1$
		sb.append("- RED: Requires DSL extensions not yet available (may be supported in future DSL versions)\n"); //$NON-NLS-1$
		sb.append("- NOT_APPLICABLE: Commit is not relevant for DSL mining\n"); //$NON-NLS-1$
	}

	private static String loadResource(String resourcePath) {
		try (InputStream is = PromptBuilder.class.getResourceAsStream(resourcePath)) {
			if (is == null) {
				return "(Resource not available: " + resourcePath + ")"; //$NON-NLS-1$ //$NON-NLS-2$
			}
			return new String(is.readAllBytes(), StandardCharsets.UTF_8);
		} catch (IOException e) {
			return "(Failed to load resource: " + resourcePath + ")"; //$NON-NLS-1$ //$NON-NLS-2$
		}
	}
}