/
psklyarov
/
agent-replacer
Обзор
Документация
Войти
/
psklyarov
/
agent-replacer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/main/java/com/example/agentreplacer/plan/PlanService.java
360 строк
21 KB
pavelsklyarov
insecure ssl
16 дек 2025, 09:03
16 дек 2025, 09:03
935ea9c
Код
Авторство
О чём код?
package com.example.agentreplacer.plan; import com.example.agentreplacer.chat.ChatSessionState; import com.example.agentreplacer.llm.LlmClient; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.Map; import java.util.List; import java.util.ArrayList; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @Service public class PlanService { private final LlmClient llmClient; private final ObjectMapper objectMapper = new ObjectMapper(); private final com.networknt.schema.JsonSchema schema; private final com.networknt.schema.JsonSchemaFactory schemaFactory = com.networknt.schema.JsonSchemaFactory.getInstance(com.networknt.schema.SpecVersion.VersionFlag.V202012); private static final Logger log = LoggerFactory.getLogger(PlanService.class); public PlanService(LlmClient llmClient) { this.llmClient = llmClient; try (var in = PlanService.class.getResourceAsStream("/plan-schema.json")) { if (in == null) throw new IllegalStateException("plan-schema.json not found"); this.schema = schemaFactory.getSchema(in); } catch (Exception e) { throw new RuntimeException("Не удалось загрузить JSON Schema для плана", e); } } public ChangePlan buildPlan(ChatSessionState session) { try { Map<String, Object> payload = new HashMap<>(); payload.put("includedReposAnswer", session.getIncludedReposAnswer()); payload.put("excludedReposAnswer", session.getExcludedReposAnswer()); payload.put("includeCriteriaAnswer", session.getIncludeCriteriaAnswer()); payload.put("excludeCriteriaAnswer", session.getExcludeCriteriaAnswer()); payload.put("whatToChangeAnswer", session.getWhatToChangeAnswer()); payload.put("projectKeyAnswer", session.getProjectKeyAnswer()); payload.put("targetBranchesAnswer", session.getTargetBranchesAnswer()); payload.put("modeAnswer", session.getModeAnswer()); payload.put("taskNumberAnswer", session.getTaskNumberAnswer()); String inputJson = objectMapper.writeValueAsString(payload); log.info("Building plan for session {}. Answers: excluded={}, include={}, exclude={}, what={}, projectKey={}, targetBranches={}, mode={}, task={}", session.getSessionId(), safeLen(session.getExcludedReposAnswer()), safeLen(session.getIncludeCriteriaAnswer()), safeLen(session.getExcludeCriteriaAnswer()), safeLen(session.getWhatToChangeAnswer()), safeLen(session.getProjectKeyAnswer()), safeLen(session.getTargetBranchesAnswer()), safeLen(session.getModeAnswer()), safeLen(session.getTaskNumberAnswer())); String system = """ Выступай как инженер-ассистент. По ответам пользователя сформируй JSON-план. Требуется строго валидный JSON без лишнего текста. Контекст: проекты на Java + Maven + Spring Boot, также возможны YAML-манифесты OpenShift/Kubernetes и SQL-скрипты. Обязательное требование: всегда формируй includeRules и excludeRules как массив из минимум 2 (двух) правил каждый, с разнообразными критериями (по имени файла и по содержимому). Схема: { "projectKey": "string", "mode": "FEATURE_PR|DIRECT_COMMIT|CONFIRM", "includeRepoRegex": "string", "excludeRepoRegex": "string", "targetBranches": ["string"], "includeRules": [ { "filenameRegex": "string|null", "contentRegexes": ["string"] } ], "excludeRules": [ { "filenameRegex": "string|null", "contentRegexes": ["string"] } ], "replacements": [ { "searchRegex": "string", "replacement": "string" } ], "highLevelInstructions": "string|null", "taskNumber": "string", "branchName": "string", "commitMessage": "string" } Пояснения: - projectKey — ключ проекта в Bitbucket, получен от пользователя. - mode — режим работы: FEATURE_PR (feature-ветка + PR), DIRECT_COMMIT (прямой коммит), CONFIRM (подтверждение в чате). - includeRepoRegex — паттерн для нужных репозиториев; excludeRepoRegex — для исключаемых. - targetBranches — список базовых веток (например, ["develop","release/2025.12"]), от каждой будет создана отдельная feature-ветка для изменений. - includeRules/excludeRules — массив правил (минимум 2 в каждом): опциональный filenameRegex и массив contentRegexes. ВАЖНО: contentRegexes МОЖЕТ БЫТЬ ПУСТЫМ или null (совпадение по одному имени файла достаточно). Если contentRegexes задан — каждый regex должен найтись. Совпадение любого правила достаточно. - Подскаска для правил по контексту: - Java/Maven/Spring Boot: - filenameRegex: "pom\\.xml", "src/main/java/.+\\.java", "src/main/resources/application(-[a-zA-Z0-9_-]+)?\\.ya?ml" - contentRegexes: "spring-boot-starter", "<groupId>org\\.springframework\\.boot</groupId>", "@SpringBootApplication" - OpenShift/Kubernetes YAML: - filenameRegex: ".+\\.(ya?ml)$", "(deployment|service|route|ingress|configmap|secret)s?\\.(ya?ml)$" - contentRegexes: "^apiVersion:\\s*(apps|v1|route\\.openshift\\.io/)", "^kind:\\s*(Deployment|DeploymentConfig|Service|Route|Ingress|ConfigMap|Secret)" - SQL: - filenameRegex: ".+\\.sql$", "(V\\d+__.+|migration/.+\\.sql)$" - contentRegexes: "\\bCREATE\\s+TABLE\\b", "\\bALTER\\s+TABLE\\b", "\\bUPDATE\\b" - Если возможны простые замены строк/регекспов — заполни replacements. Иначе дай highLevelInstructions. - branchName и commitMessage сформируй из номера задачи (например, feature/ABC-123 и \"ABC-123: bulk changes\"). """; String json = tryBuildPlanJsonWithRetries(system, inputJson, 5); log.debug("LLM plan json length={}", json.length()); Map<String, Object> map = objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {}); ChangePlan plan = objectMapper.convertValue(map, ChangePlan.class); // Отдельные запросы/валидация для regex по репозиториям payload = new HashMap<>(); payload.put("includedReposAnswer", session.getIncludedReposAnswer()); payload.put("excludedReposAnswer", session.getExcludedReposAnswer()); payload.put("projectKeyAnswer", session.getProjectKeyAnswer()); String includeRepoRegex = requestRepoRegexWithValidation(payload, true, 2); String excludeRepoRegex = requestRepoRegexWithValidation(payload, false, 2); if (includeRepoRegex != null && !includeRepoRegex.isBlank()) plan.setIncludeRepoRegex(includeRepoRegex); if (excludeRepoRegex != null && !excludeRepoRegex.isBlank()) plan.setExcludeRepoRegex(excludeRepoRegex); // Отдельные запросы/валидация для правил файлов Map<String, Object> filePayload = new HashMap<>(); filePayload.put("includeCriteriaAnswer", session.getIncludeCriteriaAnswer()); filePayload.put("excludeCriteriaAnswer", session.getExcludeCriteriaAnswer()); filePayload.put("whatToChangeAnswer", session.getWhatToChangeAnswer()); List<ChangePlan.FileRule> includeRules = requestFileRulesWithValidation(filePayload, true, 2); List<ChangePlan.FileRule> excludeRules = requestFileRulesWithValidation(filePayload, false, 2); if (includeRules != null && !includeRules.isEmpty()) plan.setIncludeRules(includeRules); if (excludeRules != null && !excludeRules.isEmpty()) plan.setExcludeRules(excludeRules); // Нормализация include/exclude по ответам пользователя: // - если пользователь ответил "нет" для исключаемых репозиториев — не задаём excludeRepoRegex // - если пользователь ответил "все" для включаемых репозиториев — не задаём includeRepoRegex if (isNo(session.getExcludedReposAnswer())) { plan.setExcludeRepoRegex(null); } if (isAll(session.getIncludedReposAnswer())) { plan.setIncludeRepoRegex(null); } // Подстраховка от LLM: не допускаем blanket exclude-правило для всех файлов if (plan.getExcludeRules() != null && !plan.getExcludeRules().isEmpty()) { var cleaned = new java.util.ArrayList<com.example.agentreplacer.plan.ChangePlan.FileRule>(); for (var rule : plan.getExcludeRules()) { boolean filenameAll = rule.getFilenameRegex() != null && rule.getFilenameRegex().trim().equals(".*"); boolean noContentReq = rule.getContentRegexes() == null || rule.getContentRegexes().isEmpty(); if (filenameAll && noContentReq) { // пропускаем это правило continue; } cleaned.add(rule); } plan.setExcludeRules(cleaned.isEmpty() ? null : cleaned); } return plan; } catch (Exception e) { log.error("Plan build failed: {}", e.getMessage(), e); throw new RuntimeException("Не удалось сформировать план изменений через LLM", e); } } private String requestRepoRegexWithValidation(Map<String, Object> payload, boolean isInclude, int retries) throws Exception { String system = isInclude ? """ Сформируй только один JSON вида {"includeRepoRegex":"<regex|null>"} по ответам пользователя. Если включаем все — верни null. Ответ ДОЛЖЕН быть строго валидным JSON, без лишнего текста. """ : """ Сформируй только один JSON вида {"excludeRepoRegex":"<regex|null>"} по ответам пользователя. Если исключений нет — верни null. Ответ ДОЛЖЕН быть строго валидным JSON, без лишнего текста. """; String inputJson = objectMapper.writeValueAsString(payload); Exception last = null; for (int i = 0; i <= retries; i++) { String raw = llmClient.derivePlanJson(system, inputJson).trim(); String cleaned = stripCodeFences(raw); try { Map<String, Object> map = objectMapper.readValue(cleaned, new TypeReference<Map<String, Object>>() {}); String key = isInclude ? "includeRepoRegex" : "excludeRepoRegex"; Object val = map.get(key); if (val == null) return null; String regex = String.valueOf(val); if (regex.isBlank()) return null; Pattern.compile(regex); return regex; } catch (PatternSyntaxException psx) { last = psx; inputJson = "{\"previousInvalidJson\":" + objectMapper.writeValueAsString(cleaned) + ",\"hint\":\"верни корректный regex\"}"; log.warn("Invalid repo regex ({}), retry {}", isInclude ? "include" : "exclude", i + 2); } catch (Exception ex) { last = ex; inputJson = "{\"previousInvalidJson\":" + objectMapper.writeValueAsString(cleaned) + ",\"hint\":\"верни корректный JSON\"}"; log.warn("Invalid JSON for repo regex ({}), retry {}", isInclude ? "include" : "exclude", i + 2); } } throw last != null ? last : new IllegalStateException("Не удалось получить валидный regex для репозиториев"); } private List<ChangePlan.FileRule> requestFileRulesWithValidation(Map<String, Object> payload, boolean isInclude, int retries) throws Exception { String system = isInclude ? """ Сформируй только один JSON вида {"includeRules":[{"filenameRegex":"string|null","contentRegexes":["string"]}]}. contentRegexes МОЖЕТ БЫТЬ ПУСТЫМ или null. Верни минимум 2 правила. Ответ — строго валидный JSON. """ : """ Сформируй только один JSON вида {"excludeRules":[{"filenameRegex":"string|null","contentRegexes":["string"]}]}. contentRegexes МОЖЕТ БЫТЬ ПУСТЫМ или null. Верни 2-3 осмысленных правила. Ответ — строго валидный JSON. """; String inputJson = objectMapper.writeValueAsString(payload); Exception last = null; for (int i = 0; i <= retries; i++) { String raw = llmClient.derivePlanJson(system, inputJson).trim(); String cleaned = stripCodeFences(raw); try { Map<String, Object> map = objectMapper.readValue(cleaned, new TypeReference<Map<String, Object>>() {}); String key = isInclude ? "includeRules" : "excludeRules"; Object val = map.get(key); List<ChangePlan.FileRule> rules = objectMapper.convertValue(val, new TypeReference<List<ChangePlan.FileRule>>() {}); if (rules == null) rules = new ArrayList<>(); List<ChangePlan.FileRule> validated = new ArrayList<>(); for (ChangePlan.FileRule r : rules) { if (r == null) continue; if (r.getFilenameRegex() != null && !r.getFilenameRegex().isBlank()) { Pattern.compile(r.getFilenameRegex()); } if (r.getContentRegexes() != null) { for (String cr : r.getContentRegexes()) { if (cr == null || cr.isBlank()) continue; Pattern.compile(cr); } } validated.add(r); } if (validated.isEmpty()) { throw new IllegalArgumentException("Пустой список правил"); } return validated; } catch (PatternSyntaxException psx) { last = psx; inputJson = "{\"previousInvalidJson\":" + objectMapper.writeValueAsString(cleaned) + ",\"hint\":\"исправь некорректный regex\"}"; log.warn("Invalid file rule regex ({}), retry {}", isInclude ? "include" : "exclude", i + 2); } catch (Exception ex) { last = ex; inputJson = "{\"previousInvalidJson\":" + objectMapper.writeValueAsString(cleaned) + ",\"hint\":\"верни валидный JSON\"}"; log.warn("Invalid JSON for file rules ({}), retry {}", isInclude ? "include" : "exclude", i + 2); } } throw last != null ? last : new IllegalStateException("Не удалось получить валидные правила файлов"); } /** * Возвращает сырой JSON, который вернул LLM для плана (после возможной повторной попытки и нормализации code fences). */ public String buildPlanRawJson(ChatSessionState session) { try { Map<String, Object> payload = new HashMap<>(); payload.put("includedReposAnswer", session.getIncludedReposAnswer()); payload.put("excludedReposAnswer", session.getExcludedReposAnswer()); payload.put("includeCriteriaAnswer", session.getIncludeCriteriaAnswer()); payload.put("excludeCriteriaAnswer", session.getExcludeCriteriaAnswer()); payload.put("whatToChangeAnswer", session.getWhatToChangeAnswer()); payload.put("projectKeyAnswer", session.getProjectKeyAnswer()); payload.put("targetBranchesAnswer", session.getTargetBranchesAnswer()); payload.put("modeAnswer", session.getModeAnswer()); payload.put("taskNumberAnswer", session.getTaskNumberAnswer()); String inputJson = objectMapper.writeValueAsString(payload); String system = """ Выступай как инженер-ассистент. По ответам пользователя сформируй JSON-план. Требуется строго валидный JSON без лишнего текста. Контекст: проекты на Java + Maven + Spring Boot, также возможны YAML-манифесты OpenShift/Kubernetes и SQL-скрипты. Обязательное требование: всегда формируй includeRules и excludeRules как массив из минимум 2 (двух) правил каждый, с разнообразными критериями. Если указан filenameRegex, contentRegexes может быть пустым или null. Схема: { "projectKey": "string", "mode": "FEATURE_PR|DIRECT_COMMIT|CONFIRM", "includeRepoRegex": "string", "excludeRepoRegex": "string", "targetBranches": ["string"], "includeRules": [ { "filenameRegex": "string|null", "contentRegexes": ["string"] } ], "excludeRules": [ { "filenameRegex": "string|null", "contentRegexes": ["string"] } ], "replacements": [ { "searchRegex": "string", "replacement": "string" } ], "highLevelInstructions": "string|null", "taskNumber": "string", "branchName": "string", "commitMessage": "string" } Пояснения: - projectKey — ключ проекта в Bitbucket, получен от пользователя. - mode — режим работы: FEATURE_PR (feature-ветка + PR), DIRECT_COMMIT (прямой коммит), CONFIRM (подтверждение в чате). - includeRepoRegex — паттерн для нужных репозиториев; excludeRepoRegex — для исключаемых. - ВАЖНО: если исключений нет — excludeRepoRegex должен быть null (не ".*"). Если включаем все репозитории — includeRepoRegex должен быть null. - targetBranches — список базовых веток (например, ["develop","release/2025.12"]), от каждой будет создана отдельная feature-ветка для изменений. - includeRules/excludeRules — массив правил (минимум 2 в каждом): опциональный filenameRegex и массив contentRegexes. ВАЖНО: contentRegexes МОЖЕТ БЫТЬ ПУСТЫМ или null (совпадение по одному имени файла достаточно). Если contentRegexes задан — каждый regex должен найтись. Совпадение любого правила достаточно. - Подскаска для правил по контексту: - Java/Maven/Spring Boot: - filenameRegex: "pom\\.xml", "src/main/java/.+\\.java", "src/main/resources/application(-[a-zA-Z0-9_-]+)?\\.ya?ml" - contentRegexes: "spring-boot-starter", "<groupId>org\\.springframework\\.boot</groupId>", "@SpringBootApplication" - OpenShift/Kubernetes YAML: - filenameRegex: ".+\\.(ya?ml)$", "(deployment|service|route|ingress|configmap|secret)s?\\.(ya?ml)$" - contentRegexes: "^apiVersion:\\s*(apps|v1|route\\.openshift\\.io/)", "^kind:\\s*(Deployment|DeploymentConfig|Service|Route|Ingress|ConfigMap|Secret)" - SQL: - filenameRegex: ".+\\.sql$", "(V\\d+__.+|migration/.+\\.sql)$" - contentRegexes: "\\bCREATE\\s+TABLE\\b", "\\bALTER\\s+TABLE\\b", "\\bUPDATE\\b" - Если возможны простые замены строк/регекспов — заполни replacements. Иначе дай highLevelInstructions. - branchName и commitMessage сформируй из номера задачи (например, feature/ABC-123 и "ABC-123: bulk changes"). """; return tryBuildPlanJsonWithRetries(system, inputJson, 5); } catch (Exception e) { throw new RuntimeException("Не удалось получить сырой JSON плана через LLM", e); } } private String tryBuildPlanJsonWithRetries(String system, String inputJson, int retries) throws Exception { Exception last = null; for (int i = 0; i <= retries; i++) { String raw = llmClient.derivePlanJson(system, inputJson).trim(); String cleaned = stripCodeFences(raw); if (isValidAgainstSchema(cleaned)) { log.info("Plan JSON validated on attempt {}", i + 1); return cleaned; } last = new IllegalArgumentException("LLM ответ не соответствует JSON Schema"); inputJson = "{\"previousInvalidJson\":" + objectMapper.writeValueAsString(cleaned) + ",\"original\":" + inputJson + "}"; log.warn("Invalid plan JSON, retry {}", i + 2); } throw last != null ? last : new IllegalStateException("Не удалось получить валидный JSON-план"); } private Object safeLen(String s) { if (s == null) return null; return s.length(); } private boolean isValidAgainstSchema(String json) { try { var node = objectMapper.readTree(json); var errors = schema.validate(node); return errors == null || errors.isEmpty(); } catch (Exception e) { return false; } } private String stripCodeFences(String text) { String trimmed = text.trim(); if (trimmed.startsWith("```") && trimmed.endsWith("```")) { String inner = trimmed.substring(3, trimmed.length() - 3); inner = inner.replaceFirst("^[a-zA-Z0-9+\\-._]+\\n", ""); return inner.trim(); } return trimmed; } private boolean isNo(String s) { if (s == null) return false; String v = s.trim().toLowerCase(); return v.equals("нет") || v.equals("no") || v.equals("none") || v.equals("не нужно") || v.equals("не"); } private boolean isAll(String s) { if (s == null) return false; String v = s.trim().toLowerCase(); return v.equals("все") || v.equals("all") || v.equals("any"); } }