This is an automated email from the ASF dual-hosted git repository.

dspavlov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite-teamcity-bot.git


The following commit(s) were added to refs/heads/master by this push:
     new 5b044ab5 Ignite 28635 add AI prompt generation for TeamCity failures 
(#209)
5b044ab5 is described below

commit 5b044ab52406002a6585d84a79254245126257ae
Author: ignitetcbot <[email protected]>
AuthorDate: Fri May 8 19:32:47 2026 +0300

    Ignite 28635 add AI prompt generation for TeamCity failures (#209)
    
    Add TeamCity failure context extraction, stale-cache fallback and 
monitoring for AI prompt generation.
    
    Codex co-authored-by: Dmitriy Pavlov [email protected]
---
 .../ignite/ci/web/StaticResourceServlet.java       |  16 +-
 .../rest/build/GetSingleBuildTestFailuresRest.java |  13 +
 .../ci/web/rest/monitoring/MonitoringService.java  |  11 +-
 .../ignite/ci/web/rest/pr/GetPrTestFailures.java   |  40 ++
 .../rest/tracked/GetTrackedBranchTestResults.java  |  25 +
 ignite-tc-helper-web/src/main/webapp/build.html    |   4 +-
 ignite-tc-helper-web/src/main/webapp/current.html  |  47 ++
 .../src/main/webapp/js/testfails-2.2.js            |  33 +
 .../src/main/webapp/monitoring.html                |  57 ++
 ignite-tc-helper-web/src/main/webapp/pr.html       |  49 +-
 .../ignite/ci/web/StaticResourceServletTest.java   | 115 +++
 .../monitoring/MonitoringServiceSecurityTest.java  |   3 +
 .../tcbot/engine/build/AiPromptRequestMonitor.java | 233 +++++++
 .../engine/build/SingleBuildResultsService.java    | 141 +++-
 .../engine/build/TestFailuresAiPromptBuilder.java  | 769 +++++++++++++++++++++
 .../tcbot/engine/chain/BuildChainProcessor.java    |  27 +-
 .../ignite/tcbot/engine/chain/ProcessLogsMode.java |   3 +
 .../tcbot/engine/chain/SingleBuildRunCtx.java      |   4 +
 .../ignite/tcbot/engine/pr/PrChainsProcessor.java  | 147 ++++
 .../tracked/TrackedBranchChainsProcessor.java      | 163 +++++
 .../build/TestFailuresAiPromptBuilderTest.java     |  37 +
 .../ignited/buildtype/ParametersCompacted.java     |   3 +
 .../ignite/tcignited/build/TestCompactedV2.java    |  23 +-
 .../tcignited/buildlog/BuildLogProcessor.java      |  16 +
 .../tcignited/buildlog/IBuildLogProcessor.java     |   2 +
 25 files changed, 1946 insertions(+), 35 deletions(-)

diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/StaticResourceServlet.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/StaticResourceServlet.java
index 5a373881..8e038ea5 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/StaticResourceServlet.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/StaticResourceServlet.java
@@ -84,9 +84,7 @@ public class StaticResourceServlet extends HttpServlet {
         if (path.endsWith("/"))
             path += "index.html";
 
-        String resPath = STATIC_ROOT + path;
-
-        try (InputStream in = 
Thread.currentThread().getContextClassLoader().getResourceAsStream(resPath)) {
+        try (InputStream in = openResource(path)) {
             if (in == null) {
                 resp.sendError(HttpServletResponse.SC_NOT_FOUND);
                 return;
@@ -103,4 +101,16 @@ public class StaticResourceServlet extends HttpServlet {
             in.transferTo(resp.getOutputStream());
         }
     }
+
+    /**
+     * @param path Resource path relative to webapp root.
+     */
+    private InputStream openResource(String path) {
+        InputStream in = getServletContext().getResourceAsStream("/" + path);
+
+        if (in != null)
+            return in;
+
+        return 
Thread.currentThread().getContextClassLoader().getResourceAsStream(STATIC_ROOT 
+ path);
+    }
 }
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/build/GetSingleBuildTestFailuresRest.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/build/GetSingleBuildTestFailuresRest.java
index abd9ed7a..6dcb9ff3 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/build/GetSingleBuildTestFailuresRest.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/build/GetSingleBuildTestFailuresRest.java
@@ -85,6 +85,19 @@ public class GetSingleBuildTestFailuresRest {
         return getBuildTestFails(srvCodeOrAlias, buildId, 
checkAllLogs).toString();
     }
 
+    @GET
+    @Path("failures/aiPrompt")
+    @Produces(MediaType.TEXT_PLAIN)
+    public String getTestFailsAiPrompt(
+        @QueryParam("serverId") String srvCodeOrAlias,
+        @QueryParam("buildId") Integer buildId,
+        @Nullable @QueryParam("maxDetailsChars") Integer maxDetailsChars) 
throws ServiceUnauthorizedException {
+        return CtxListener.getInjector(ctx)
+            .getInstance(SingleBuildResultsService.class)
+            .getSingleBuildFailuresAiPrompt(srvCodeOrAlias, buildId, 
maxDetailsChars, SyncMode.RELOAD_QUEUED,
+                ITcBotUserCreds.get(req));
+    }
+
     @GET
     @Path("failuresNoSync")
     public DsSummaryUi getBuildTestFailsNoSync(
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
index 65dc2a0a..f2b5706d 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringService.java
@@ -34,9 +34,10 @@ import org.apache.ignite.cache.CacheMetrics;
 import org.apache.ignite.cache.affinity.Affinity;
 import org.apache.ignite.ci.web.CtxListener;
 import org.apache.ignite.ci.web.model.SimpleResult;
+import org.apache.ignite.tcbot.common.conf.TcBotWorkDir;
 import org.apache.ignite.tcbot.common.interceptor.AutoProfilingInterceptor;
 import org.apache.ignite.tcbot.common.interceptor.MonitoredTaskInterceptor;
-import org.apache.ignite.tcbot.common.conf.TcBotWorkDir;
+import org.apache.ignite.tcbot.engine.build.AiPromptRequestMonitor;
 import org.apache.ignite.tcbot.engine.conf.INotificationChannel;
 import org.apache.ignite.tcbot.engine.conf.ITcBotConfig;
 import org.apache.ignite.tcbot.engine.conf.NotificationsConfig;
@@ -500,4 +501,12 @@ public class MonitoringService {
 
         return new SimpleResult("Ok");
     }
+
+    @GET
+    @Path("aiPrompts")
+    public List<AiPromptRequestMonitor.Request> getAiPromptRequests() {
+        AiPromptRequestMonitor monitor = 
CtxListener.getInjector(ctx).getInstance(AiPromptRequestMonitor.class);
+
+        return monitor.getRequests();
+    }
 }
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
index d46c82d7..c0347023 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
@@ -35,6 +35,7 @@ import org.apache.ignite.ci.web.CtxListener;
 import org.apache.ignite.githubignited.IGitHubConnIgnited;
 import org.apache.ignite.githubignited.IGitHubConnIgnitedProvider;
 import org.apache.ignite.githubservice.IGitHubConnection;
+import org.apache.ignite.tcbot.engine.build.TestFailuresAiPromptBuilder;
 import org.apache.ignite.tcbot.engine.pr.PrChainsProcessor;
 import org.apache.ignite.tcbot.engine.ui.DsSummaryUi;
 import org.apache.ignite.tcbot.engine.ui.UpdateInfo;
@@ -120,6 +121,45 @@ public class GetPrTestFailures {
         return getPrFailsWithSyncMode(srvId, suiteId, branchForTc, act, cnt, 
baseBranchForTc, checkAllLogs, SyncMode.RELOAD_QUEUED);
     }
 
+    /**
+     * @param srvId Server id.
+     * @param suiteId Suite id.
+     * @param branchForTc Branch name in TC identification.
+     * @param act Action.
+     * @param cnt Count.
+     * @param baseBranchForTc Base branch name in TC identification.
+     * @param maxDetailsChars Max chars per TeamCity failure details block. 
Non-positive means default cap.
+     * @param testName Optional full test name filter.
+     * @param promptSuiteId Optional suite id filter.
+     */
+    @GET
+    @Path("results/aiPrompt")
+    @Produces(MediaType.TEXT_PLAIN)
+    @NotNull public String getPrFailuresAiPrompt(
+        @Nullable @QueryParam("serverId") String srvId,
+        @Nonnull @QueryParam("suiteId") String suiteId,
+        @Nonnull @QueryParam("branchForTc") String branchForTc,
+        @Nonnull @QueryParam("action") String act,
+        @Nullable @QueryParam("count") Integer cnt,
+        @Nullable @QueryParam("baseBranchForTc") String baseBranchForTc,
+        @Nullable @QueryParam("maxDetailsChars") Integer maxDetailsChars,
+        @Nullable @QueryParam("testName") String testName,
+        @Nullable @QueryParam("promptSuiteId") String promptSuiteId) {
+        final Injector injector = CtxListener.getInjector(ctx);
+
+        return 
injector.getInstance(PrChainsProcessor.class).getPrFailuresAiPrompt(
+            ITcBotUserCreds.get(req),
+            srvId,
+            suiteId,
+            branchForTc,
+            act,
+            cnt,
+            baseBranchForTc,
+            TestFailuresAiPromptBuilder.restMaxDetailsChars(maxDetailsChars),
+            testName,
+            promptSuiteId);
+    }
+
     @POST
     @Path("notifyGit")
     public String getNotifyGit(
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
index b0b969a3..d39f3740 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
@@ -106,6 +106,31 @@ public class GetTrackedBranchTestResults {
             displayMode, sortOption, mergeCnt, showTestLongerThan, showMuted, 
showIgnored).toString();
     }
 
+    @GET
+    @Path("results/aiPrompt")
+    @Produces(MediaType.TEXT_PLAIN)
+    public String getTestFailsAiPrompt(@Nullable @QueryParam("branch") String 
branchOrNull,
+        @Nullable @QueryParam("tagForHistSelected") String tagForHistSelected,
+        @Nullable @QueryParam("sortOption") String sortOption,
+        @Nullable @QueryParam("count") Integer mergeCnt,
+        @Nullable @QueryParam("maxDetailsChars") Integer maxDetailsChars,
+        @Nullable @QueryParam("testName") String testName,
+        @Nullable @QueryParam("promptSuiteId") String promptSuiteId) {
+        int actualMergeBuilds = (mergeCnt == null || mergeCnt < 1) ? 1 : 
mergeCnt;
+
+        return CtxListener.getInjector(ctx)
+            .getInstance(TrackedBranchChainsProcessor.class)
+            .getTrackedBranchFailuresAiPrompt(branchOrNull,
+                actualMergeBuilds,
+                ITcBotUserCreds.get(req),
+                SyncMode.RELOAD_QUEUED,
+                tagForHistSelected,
+                SortOption.parseStringValue(sortOption),
+                maxDetailsChars,
+                testName,
+                promptSuiteId);
+    }
+
     @GET
     @Path("resultsNoSync")
     public DsSummaryUi getTestFailsResultsNoSync(
diff --git a/ignite-tc-helper-web/src/main/webapp/build.html 
b/ignite-tc-helper-web/src/main/webapp/build.html
index 6d26a445..50e8b435 100644
--- a/ignite-tc-helper-web/src/main/webapp/build.html
+++ b/ignite-tc-helper-web/src/main/webapp/build.html
@@ -128,8 +128,10 @@ function loadPartialData() {
 
 function showData(result) {
     var txtUrl = "rest/build/failures/txt" + parmsForRest();
+    var aiPromptUrl = "rest/build/failures/aiPrompt" + parmsForRest();
 
     $("#divFailures").html(showChainOnServersResults(result)
+        + " <a href='"+ aiPromptUrl + "' title='Download AI prompt with 
TeamCity failure context'>AI prompt</a>"
         // + " <a href='"+ txtUrl + "'>txt</a>"
     );
 }
@@ -143,4 +145,4 @@ function showData(result) {
 <div id="version"></div>
 <div style="visibility:hidden;"><div id="triggerConfirm" title="Trigger 
Confirmation"></div><div id="triggerDialog" title="Trigger Result"></div></div>
 </body>
-</html>
\ No newline at end of file
+</html>
diff --git a/ignite-tc-helper-web/src/main/webapp/current.html 
b/ignite-tc-helper-web/src/main/webapp/current.html
index decd43c6..674b2b3e 100644
--- a/ignite-tc-helper-web/src/main/webapp/current.html
+++ b/ignite-tc-helper-web/src/main/webapp/current.html
@@ -347,6 +347,53 @@ function showData(result) {
     //        + " <a href='"+ txtUrl + "'>txt</a>");
 }
 
+function aiPromptUrlForTest(testName) {
+    return "rest/tracked/results/aiPrompt" + parmsForRest() + "&testName=" + 
encodeURIComponent(testName);
+}
+
+function aiPromptUrlForSuite(suiteId) {
+    return "rest/tracked/results/aiPrompt" + parmsForRest() + 
"&promptSuiteId=" + encodeURIComponent(suiteId);
+}
+
+function openAiPromptForTest(testName) {
+    openAiPrompt(aiPromptUrlForTest(testName));
+}
+
+function openAiPromptForSuite(suiteId) {
+    openAiPrompt(aiPromptUrlForSuite(suiteId));
+}
+
+function openAiPrompt(url) {
+    let promptWindow = window.open("about:blank", "_blank");
+    if (promptWindow) {
+        promptWindow.document.title = "AI prompt";
+        promptWindow.document.body.innerHTML = "<pre>Loading AI 
prompt...</pre>";
+    }
+
+    $.ajax({
+        url: url,
+        timeout: 300000,
+        success: function (result) {
+            writeAiPromptWindow(promptWindow, result);
+        },
+        error: function (xhr, status, error) {
+            showErrInLoadStatus(xhr, status, error);
+            writeAiPromptWindow(promptWindow, "AI prompt request failed: " + 
status + "\n\n" + xhr.responseText);
+        }
+    });
+}
+
+function writeAiPromptWindow(promptWindow, text) {
+    if (!promptWindow)
+        return;
+
+    promptWindow.document.body.innerHTML = "";
+
+    let pre = promptWindow.document.createElement("pre");
+    pre.textContent = text;
+    promptWindow.document.body.appendChild(pre);
+}
+
 </script>
 
 
diff --git a/ignite-tc-helper-web/src/main/webapp/js/testfails-2.2.js 
b/ignite-tc-helper-web/src/main/webapp/js/testfails-2.2.js
index 8d7b6042..d7882eea 100644
--- a/ignite-tc-helper-web/src/main/webapp/js/testfails-2.2.js
+++ b/ignite-tc-helper-web/src/main/webapp/js/testfails-2.2.js
@@ -717,6 +717,13 @@ function showSuiteData(suite, settings, prNum) {
 
     res += "</a> ]";
 
+    if (isDefinedAndFilled(suite.suiteId) && isSuiteProblematic(suite) && 
typeof openAiPromptForSuite === "function") {
+        res += " <a href='javascript:void(0);' ";
+        res += "onClick='openAiPromptForSuite(decodeURIComponent(";
+        res += JSON.stringify(encodeURIComponent(suite.suiteId)) + "))' ";
+        res += "title='Open AI prompt with TeamCity context for this 
suite'>[AI Prompt]</a>";
+    }
+
     if(isDefinedAndFilled(suite.tags)) {
         for (let i = 0; i < suite.tags.length; i++) {
             const tag = suite.tags[i];
@@ -818,6 +825,25 @@ function showSuiteData(suite, settings, prNum) {
     return res;
 }
 
+function isSuiteProblematic(suite) {
+    if (isDefinedAndFilled(suite.success) && suite.success === true)
+        return false;
+
+    if (isDefinedAndFilled(suite.failedTests) && suite.failedTests > 0)
+        return true;
+
+    if (isDefinedAndFilled(suite.result) && suite.result !== "")
+        return true;
+
+    if (isDefinedAndFilled(suite.hasCriticalProblem) && 
suite.hasCriticalProblem)
+        return true;
+
+    if (isDefinedAndFilled(suite.blockerComment) && suite.blockerComment !== 
"")
+        return true;
+
+    return false;
+}
+
 function failureRateToColor(failureRate) {
     var redSaturation = 255;
     var greenSaturation = 0;
@@ -958,6 +984,13 @@ function showTestFailData(testFail, isFailureShown, 
settings) {
     else
         res += testFail.name;
 
+    if (isDefinedAndFilled(testFail.name) && typeof openAiPromptForTest === 
"function") {
+        res += " <a href='javascript:void(0);' ";
+        res += "onClick='openAiPromptForTest(decodeURIComponent(";
+        res += JSON.stringify(encodeURIComponent(testFail.name)) + "))' ";
+        res += "title='Open AI prompt with TeamCity context for this test'>[AI 
Prompt]</a>";
+    }
+
     var histContent = "";
 
     //see class TestHistory
diff --git a/ignite-tc-helper-web/src/main/webapp/monitoring.html 
b/ignite-tc-helper-web/src/main/webapp/monitoring.html
index d9932882..6aa73048 100644
--- a/ignite-tc-helper-web/src/main/webapp/monitoring.html
+++ b/ignite-tc-helper-web/src/main/webapp/monitoring.html
@@ -22,6 +22,7 @@
         $( document ).tooltip();
 
         loadData();
+        setInterval(loadAiPromptsData, 5000);
     });
 
     function loadPofilingData() {
@@ -84,6 +85,20 @@
             },
             error: showErrInLoadStatus
         });
+
+        loadAiPromptsData();
+    }
+
+    function loadAiPromptsData() {
+        $.ajax({
+            url: "rest/monitoring/aiPrompts",
+            success: function(result) {
+                $("#loadStatus").html("");
+
+                showAiPrompts(result);
+            },
+            error: showErrInLoadStatus
+        });
     }
 
     /**
@@ -202,6 +217,43 @@
         $("#caches").html(res);
     }
 
+    function showAiPrompts(result) {
+        var res = "<table class='stat'>" ;
+        res += "<tr>";
+        res += "<th>Id</th>";
+        res += "<th>Kind</th>";
+        res += "<th>Branch</th>";
+        res += "<th>Server</th>";
+        res += "<th>Suite</th>";
+        res += "<th>Test</th>";
+        res += "<th>Stage</th>";
+        res += "<th>Start</th>";
+        res += "<th>End</th>";
+        res += "<th>Duration</th>";
+        res += "<th>Running</th>";
+        res += "<th>Result</th>";
+        res += "</tr>";
+        for (var i = 0; i < result.length; i++) {
+            var req = result[i];
+            res += "<tr>";
+            res += "<td>" + escapeHtml(req.id) + "</td>";
+            res += "<td>" + escapeHtml(req.kind) + "</td>";
+            res += "<td>" + escapeHtml(req.branch) + "</td>";
+            res += "<td>" + escapeHtml(req.server) + "</td>";
+            res += "<td>" + escapeHtml(req.suite) + "</td>";
+            res += "<td>" + escapeHtml(req.test) + "</td>";
+            res += "<td>" + escapeHtml(req.stage) + "</td>";
+            res += "<td>" + escapeHtml(req.start) + "</td>";
+            res += "<td>" + escapeHtml(req.end) + "</td>";
+            res += "<td>" + escapeHtml(req.duration) + "</td>";
+            res += "<td>" + escapeHtml(req.running) + "</td>";
+            res += "<td>" + escapeHtml(req.result) + "</td>";
+            res += "</tr>";
+        }
+        res += "</table>";
+        $("#aiPrompts").html(res);
+    }
+
     function resetProfiling() {
         $.ajax({
             url: "rest/monitoring/resetProfiling",
@@ -271,6 +323,11 @@ Tasks Monitoring Data:
 <div id="tasks" style="font-family: monospace"></div>
 <br>
 
+<hr>
+<b>AI Prompt Requests:</b>
+<div id="aiPrompts" style="font-family: monospace"></div>
+<br>
+
 <hr>
 <b>REST Request Timings:</b> <button onclick="resetRequests()">Reset</button>
 <div id="requests" style="font-family: monospace"></div>
diff --git a/ignite-tc-helper-web/src/main/webapp/pr.html 
b/ignite-tc-helper-web/src/main/webapp/pr.html
index 11b52c44..5f0f97c2 100644
--- a/ignite-tc-helper-web/src/main/webapp/pr.html
+++ b/ignite-tc-helper-web/src/main/webapp/pr.html
@@ -128,6 +128,53 @@ function parmsForRest() {
     return curReqParms;
 }
 
+function aiPromptUrlForTest(testName) {
+    return "rest/pr/results/aiPrompt" + parmsForRest() + "&testName=" + 
encodeURIComponent(testName);
+}
+
+function aiPromptUrlForSuite(suiteId) {
+    return "rest/pr/results/aiPrompt" + parmsForRest() + "&promptSuiteId=" + 
encodeURIComponent(suiteId);
+}
+
+function openAiPromptForTest(testName) {
+    openAiPrompt(aiPromptUrlForTest(testName));
+}
+
+function openAiPromptForSuite(suiteId) {
+    openAiPrompt(aiPromptUrlForSuite(suiteId));
+}
+
+function openAiPrompt(url) {
+    let promptWindow = window.open("about:blank", "_blank");
+    if (promptWindow) {
+        promptWindow.document.title = "AI prompt";
+        promptWindow.document.body.innerHTML = "<pre>Loading AI 
prompt...</pre>";
+    }
+
+    $.ajax({
+        url: url,
+        timeout: 300000,
+        success: function (result) {
+            writeAiPromptWindow(promptWindow, result);
+        },
+        error: function (xhr, status, error) {
+            showErrInLoadStatus(xhr, status, error);
+            writeAiPromptWindow(promptWindow, "AI prompt request failed: " + 
status + "\n\n" + xhr.responseText);
+        }
+    });
+}
+
+function writeAiPromptWindow(promptWindow, text) {
+    if (!promptWindow)
+        return;
+
+    promptWindow.document.body.innerHTML = "";
+
+    let pre = promptWindow.document.createElement("pre");
+    pre.textContent = text;
+    promptWindow.document.body.appendChild(pre);
+}
+
 function checkForUpdate() {
     var curFailuresUrl = "rest/pr/updates" + parmsForRest();
 
@@ -235,4 +282,4 @@ function showData(result) {
 <div id="version"></div>
 <div style="visibility:hidden"><div id="triggerConfirm" title="Trigger 
Confirmation"></div><div id="triggerDialog" title="Trigger Result"></div></div>
 </body>
-</html>
\ No newline at end of file
+</html>
diff --git 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/StaticResourceServletTest.java
 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/StaticResourceServletTest.java
new file mode 100644
index 00000000..a04131c6
--- /dev/null
+++ 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/StaticResourceServletTest.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.ci.web;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class StaticResourceServletTest {
+    @Test
+    public void servesRootFromWebappResourceBase() throws Exception {
+        ServletContext ctx = mock(ServletContext.class);
+        byte[] html = "<html>ok</html>".getBytes(StandardCharsets.UTF_8);
+
+        when(ctx.getResourceAsStream("/index.html")).thenReturn(new 
ByteArrayInputStream(html));
+        when(ctx.getMimeType("index.html")).thenReturn("text/html");
+
+        TestStaticResourceServlet servlet = new TestStaticResourceServlet(ctx);
+
+        HttpServletRequest req = mock(HttpServletRequest.class);
+        when(req.getContextPath()).thenReturn("");
+        when(req.getRequestURI()).thenReturn("/");
+
+        ByteArrayOutputStream body = new ByteArrayOutputStream();
+        HttpServletResponse resp = mock(HttpServletResponse.class);
+        when(resp.getOutputStream()).thenReturn(new 
ByteArrayServletOutputStream(body));
+
+        servlet.get(req, resp);
+
+        assertEquals("<html>ok</html>", 
body.toString(StandardCharsets.UTF_8.name()));
+
+        verify(resp).setContentType("text/html");
+        verify(resp, never()).sendError(HttpServletResponse.SC_NOT_FOUND);
+    }
+
+    private static class TestStaticResourceServlet extends 
StaticResourceServlet {
+        /** Context. */
+        private final ServletContext ctx;
+
+        /**
+         * @param ctx Context.
+         */
+        private TestStaticResourceServlet(ServletContext ctx) {
+            this.ctx = ctx;
+        }
+
+        /** {@inheritDoc} */
+        @Override public ServletContext getServletContext() {
+            return ctx;
+        }
+
+        /**
+         * @param req Request.
+         * @param resp Response.
+         */
+        private void get(HttpServletRequest req, HttpServletResponse resp) 
throws Exception {
+            doGet(req, resp);
+        }
+    }
+
+    private static class ByteArrayServletOutputStream extends 
ServletOutputStream {
+        /** Delegate. */
+        private final ByteArrayOutputStream delegate;
+
+        /**
+         * @param delegate Delegate.
+         */
+        private ByteArrayServletOutputStream(ByteArrayOutputStream delegate) {
+            this.delegate = delegate;
+        }
+
+        /** {@inheritDoc} */
+        @Override public boolean isReady() {
+            return true;
+        }
+
+        /** {@inheritDoc} */
+        @Override public void setWriteListener(WriteListener listener) {
+            // No async IO in this test.
+        }
+
+        /** {@inheritDoc} */
+        @Override public void write(int b) throws IOException {
+            delegate.write(b);
+        }
+    }
+}
diff --git 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
index 6eae4b1e..160e7299 100644
--- 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
+++ 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/rest/monitoring/MonitoringServiceSecurityTest.java
@@ -35,6 +35,7 @@ public class MonitoringServiceSecurityTest {
         
assertAuthRequired(MonitoringService.class.getMethod("getRequestStats"));
         
assertAuthRequired(MonitoringService.class.getMethod("getRecentRequests"));
         
assertAuthRequired(MonitoringService.class.getMethod("resetRequestStats"));
+        
assertAuthRequired(MonitoringService.class.getMethod("getAiPromptRequests"));
     }
 
     @Test
@@ -44,6 +45,8 @@ public class MonitoringServiceSecurityTest {
         assertTrue(html.contains("escapeHtml(inv.method)"));
         assertTrue(html.contains("escapeHtml(inv.path)"));
         assertTrue(html.contains("escapeHtml(inv.lastRequest)"));
+        assertTrue(html.contains("escapeHtml(req.branch)"));
+        assertTrue(html.contains("escapeHtml(req.result)"));
         assertTrue(html.contains("String(str == null ? \"\" : str)"));
     }
 
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/AiPromptRequestMonitor.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/AiPromptRequestMonitor.java
new file mode 100644
index 00000000..97b2fb3c
--- /dev/null
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/AiPromptRequestMonitor.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.tcbot.engine.build;
+
+import com.google.common.base.Strings;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import javax.inject.Singleton;
+import org.apache.ignite.tcbot.common.util.TimeUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Lightweight request monitor for AI prompt generation.
+ */
+@Singleton
+public class AiPromptRequestMonitor {
+    /** Logger. */
+    private static final Logger logger = 
LoggerFactory.getLogger(AiPromptRequestMonitor.class);
+
+    /** Request sequence. */
+    private final AtomicLong seq = new AtomicLong();
+
+    /** Requests. */
+    private final ConcurrentMap<Long, Request> requests = new 
ConcurrentHashMap<>();
+
+    /**
+     * @param kind Prompt kind.
+     * @param branch Branch.
+     * @param server Server.
+     * @param suite Suite.
+     * @param test Test.
+     */
+    public long start(String kind, @Nullable String branch, @Nullable String 
server, @Nullable String suite,
+        @Nullable String test) {
+        long id = seq.incrementAndGet();
+        Request req = new Request(id, kind, branch, server, suite, test);
+
+        requests.put(id, req);
+
+        logger.info("AI prompt request started: {}", req);
+
+        trim();
+
+        return id;
+    }
+
+    /**
+     * @param id Request id.
+     * @param stage Stage.
+     */
+    public void stage(long id, String stage) {
+        Request req = requests.get(id);
+
+        if (req == null)
+            return;
+
+        req.stage = stage;
+
+        logger.info("AI prompt request stage: id={}, stage={}", id, stage);
+    }
+
+    /**
+     * @param id Request id.
+     * @param result Result.
+     */
+    public void finish(long id, String result) {
+        Request req = requests.get(id);
+
+        if (req == null)
+            return;
+
+        req.finished = System.currentTimeMillis();
+        req.stage = "finished";
+        req.result = result;
+
+        logger.info("AI prompt request finished: {}", req);
+    }
+
+    /**
+     * @param id Request id.
+     * @param e Error.
+     */
+    public void fail(long id, Throwable e) {
+        Request req = requests.get(id);
+
+        if (req == null)
+            return;
+
+        req.finished = System.currentTimeMillis();
+        req.stage = "failed";
+        req.result = e.getClass().getSimpleName() + ": " + e.getMessage();
+
+        logger.warn("AI prompt request failed: {}", req, e);
+    }
+
+    /**
+     * @return Snapshot.
+     */
+    public List<Request> getRequests() {
+        return requests.values().stream()
+            .sorted(Comparator.comparingLong(Request::id).reversed())
+            .collect(Collectors.toList());
+    }
+
+    /** */
+    private void trim() {
+        if (requests.size() <= 200)
+            return;
+
+        requests.values().stream()
+            .sorted(Comparator.comparingLong(Request::id))
+            .limit(requests.size() - 200)
+            .map(Request::id)
+            .forEach(requests::remove);
+    }
+
+    /** Request record. */
+    @SuppressWarnings({"WeakerAccess", "PublicField"})
+    public static class Request {
+        /** Id. */
+        public final long id;
+
+        /** Kind. */
+        public final String kind;
+
+        /** Branch. */
+        public final String branch;
+
+        /** Server. */
+        public final String server;
+
+        /** Suite. */
+        public final String suite;
+
+        /** Test. */
+        public final String test;
+
+        /** Started timestamp. */
+        public final long started;
+
+        /** Finished timestamp. */
+        public volatile long finished;
+
+        /** Stage. */
+        public volatile String stage = "starting";
+
+        /** Result. */
+        public volatile String result = "";
+
+        /**
+         * @param id Id.
+         * @param kind Kind.
+         * @param branch Branch.
+         * @param server Server.
+         * @param suite Suite.
+         * @param test Test.
+         */
+        private Request(long id, String kind, @Nullable String branch, 
@Nullable String server,
+            @Nullable String suite, @Nullable String test) {
+            this.id = id;
+            this.kind = kind;
+            this.branch = Strings.nullToEmpty(branch);
+            this.server = Strings.nullToEmpty(server);
+            this.suite = Strings.nullToEmpty(suite);
+            this.test = Strings.nullToEmpty(test);
+            started = System.currentTimeMillis();
+        }
+
+        /** @return Id. */
+        public long id() {
+            return id;
+        }
+
+        /** @return Started printable. */
+        public String getStart() {
+            return TimeUtil.timestampToDateTimePrintable(started);
+        }
+
+        /** @return End printable. */
+        public String getEnd() {
+            return TimeUtil.timestampToDateTimePrintable(finished);
+        }
+
+        /** @return Duration printable. */
+        public String getDuration() {
+            long end = finished == 0 ? System.currentTimeMillis() : finished;
+
+            return TimeUtil.millisToDurationPrintable(end - started);
+        }
+
+        /** @return Running. */
+        public boolean isRunning() {
+            return finished == 0;
+        }
+
+        /** {@inheritDoc} */
+        @Override public String toString() {
+            return "Request{" +
+                "id=" + id +
+                ", kind='" + kind + '\'' +
+                ", branch='" + branch + '\'' +
+                ", server='" + server + '\'' +
+                ", suite='" + suite + '\'' +
+                ", test='" + test + '\'' +
+                ", stage='" + stage + '\'' +
+                ", started='" + getStart() + '\'' +
+                ", duration='" + getDuration() + '\'' +
+                ", result='" + result + '\'' +
+                '}';
+        }
+    }
+}
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
index f0ac5557..eab55dc0 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
@@ -21,6 +21,9 @@ import java.util.Collections;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import javax.inject.Inject;
@@ -28,6 +31,7 @@ import 
org.apache.ignite.tcbot.engine.chain.BuildChainProcessor;
 import org.apache.ignite.tcbot.engine.chain.FullChainRunCtx;
 import org.apache.ignite.tcbot.engine.chain.LatestRebuildMode;
 import org.apache.ignite.tcbot.engine.chain.ProcessLogsMode;
+import org.apache.ignite.tcbot.engine.pool.TcUpdatePool;
 import org.apache.ignite.tcbot.engine.ui.DsChainUi;
 import org.apache.ignite.tcbot.engine.ui.DsSummaryUi;
 import org.apache.ignite.tcbot.persistence.IStringCompactor;
@@ -48,11 +52,74 @@ public class SingleBuildResultsService {
     @Inject BranchEquivalence branchEquivalence;
     @Inject IStringCompactor compactor;
     @Inject UpdateCountersStorage updateCounters;
+    @Inject AiPromptRequestMonitor aiPromptMonitor;
+    @Inject TcUpdatePool tcUpdatePool;
 
     @Nonnull public DsSummaryUi getSingleBuildResults(String srvCodeOrAlias, 
Integer buildId,
         @Nullable Boolean checkAllLogs, SyncMode syncMode, ICredentialsProv 
prov) {
         DsSummaryUi res = new DsSummaryUi();
 
+        FullChainRunCtx ctx = loadSingleBuildContext(srvCodeOrAlias, buildId, 
checkAllLogs, syncMode, prov);
+
+        ITeamcityIgnited tcIgnited = tcIgnitedProv.server(srvCodeOrAlias, 
prov);
+
+        String failRateBranch = ITeamcity.DEFAULT;
+
+        DsChainUi chainStatus = new DsChainUi(srvCodeOrAlias, 
tcIgnited.serverCode(), ctx.branchName());
+
+        chainStatus.initFromContext(tcIgnited, ctx, failRateBranch, compactor, 
false, null, null, -1, null, false, false);
+
+        res.addChainOnServer(chainStatus);
+
+        res.initCounters(getBranchCntrs(srvCodeOrAlias, buildId, prov));
+        return res;
+    }
+
+    /**
+     * @param srvCodeOrAlias Server id or alias.
+     * @param buildId Build id.
+     * @param maxDetailsChars Max chars to include for every test details 
block. Non-positive means default cap.
+     * @param syncMode Synchronization mode.
+     * @param prov Credentials provider.
+     */
+    @Nonnull public String getSingleBuildFailuresAiPrompt(String 
srvCodeOrAlias, Integer buildId,
+        @Nullable Integer maxDetailsChars, SyncMode syncMode, ICredentialsProv 
prov) {
+        long reqId = aiPromptMonitor.start("singleBuild", null, 
srvCodeOrAlias, String.valueOf(buildId), null);
+
+        try {
+            aiPromptMonitor.stage(reqId, "loading build context");
+
+            FullChainRunCtx ctx = loadSingleBuildContextBestEffort(reqId, 
srvCodeOrAlias, buildId, syncMode, prov);
+
+            ITeamcityIgnited tcIgnited = tcIgnitedProv.server(srvCodeOrAlias, 
prov);
+
+            int maxDetails = 
TestFailuresAiPromptBuilder.restMaxDetailsChars(maxDetailsChars);
+
+            aiPromptMonitor.stage(reqId, "building prompt");
+
+            String res = new TestFailuresAiPromptBuilder(compactor)
+                .buildPrompt(tcIgnited, ctx, ITeamcity.DEFAULT, maxDetails);
+
+            aiPromptMonitor.finish(reqId, "chars=" + res.length());
+
+            return res;
+        }
+        catch (RuntimeException e) {
+            aiPromptMonitor.fail(reqId, e);
+
+            throw e;
+        }
+    }
+
+    /**
+     * @param srvCodeOrAlias Server id or alias.
+     * @param buildId Build id.
+     * @param checkAllLogs Check all logs.
+     * @param syncMode Synchronization mode.
+     * @param prov Credentials provider.
+     */
+    private FullChainRunCtx loadSingleBuildContext(String srvCodeOrAlias, 
Integer buildId,
+        @Nullable Boolean checkAllLogs, SyncMode syncMode, ICredentialsProv 
prov) {
         tcIgnitedProv.checkAccess(srvCodeOrAlias, prov);
 
         ITeamcityIgnited tcIgnited = tcIgnitedProv.server(srvCodeOrAlias, 
prov);
@@ -72,16 +139,78 @@ public class SingleBuildResultsService {
             null,
             null);
 
-        DsChainUi chainStatus = new DsChainUi(srvCodeOrAlias, 
tcIgnited.serverCode(), ctx.branchName());
+        return ctx;
+    }
 
-        chainStatus.initFromContext(tcIgnited, ctx, failRateBranch, compactor, 
false, null, null, -1, null, false, false);
+    /**
+     * @param reqId Monitor request id.
+     * @param srvCodeOrAlias Server id or alias.
+     * @param buildId Build id.
+     * @param liveSyncMode Live sync mode.
+     * @param prov Credentials provider.
+     */
+    private FullChainRunCtx loadSingleBuildContextBestEffort(long reqId, 
String srvCodeOrAlias, Integer buildId,
+        SyncMode liveSyncMode, ICredentialsProv prov) {
+        Future<FullChainRunCtx> live = null;
+
+        try {
+            aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s");
+
+            live = tcUpdatePool.getService().submit(() ->
+                loadSingleBuildContext(srvCodeOrAlias, buildId, null, 
liveSyncMode, prov, ProcessLogsMode.CACHED_ONLY));
+
+            return live.get(1, TimeUnit.SECONDS);
+        }
+        catch (TimeoutException e) {
+            if (live != null)
+                live.cancel(true);
+
+            aiPromptMonitor.stage(reqId, "fresh context timed out, using stale 
cache");
+        }
+        catch (InterruptedException e) {
+            if (live != null)
+                live.cancel(true);
+
+            Thread.currentThread().interrupt();
+
+            aiPromptMonitor.stage(reqId, "fresh context interrupted");
+
+            throw new IllegalStateException("Interrupted while loading fresh 
TeamCity context", e);
+        }
+        catch (Exception e) {
+            aiPromptMonitor.stage(reqId, "fresh context failed, using stale 
cache: " + e.getMessage());
+        }
+
+        return loadSingleBuildContext(srvCodeOrAlias, buildId, null, 
SyncMode.NONE, prov, ProcessLogsMode.CACHED_ONLY);
+    }
 
-        res.addChainOnServer(chainStatus);
+    /**
+     * @param srvCodeOrAlias Server id or alias.
+     * @param buildId Build id.
+     * @param checkAllLogs Check all logs.
+     * @param syncMode Synchronization mode.
+     * @param prov Credentials provider.
+     * @param procLogs Process logs mode override.
+     */
+    private FullChainRunCtx loadSingleBuildContext(String srvCodeOrAlias, 
Integer buildId,
+        @Nullable Boolean checkAllLogs, SyncMode syncMode, ICredentialsProv 
prov, ProcessLogsMode procLogs) {
+        tcIgnitedProv.checkAccess(srvCodeOrAlias, prov);
 
-        res.initCounters(getBranchCntrs(srvCodeOrAlias, buildId, prov));
-        return res;
-    }
+        ITeamcityIgnited tcIgnited = tcIgnitedProv.server(srvCodeOrAlias, 
prov);
 
+        String failRateBranch = ITeamcity.DEFAULT;
+
+        return buildChainProcessor.loadFullChainContext(
+            tcIgnited,
+            Collections.singletonList(buildId),
+            LatestRebuildMode.NONE,
+            procLogs,
+            false,
+            failRateBranch,
+            syncMode,
+            null,
+            null);
+    }
 
 
     public Map<Integer, Integer> getBranchCntrs(String srvCodeOrAlias,
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
new file mode 100644
index 00000000..f262ca7e
--- /dev/null
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
@@ -0,0 +1,769 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.tcbot.engine.build;
+
+import com.google.common.base.Strings;
+import java.util.Comparator;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import org.apache.ignite.ci.teamcity.ignited.buildtype.ParametersCompacted;
+import org.apache.ignite.ci.teamcity.ignited.runhist.Invocation;
+import org.apache.ignite.tcbot.engine.chain.FullChainRunCtx;
+import org.apache.ignite.tcbot.engine.chain.MultBuildRunCtx;
+import org.apache.ignite.tcbot.engine.chain.SingleBuildRunCtx;
+import org.apache.ignite.tcbot.engine.chain.TestCompactedMult;
+import org.apache.ignite.tcbot.engine.ui.ShortTestFailureUi;
+import org.apache.ignite.tcbot.persistence.IStringCompactor;
+import org.apache.ignite.tcignited.ITeamcityIgnited;
+import org.apache.ignite.tcignited.build.ITest;
+import org.apache.ignite.tcignited.buildlog.ITestLogCheckResult;
+import org.apache.ignite.tcignited.history.InvocationData;
+import org.apache.ignite.tcignited.history.IRunHistory;
+import org.apache.ignite.tcservice.model.result.problems.ProblemOccurrence;
+
+import static 
org.apache.ignite.tcbot.common.util.TimeUtil.millisToDurationPrintable;
+import static 
org.apache.ignite.tcignited.buildref.BranchEquivalence.normalizeBranch;
+
+/**
+ * Builds an AI prompt with TeamCity failure context.
+ */
+public class TestFailuresAiPromptBuilder {
+    /** Default limit for a single TeamCity failure details block. */
+    public static final int DFLT_MAX_DETAILS_CHARS = 40000;
+
+    /** String compactor. */
+    private final IStringCompactor compactor;
+
+    /**
+     * @param compactor String compactor.
+     */
+    public TestFailuresAiPromptBuilder(IStringCompactor compactor) {
+        this.compactor = compactor;
+    }
+
+    /**
+     * @param maxDetailsChars Requested REST limit.
+     * @return Safe REST limit for a single TeamCity failure details block.
+     */
+    public static int restMaxDetailsChars(@Nullable Integer maxDetailsChars) {
+        if (maxDetailsChars == null || maxDetailsChars <= 0)
+            return DFLT_MAX_DETAILS_CHARS;
+
+        return Math.min(maxDetailsChars, DFLT_MAX_DETAILS_CHARS);
+    }
+
+    /**
+     * @param tcIgnited TeamCity facade.
+     * @param ctx Full chain context.
+     * @param baseBranchTc Base branch for failure-rate history.
+     * @param maxDetailsChars Max chars to include for every test details 
block. Non-positive means no limit.
+     */
+    @Nonnull public String buildPrompt(ITeamcityIgnited tcIgnited, 
FullChainRunCtx ctx,
+        @Nullable String baseBranchTc, int maxDetailsChars) {
+        return buildPrompt(tcIgnited, ctx, baseBranchTc, maxDetailsChars, 
null, null);
+    }
+
+    /**
+     * @param tcIgnited TeamCity facade.
+     * @param ctx Full chain context.
+     * @param baseBranchTc Base branch for failure-rate history.
+     * @param maxDetailsChars Max chars to include for every test details 
block. Non-positive means no limit.
+     * @param testNameFilter Optional full test name filter.
+     */
+    @Nonnull public String buildPrompt(ITeamcityIgnited tcIgnited, 
FullChainRunCtx ctx,
+        @Nullable String baseBranchTc, int maxDetailsChars, @Nullable String 
testNameFilter) {
+        return buildPrompt(tcIgnited, ctx, baseBranchTc, maxDetailsChars, 
testNameFilter, null);
+    }
+
+    /**
+     * @param tcIgnited TeamCity facade.
+     * @param ctx Full chain context.
+     * @param baseBranchTc Base branch for failure-rate history.
+     * @param maxDetailsChars Max chars to include for every test details 
block. Non-positive means no limit.
+     * @param testNameFilter Optional full test name filter.
+     * @param suiteIdFilter Optional suite id filter.
+     */
+    @Nonnull public String buildPrompt(ITeamcityIgnited tcIgnited, 
FullChainRunCtx ctx,
+        @Nullable String baseBranchTc, int maxDetailsChars, @Nullable String 
testNameFilter,
+        @Nullable String suiteIdFilter) {
+        StringBuilder res = new StringBuilder();
+
+        String normalizedBaseBranch = normalizeBranch(baseBranchTc);
+        Integer baseBranchId = 
compactor.getStringIdIfPresent(normalizedBaseBranch);
+
+        appendHeader(res, tcIgnited, ctx, normalizedBaseBranch);
+
+        AtomicInteger suiteCnt = new AtomicInteger();
+        AtomicInteger testCnt = new AtomicInteger();
+
+        List<MultBuildRunCtx> failedSuites = ctx.suites()
+            .filter(suite -> !suite.isComposite())
+            .filter(MultBuildRunCtx::isFailed)
+            .filter(suite -> suiteIdFilter == null || 
suiteIdFilter.equals(suite.suiteId()))
+            .filter(suite -> testNameFilter == null
+                || suite.getFailedTests().stream().anyMatch(test -> 
testNameFilter.equals(test.getName())))
+            .collect(Collectors.toList());
+
+        if (failedSuites.isEmpty())
+            res.append("No failed suites were found in the TeamCity 
context.\n");
+
+        failedSuites.forEach(suite -> {
+            suiteCnt.incrementAndGet();
+            appendSuite(res, tcIgnited, suite, baseBranchId);
+
+            List<TestCompactedMult> failedTests = suite.getFailedTests()
+                .stream()
+                .filter(test -> testNameFilter == null || 
testNameFilter.equals(test.getName()))
+                .collect(Collectors.toList());
+
+            if (failedTests.isEmpty())
+                appendSuiteFailure(res, suite);
+            else {
+                for (TestCompactedMult test : failedTests) {
+                    testCnt.incrementAndGet();
+                    appendTest(res, tcIgnited, suite, test, baseBranchId, 
maxDetailsChars);
+                }
+            }
+        });
+
+        res.append("## Included Scope\n");
+        res.append("Failed suites included: 
").append(suiteCnt.get()).append('\n');
+        res.append("Failed tests included: 
").append(testCnt.get()).append('\n');
+
+        return res.toString();
+    }
+
+    /**
+     * @param res Result builder.
+     * @param tcIgnited TeamCity facade.
+     * @param ctx Full chain context.
+     * @param normalizedBaseBranch Normalized base branch.
+     */
+    private void appendHeader(StringBuilder res, ITeamcityIgnited tcIgnited, 
FullChainRunCtx ctx,
+        @Nullable String normalizedBaseBranch) {
+        res.append("# AI Prompt: Investigate TeamCity Failure\n\n");
+        res.append("You are in the local checkout of the project that produced 
this TeamCity failure. ");
+        res.append("Use this compact investigation brief to find the likely 
root cause with minimal assumptions.\n\n");
+
+        res.append("## Run Context\n");
+        appendLine(res, "TeamCity server", tcIgnited.serverCode());
+        appendLine(res, "Chain", ctx.suiteName());
+        appendLine(res, "Suite id", ctx.suiteId());
+        appendLine(res, "Branch", ctx.branchName());
+        appendLine(res, "Base branch for history", normalizedBaseBranch);
+        appendLine(res, "Entry build id", 
String.valueOf(ctx.getSuiteBuildId()));
+        appendLine(res, "Entry build", tcIgnited.host() + 
"viewLog.html?buildId=" + ctx.getSuiteBuildId());
+        appendLine(res, "Duration", ctx.getDurationPrintable(suite -> true));
+        res.append('\n');
+
+    }
+
+    /**
+     * @param res Result builder.
+     * @param tcIgnited TeamCity facade.
+     * @param suite Suite context.
+     * @param baseBranchId Base branch compacted id.
+     */
+    private void appendSuite(StringBuilder res, ITeamcityIgnited tcIgnited, 
MultBuildRunCtx suite,
+        @Nullable Integer baseBranchId) {
+        IRunHistory baseHist = suite.history(tcIgnited, baseBranchId, null);
+
+        res.append("## Run Context\n");
+        res.append("Suite: 
").append(nullToUnknown(suite.suiteName())).append('\n');
+        appendLine(res, "Suite id", suite.suiteId());
+        appendLine(res, "Build id", String.valueOf(suite.getBuildId()));
+        appendLine(res, "Build", tcIgnited.host() + "viewLog.html?buildId=" + 
suite.getBuildId());
+        appendLine(res, "Branch", suite.branchName());
+        appendLine(res, "Result", suite.getResult());
+        appendLine(res, "Failed tests", String.valueOf(suite.failedTests()));
+        appendLine(res, "Duration", 
millisToDurationPrintable(suite.buildDuration()));
+        appendLine(res, "Execution parameters", executionParameters(suite));
+        appendLine(res, "Last change users", 
compactCsv(suite.lastChangeUsers().collect(Collectors.toList())));
+
+        if (baseHist != null) {
+            appendLine(res, "Base branch suite failure rate", 
baseHist.getFailPercentPrintable() + "%");
+            appendLine(res, "Base branch suite critical failure rate",
+                baseHist.getCriticalFailPercentPrintable() + "%");
+        }
+
+        String blockerComment = suite.getPossibleBlockerComment(compactor, 
baseHist, tcIgnited.config());
+        appendLine(res, "Possible blocker note", blockerComment);
+
+        res.append('\n');
+    }
+
+    /**
+     * @param res Result builder.
+     * @param suite Suite context.
+     */
+    private void appendProblems(StringBuilder res, MultBuildRunCtx suite) {
+        List<ProblemOccurrence> problems = suite.allProblemsInAllBuilds()
+            .map(problem -> problem.toProblemOccurrence(compactor, 
suite.getBuildId()))
+            .collect(Collectors.toList());
+
+        if (problems.isEmpty())
+            return;
+
+        res.append("TeamCity build problems:\n");
+
+        for (ProblemOccurrence problem : problems) {
+            res.append("- type=").append(nullToUnknown(problem.type));
+            res.append(", identity=").append(nullToUnknown(problem.identity));
+
+            if (problem.buildRef != null && problem.buildRef.getId() != null)
+                res.append(", 
actualBuildId=").append(problem.buildRef.getId());
+
+            res.append('\n');
+        }
+    }
+
+    /**
+     * @param res Result builder.
+     * @param suite Suite context.
+     */
+    private void appendLogChecks(StringBuilder res, MultBuildRunCtx suite) {
+        List<Map.Entry<String, ITestLogCheckResult>> warnings = 
suite.getLogsCheckResults()
+            .flatMap(map -> map.entrySet().stream())
+            .filter(entry -> entry.getValue() != null && 
entry.getValue().hasWarns())
+            .collect(Collectors.toList());
+
+        if (warnings.isEmpty())
+            return;
+
+        res.append("Build log scanner warnings:\n");
+
+        for (Map.Entry<String, ITestLogCheckResult> entry : warnings) {
+            ITestLogCheckResult checkRes = entry.getValue();
+
+            res.append("- ").append(nullToUnknown(entry.getKey()));
+            res.append(" (log size 
").append(checkRes.getLogSizeBytes()).append(" bytes)\n");
+
+            for (String warn : checkRes.getWarns())
+                res.append("  ").append(warn).append('\n');
+        }
+    }
+
+    /**
+     * @param res Result builder.
+     * @param tcIgnited TeamCity facade.
+     * @param suite Suite context.
+     * @param test Test context.
+     * @param baseBranchId Base branch compacted id.
+     * @param maxDetailsChars Max details chars.
+     */
+    private void appendTest(StringBuilder res, ITeamcityIgnited tcIgnited, 
MultBuildRunCtx suite,
+        TestCompactedMult test, @Nullable Integer baseBranchId, int 
maxDetailsChars) {
+        String fullName = test.getName();
+        IRunHistory testHist = test.history(tcIgnited, baseBranchId);
+
+        res.append("## Failure Signal\n");
+        appendLine(res, "Full test name", fullName);
+        appendLine(res, "Suite before colon", testSuitePart(fullName));
+        appendLine(res, "Test after colon", testCasePart(fullName));
+        appendProblems(res, suite);
+
+        AtomicInteger invocationIdx = new AtomicInteger();
+
+        test.getInvocationsStream()
+            .filter(Objects::nonNull)
+            .filter(invocation -> invocation.isFailedButNotMuted(compactor))
+            .forEach(invocation -> appendInvocation(res, tcIgnited, suite, 
invocation, fullName,
+                invocationIdx.incrementAndGet(), maxDetailsChars));
+
+        appendRelevantLogChecks(res, suite, fullName);
+
+        res.append("\n## Changed Files / PR Context\n");
+        res.append("Changed files and patches are not available in this cached 
TeamCity bot context. ");
+        res.append("Inspect the local checkout diff/PR metadata if needed, 
then compare changed packages with the failing test/module.\n");
+
+        res.append("\n## Test Context\n");
+        appendLine(res, "Full test name", fullName);
+        appendLine(res, "Suite before colon", testSuitePart(fullName));
+        appendLine(res, "Test after colon", testCasePart(fullName));
+        appendLine(res, "Short suite", 
ShortTestFailureUi.extractSuite(Strings.nullToEmpty(testSuitePart(fullName))));
+        appendLine(res, "Short test", extractShortTest(fullName));
+        appendLine(res, "Failures in loaded context", 
String.valueOf(test.failuresCount()));
+        appendLine(res, "Investigated in TeamCity", 
String.valueOf(test.isInvestigated()));
+        appendLine(res, "Possible blocker note", 
test.getPossibleBlockerComment(testHist));
+
+        if (testHist != null) {
+            res.append("\n## History\n");
+            appendLine(res, "Base branch test failure rate", 
testHist.getFailPercentPrintable() + "%");
+            appendLine(res, "Base branch flaky", 
String.valueOf(testHist.isFlaky()));
+            appendLine(res, "Base branch latest runs", 
String.valueOf(testHist.getLatestRunResults()));
+            appendLine(res, "Break boundary", breakBoundary(testHist));
+            appendLine(res, "Recent execution history", 
recentHistory(testHist, 12));
+        }
+
+        appendInvestigationInstructions(res);
+
+        res.append('\n');
+    }
+
+    /**
+     * @param res Result builder.
+     * @param suite Suite context.
+     */
+    private void appendSuiteFailure(StringBuilder res, MultBuildRunCtx suite) {
+        res.append("## Failure Signal\n");
+        res.append("No failed non-muted test was reported for this suite. 
Investigate this as a suite/build-level failure.\n");
+        appendProblems(res, suite);
+        appendEmptyDetailsDiagnosis(res, suite);
+
+        res.append("\n## Log Context\n");
+        appendLogChecks(res, suite);
+
+        res.append("\n## Changed Files / PR Context\n");
+        res.append("Changed files and patches are not available in this cached 
TeamCity bot context. ");
+        res.append("Inspect the local checkout diff/PR metadata if needed, 
then compare changed packages with the failed suite/module.\n");
+
+        res.append("\n## Test Context\n");
+        appendLine(res, "Suite id", suite.suiteId());
+        appendLine(res, "Suite name", suite.suiteName());
+        appendLine(res, "Failed tests reported", 
String.valueOf(suite.failedTests()));
+        appendLine(res, "Total tests", String.valueOf(suite.totalTests()));
+
+        appendInvestigationInstructions(res);
+
+        res.append('\n');
+    }
+
+    /**
+     * @param res Result builder.
+     * @param tcIgnited TeamCity facade.
+     * @param suite Suite context.
+     * @param invocation Single test invocation.
+     * @param idx Invocation index.
+     * @param maxDetailsChars Max details chars.
+     */
+    private void appendInvocation(StringBuilder res, ITeamcityIgnited 
tcIgnited, MultBuildRunCtx suite,
+        ITest invocation, @Nullable String fullName, int idx, int 
maxDetailsChars) {
+        int actualBuildId = invocation.getActualBuildId() > 0 ? 
invocation.getActualBuildId() : suite.getBuildId();
+
+        res.append("Invocation ").append(idx).append(":\n");
+        appendLine(res, "Full test name", fullName);
+        appendLine(res, "Status", 
compactor.getStringFromId(invocation.status()));
+        Integer duration = invocation.getDuration();
+
+        appendLine(res, "Duration", millisToDurationPrintable(duration == null 
? null : duration.longValue()));
+        appendLine(res, "Actual build id", String.valueOf(actualBuildId));
+        appendLine(res, "Build URL", tcIgnited.host() + 
"viewLog.html?buildId=" + actualBuildId);
+        appendLine(res, "TeamCity test id", 
String.valueOf(invocation.getTestId()));
+        appendLine(res, "TeamCity test REST", testOccurrenceRestUrl(tcIgnited, 
invocation, actualBuildId));
+        appendLine(res, "Muted", String.valueOf(invocation.isMutedTest()));
+        appendLine(res, "Ignored", String.valueOf(invocation.isIgnoredTest()));
+        appendLine(res, "Currently muted", 
String.valueOf(invocation.getCurrentlyMuted()));
+        appendLine(res, "Currently investigated", 
String.valueOf(invocation.getCurrInvestigatedFlag()));
+
+        String details = invocation.getDetailsText();
+
+        if (Strings.isNullOrEmpty(details))
+            appendEmptyDetailsDiagnosis(res, suite);
+        else {
+            String cleanedDetails = cleanDetails(details);
+
+            res.append("First real failure signal:\n");
+            res.append("```text\n");
+            res.append(limit(firstFailureSignal(cleanedDetails), 6000));
+            res.append("\n```\n");
+
+            res.append("Failure details text from TeamCity:\n");
+            res.append("```text\n");
+            res.append(limit(cleanedDetails, maxDetailsChars));
+            res.append("\n```\n");
+        }
+    }
+
+    /**
+     * @param res Result builder.
+     * @param suite Suite context.
+     * @param fullName Full test name.
+     */
+    private void appendRelevantLogChecks(StringBuilder res, MultBuildRunCtx 
suite, @Nullable String fullName) {
+        List<String> snippets = new ArrayList<>();
+
+        suite.getLogsCheckResults()
+            .flatMap(map -> map.entrySet().stream())
+            .filter(entry -> isSameTestLog(entry.getKey(), fullName))
+            .filter(entry -> entry.getValue() != null)
+            .forEach(entry -> {
+                ITestLogCheckResult checkRes = entry.getValue();
+
+                if (!checkRes.hasWarns())
+                    return;
+
+                snippets.add("Log grep for " + entry.getKey() + " (log size "
+                    + checkRes.getLogSizeBytes() + " bytes):");
+
+                snippets.addAll(checkRes.getWarns());
+            });
+
+        if (snippets.isEmpty()) {
+            res.append("\n## Log Context\n");
+            res.append("Cached build log analysis has no warning snippets for 
this test. ");
+            res.append("Raw 200-500 line windows around test start/end are not 
available in the current bot cache.\n");
+
+            return;
+        }
+
+        res.append("\n## Log Context\n");
+        res.append("Cached log grep from processed build log:\n");
+        res.append("```text\n");
+
+        for (String snippet : snippets)
+            res.append(snippet).append('\n');
+
+        res.append("```\n");
+    }
+
+    /**
+     * @param logTestName Test name from log scanner.
+     * @param fullName Full TeamCity test name.
+     */
+    private boolean isSameTestLog(@Nullable String logTestName, @Nullable 
String fullName) {
+        if (Strings.isNullOrEmpty(logTestName) || 
Strings.isNullOrEmpty(fullName))
+            return false;
+
+        if (Objects.equals(logTestName, fullName))
+            return true;
+
+        String shortTest = extractShortTest(fullName);
+
+        return !Strings.isNullOrEmpty(shortTest) && 
logTestName.contains(shortTest);
+    }
+
+    /**
+     * @param fullName Full test name.
+     */
+    @Nullable private String extractShortTest(@Nullable String fullName) {
+        if (fullName == null)
+            return null;
+
+        String[] split = fullName.split("\\:");
+
+        if (split.length < 2)
+            return null;
+
+        return ShortTestFailureUi.extractTest(split[1]);
+    }
+
+    /**
+     * @param fullName Full test name.
+     */
+    @Nullable private String testSuitePart(@Nullable String fullName) {
+        if (fullName == null)
+            return null;
+
+        int colon = fullName.indexOf(':');
+
+        if (colon < 0)
+            return fullName;
+
+        return fullName.substring(0, colon).trim();
+    }
+
+    /**
+     * @param fullName Full test name.
+     */
+    @Nullable private String testCasePart(@Nullable String fullName) {
+        if (fullName == null)
+            return null;
+
+        int colon = fullName.indexOf(':');
+
+        if (colon < 0 || colon + 1 >= fullName.length())
+            return null;
+
+        return fullName.substring(colon + 1).trim();
+    }
+
+    /**
+     * @param tcIgnited TeamCity facade.
+     * @param invocation Invocation.
+     * @param actualBuildId Actual build id.
+     */
+    @Nullable private String testOccurrenceRestUrl(ITeamcityIgnited tcIgnited, 
ITest invocation, int actualBuildId) {
+        if (invocation.idInBuild() < 0 || actualBuildId <= 0)
+            return null;
+
+        return tcIgnited.host() + "app/rest/latest/testOccurrences/id:" + 
invocation.idInBuild()
+            + ",build:(id:" + actualBuildId + ")";
+    }
+
+    /**
+     * @param res Result builder.
+     * @param name Field name.
+     * @param val Field value.
+     */
+    private void appendLine(StringBuilder res, String name, @Nullable String 
val) {
+        if (Strings.isNullOrEmpty(val))
+            return;
+
+        res.append("- ").append(name).append(": ").append(val).append('\n');
+    }
+
+    /**
+     * @param val Value.
+     */
+    private String nullToUnknown(@Nullable String val) {
+        return Strings.isNullOrEmpty(val) ? "<unknown>" : val;
+    }
+
+    /**
+     * @param text Text.
+     * @param maxChars Limit.
+     */
+    private String limit(String text, int maxChars) {
+        if (maxChars <= 0 || text.length() <= maxChars)
+            return text;
+
+        return text.substring(0, maxChars)
+            + "\n\n... truncated " + (text.length() - maxChars) + " chars. "
+            + "Increase maxDetailsChars up to the server cap if more details 
are needed.";
+    }
+
+    /**
+     * @param details Details from TeamCity.
+     */
+    private String cleanDetails(String details) {
+        return details.trim()
+            .replace(" ------- Stdout: ------- ", "\n\n------- Stdout: 
-------\n")
+            .replace(" ------- Stderr: ------- ", "\n\n------- Stderr: 
-------\n");
+    }
+
+    /**
+     * @param details Details from TeamCity.
+     */
+    private String firstFailureSignal(String details) {
+        String normalized = details
+            .replace(" Caused by: ", "\nCaused by: ")
+            .replace(" at ", "\n    at ");
+
+        String[] lines = normalized.split("\\r?\\n");
+        int start = -1;
+
+        for (int i = 0; i < lines.length; i++) {
+            String line = lines[i];
+
+            if (line.contains("Exception")
+                || line.contains("Assertion")
+                || line.contains("Error")
+                || line.contains("Caused by:")
+                || line.contains("Test failed")
+                || line.contains("Failed")) {
+                start = i;
+
+                break;
+            }
+        }
+
+        if (start < 0)
+            return limit(normalized, 6000);
+
+        StringBuilder res = new StringBuilder();
+
+        for (int i = start; i < lines.length && i < start + 80; i++)
+            res.append(lines[i]).append('\n');
+
+        return res.toString().trim();
+    }
+
+    /**
+     * @param res Result builder.
+     * @param suite Suite context.
+     */
+    private void appendEmptyDetailsDiagnosis(StringBuilder res, 
MultBuildRunCtx suite) {
+        res.append("TeamCity failure details: <empty>\n");
+        res.append("Likely empty-details category: ");
+
+        if (suite.hasTimeoutProblem())
+            res.append("timeout or test process killed before reporting a 
stack trace");
+        else if (suite.hasJvmCrashProblem())
+            res.append("JVM crash");
+        else if (suite.hasOomeProblem())
+            res.append("OOM / process killed");
+        else if (suite.hasExitCodeProblem())
+            res.append("test process exited with non-zero code");
+        else if (suite.hasProblemNonByFailedTest())
+            res.append("build problem without a test stack");
+        else if (suite.failedTests() == 0)
+            res.append("orphaned test/reporting issue");
+        else
+            res.append("reporting issue or TeamCity did not persist details");
+
+        res.append(".\n");
+    }
+
+    /**
+     * @param res Result builder.
+     */
+    private void appendInvestigationInstructions(StringBuilder res) {
+        res.append("\n## Investigation Instructions\n");
+        res.append("- Start from the First real failure signal and nearest 
project frame.\n");
+        res.append("- Search for the failing class, method, assertion text, 
and error message in the checkout.\n");
+        res.append("- Compare test history and suite history separately before 
calling it flaky.\n");
+        res.append("- Classify the cause as exactly one of: caused by current 
change; pre-existing flaky test; ");
+        res.append("environmental/infra; product bug exposed by test; 
inconclusive.\n");
+
+        res.append("\n## Required Final Answer\n");
+        res.append("- Likely root cause.\n");
+        res.append("- Confidence level.\n");
+        res.append("- Files/classes/methods to inspect.\n");
+        res.append("- Minimal fix or mitigation.\n");
+        res.append("- Whether retry is justified.\n");
+        res.append("- Extra artifact/log that would confirm the diagnosis.\n");
+    }
+
+    /**
+     * @param vals Values.
+     */
+    private String compactCsv(List<String> vals) {
+        if (vals == null || vals.isEmpty())
+            return null;
+
+        return vals.stream()
+            .filter(v -> !Strings.isNullOrEmpty(v))
+            .distinct()
+            .limit(10)
+            .collect(Collectors.joining(", "));
+    }
+
+    /**
+     * @param suite Suite context.
+     */
+    @Nullable private String executionParameters(MultBuildRunCtx suite) {
+        List<String> params = new ArrayList<>();
+
+        suite.buildsStream()
+            .map(SingleBuildRunCtx::buildParameters)
+            .filter(Objects::nonNull)
+            .forEach(buildParams -> collectInterestingParameters(buildParams, 
params));
+
+        if (params.isEmpty())
+            return null;
+
+        return 
params.stream().distinct().limit(20).collect(Collectors.joining("; "));
+    }
+
+    /**
+     * @param buildParams Build parameters.
+     * @param params Destination.
+     */
+    private void collectInterestingParameters(ParametersCompacted buildParams, 
List<String> params) {
+        buildParams.forEach(compactor, (key, val) -> {
+            String lower = key == null ? "" : key.toLowerCase();
+
+            if (lower.contains("os")
+                || lower.contains("agent")
+                || lower.contains("java")
+                || lower.contains("jdk")
+                || lower.contains("jvm")
+                || lower.contains("runner")
+                || lower.contains("env."))
+                params.add(key + "=" + val);
+        });
+    }
+
+    /**
+     * @param hist Run history.
+     */
+    @Nullable private String breakBoundary(IRunHistory hist) {
+        List<Invocation> invocations = hist.getInvocations()
+            .filter(inv -> inv.status() != InvocationData.MISSING)
+            .filter(inv -> !Invocation.isMutedOrIgnored(inv.status()))
+            .sorted(Comparator.comparing(Invocation::buildId))
+            .collect(Collectors.toList());
+
+        Invocation lastOk = null;
+        Invocation firstBadAfterOk = null;
+
+        for (Invocation inv : invocations) {
+            if (inv.status() == InvocationData.OK) {
+                lastOk = inv;
+                firstBadAfterOk = null;
+            }
+            else if (isFailure(inv) && lastOk != null && firstBadAfterOk == 
null)
+                firstBadAfterOk = inv;
+        }
+
+        if (lastOk == null || firstBadAfterOk == null)
+            return null;
+
+        return "last OK build " + lastOk.buildId()
+            + ", first later failure build " + firstBadAfterOk.buildId()
+            + ", change state at first failure " + 
firstBadAfterOk.changesState();
+    }
+
+    /**
+     * @param hist Run history.
+     * @param limit Limit.
+     */
+    @Nullable private String recentHistory(IRunHistory hist, int limit) {
+        List<Invocation> recent = hist.getInvocations()
+            .filter(inv -> inv.status() != InvocationData.MISSING)
+            .sorted(Comparator.comparing(Invocation::buildId).reversed())
+            .limit(limit)
+            .sorted(Comparator.comparing(Invocation::buildId))
+            .collect(Collectors.toList());
+
+        if (recent.isEmpty())
+            return null;
+
+        return recent.stream()
+            .map(inv -> inv.buildId() + ":" + statusName(inv.status()) + ":" + 
inv.changesState())
+            .collect(Collectors.joining(", "));
+    }
+
+    /**
+     * @param inv Invocation.
+     */
+    private boolean isFailure(Invocation inv) {
+        return inv.status() == InvocationData.FAILURE || inv.status() == 
InvocationData.CRITICAL_FAILURE;
+    }
+
+    /**
+     * @param status Status.
+     */
+    private String statusName(byte status) {
+        if (status == InvocationData.OK)
+            return "OK";
+
+        if (status == InvocationData.FAILURE)
+            return "FAIL";
+
+        if (status == InvocationData.CRITICAL_FAILURE)
+            return "CRITICAL_FAIL";
+
+        if (status == InvocationData.MISSING)
+            return "MISSING";
+
+        if (status == InvocationData.IGNORED)
+            return "IGNORED";
+
+        if (Invocation.isMutedOrIgnored(status))
+            return "MUTED_OR_IGNORED";
+
+        return String.valueOf(status);
+    }
+}
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/BuildChainProcessor.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/BuildChainProcessor.java
index 8e04a280..02a99fb4 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/BuildChainProcessor.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/BuildChainProcessor.java
@@ -201,7 +201,8 @@ public class BuildChainProcessor {
 
             final MultBuildRunCtx ctx = new MultBuildRunCtx(ref, compactor);
 
-            buildsForSuite.forEach(buildCompacted -> 
ctx.addBuild(loadChanges(buildCompacted, tcIgn)));
+            buildsForSuite.forEach(buildCompacted -> 
ctx.addBuild(loadChanges(buildCompacted, tcIgn,
+                mode == SyncMode.NONE)));
 
             //ask for history for the suite in parallel
             tcUpdatePool.getService().submit(() -> {
@@ -310,9 +311,22 @@ public class BuildChainProcessor {
      */
     public SingleBuildRunCtx loadChanges(@Nonnull FatBuildCompacted 
buildCompacted,
         ITeamcityIgnited tcIgnited) {
+        return loadChanges(buildCompacted, tcIgnited, false);
+    }
+
+    /**
+     * @param buildCompacted Build ref from history with references to tests.
+     * @param tcIgnited TC connection.
+     * @param cachedOnly Use only data already present in the fat build cache.
+     * @return Full context.
+     */
+    public SingleBuildRunCtx loadChanges(@Nonnull FatBuildCompacted 
buildCompacted,
+        ITeamcityIgnited tcIgnited,
+        boolean cachedOnly) {
         SingleBuildRunCtx ctx = new SingleBuildRunCtx(buildCompacted, 
compactor);
 
-        ctx.setChanges(tcIgnited.getAllChanges(buildCompacted.changes()));
+        if (!cachedOnly)
+            ctx.setChanges(tcIgnited.getAllChanges(buildCompacted.changes()));
 
         
ctx.addTags(SingleBuildRunCtx.getBuildTagsFromParameters(tcIgnited.config(), 
compactor, buildCompacted));
 
@@ -493,7 +507,14 @@ public class BuildChainProcessor {
                                 ProcessLogsMode procLog) {
         for (SingleBuildRunCtx ctx : outCtx.getBuilds()) {
             boolean incompleteFailure = ctx.hasSuiteIncompleteFailure();
-            if ((procLog == ProcessLogsMode.SUITE_NOT_COMPLETE && 
incompleteFailure)
+            if (procLog == ProcessLogsMode.CACHED_ONLY) {
+                ILogCheckResult logCheckRes = 
buildLogProcessor.getCachedBuildLogAnalysis(teamcity.serverCode(),
+                    ctx.buildId());
+
+                if (logCheckRes != null)
+                    
ctx.setLogCheckResFut(CompletableFuture.completedFuture(logCheckRes));
+            }
+            else if ((procLog == ProcessLogsMode.SUITE_NOT_COMPLETE && 
incompleteFailure)
                     || procLog == ProcessLogsMode.ALL)
                 ctx.setLogCheckResFut(
                         CompletableFuture.supplyAsync(
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/ProcessLogsMode.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/ProcessLogsMode.java
index 5203ba00..3ad75f0c 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/ProcessLogsMode.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/ProcessLogsMode.java
@@ -26,6 +26,9 @@ public enum ProcessLogsMode {
     /** Parse logs if suite not completed. */
     SUITE_NOT_COMPLETE,
 
+    /** Use cached log analysis only, never download logs from TeamCity. */
+    CACHED_ONLY,
+
     /** Always parse logs. */
     ALL
 }
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
index b1a85065..d0634103 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
@@ -267,6 +267,10 @@ public class SingleBuildRunCtx implements ISuiteResults {
         return tags;
     }
 
+    @Nullable public ParametersCompacted buildParameters() {
+        return buildCompacted.parameters();
+    }
+
     public static Set<String> getBuildTagsFromParameters(ITcServerConfig tcCfg,
         IStringCompactor compactor, FatBuildCompacted fatBuildCompacted) {
         ParametersCompacted parameters = fatBuildCompacted.parameters();
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
index 5f197e0c..f47b9898 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
@@ -24,6 +24,9 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
 import javax.annotation.Nullable;
@@ -41,10 +44,13 @@ import org.apache.ignite.tcbot.engine.chain.FullChainRunCtx;
 import org.apache.ignite.tcbot.engine.chain.LatestRebuildMode;
 import org.apache.ignite.tcbot.engine.chain.MultBuildRunCtx;
 import org.apache.ignite.tcbot.engine.chain.ProcessLogsMode;
+import org.apache.ignite.tcbot.engine.build.AiPromptRequestMonitor;
+import org.apache.ignite.tcbot.engine.build.TestFailuresAiPromptBuilder;
 import org.apache.ignite.tcbot.engine.conf.ITcBotConfig;
 import org.apache.ignite.tcbot.engine.conf.ITrackedBranch;
 import org.apache.ignite.tcbot.engine.conf.ITrackedChain;
 import org.apache.ignite.tcbot.engine.newtests.NewTestsStorage;
+import org.apache.ignite.tcbot.engine.pool.TcUpdatePool;
 import org.apache.ignite.tcbot.engine.ui.DsChainUi;
 import org.apache.ignite.tcbot.engine.ui.DsSummaryUi;
 import org.apache.ignite.tcbot.engine.ui.ShortSuiteUi;
@@ -96,6 +102,12 @@ public class PrChainsProcessor {
 
     @Inject private NewTestsStorage newTestsStorage;
 
+    /** AI prompt request monitor. */
+    @Inject private AiPromptRequestMonitor aiPromptMonitor;
+
+    /** TC update pool for best-effort AI prompt refreshes. */
+    @Inject private TcUpdatePool tcUpdatePool;
+
     /**
      * @param creds Credentials.
      * @param srvCodeOrAlias Server code or alias.
@@ -432,4 +444,139 @@ public class PrChainsProcessor {
 
         return countersStorage.getCounters(allRelatedBranchCodes);
     }
+
+    /**
+     * @param creds Credentials.
+     * @param srvCodeOrAlias Server code or alias.
+     * @param suiteId Suite id.
+     * @param branchForTc Branch name in TC identification.
+     * @param act Action.
+     * @param cnt Count.
+     * @param tcBaseBranchParm Base branch name in TC identification.
+     * @param maxDetailsChars Max chars to include for every test details 
block. Non-positive means default cap.
+     * @param testName Optional full test name filter.
+     * @param promptSuiteId Optional suite id filter.
+     * @return AI prompt with PR failure context.
+     */
+    @AutoProfiling
+    public String getPrFailuresAiPrompt(
+        ICredentialsProv creds,
+        String srvCodeOrAlias,
+        String suiteId,
+        String branchForTc,
+        String act,
+        Integer cnt,
+        @Nullable String tcBaseBranchParm,
+        int maxDetailsChars,
+        @Nullable String testName,
+        @Nullable String promptSuiteId) {
+        long reqId = aiPromptMonitor.start("pr", branchForTc, srvCodeOrAlias, 
suiteId, testName);
+
+        try {
+            ITeamcityIgnited tcIgnited = 
tcIgnitedProvider.server(srvCodeOrAlias, creds);
+
+            LatestRebuildMode rebuild;
+            if (Action.HISTORY.equals(act))
+                rebuild = LatestRebuildMode.ALL;
+            else if (Action.CHAIN.equals(act))
+                rebuild = LatestRebuildMode.NONE;
+            else
+                rebuild = LatestRebuildMode.LATEST;
+
+            int buildResMergeCnt = rebuild == LatestRebuildMode.ALL ? cnt == 
null ? 10 : cnt : 1;
+
+            aiPromptMonitor.stage(reqId, "loading history: " + srvCodeOrAlias 
+ "/" + suiteId);
+
+            List<Integer> hist = tcIgnited.getLastNBuildsFromHistory(suiteId, 
branchForTc, buildResMergeCnt);
+
+            String baseBranchForTc = Strings.isNullOrEmpty(tcBaseBranchParm)
+                ? dfltBaseTcBranch(srvCodeOrAlias)
+                : tcBaseBranchParm;
+
+            aiPromptMonitor.stage(reqId, "loading chain context: " + 
srvCodeOrAlias + "/" + suiteId);
+
+            FullChainRunCtx ctx = loadAiPromptContextBestEffort(reqId, 
tcIgnited, hist, rebuild,
+                buildResMergeCnt == 1, baseBranchForTc, srvCodeOrAlias + "/" + 
suiteId);
+
+            aiPromptMonitor.stage(reqId, "building prompt: " + srvCodeOrAlias 
+ "/" + suiteId);
+
+            String res = new TestFailuresAiPromptBuilder(compactor)
+                .buildPrompt(tcIgnited, ctx, baseBranchForTc,
+                    
TestFailuresAiPromptBuilder.restMaxDetailsChars(maxDetailsChars), testName, 
promptSuiteId);
+
+            aiPromptMonitor.finish(reqId, "chars=" + res.length());
+
+            return res;
+        }
+        catch (RuntimeException e) {
+            aiPromptMonitor.fail(reqId, e);
+
+            throw e;
+        }
+    }
+
+    /**
+     * @param reqId Monitor request id.
+     * @param tcIgnited TeamCity facade.
+     * @param hist Entry builds.
+     * @param rebuild Rebuild mode.
+     * @param includeScheduledInfo Include scheduled info.
+     * @param baseBranchForTc Base branch.
+     * @param stageSuffix Stage suffix.
+     */
+    private FullChainRunCtx loadAiPromptContextBestEffort(
+        long reqId,
+        ITeamcityIgnited tcIgnited,
+        List<Integer> hist,
+        LatestRebuildMode rebuild,
+        boolean includeScheduledInfo,
+        String baseBranchForTc,
+        String stageSuffix) {
+        Future<FullChainRunCtx> live = null;
+
+        try {
+            aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s: " 
+ stageSuffix);
+
+            live = tcUpdatePool.getService().submit(() -> 
buildChainProcessor.loadFullChainContext(
+                tcIgnited,
+                hist,
+                rebuild,
+                ProcessLogsMode.CACHED_ONLY,
+                includeScheduledInfo,
+                baseBranchForTc,
+                SyncMode.RELOAD_QUEUED,
+                null, null));
+
+            return live.get(1, TimeUnit.SECONDS);
+        }
+        catch (TimeoutException e) {
+            if (live != null)
+                live.cancel(true);
+
+            aiPromptMonitor.stage(reqId, "fresh context timed out, using stale 
cache: " + stageSuffix);
+        }
+        catch (InterruptedException e) {
+            if (live != null)
+                live.cancel(true);
+
+            Thread.currentThread().interrupt();
+
+            aiPromptMonitor.stage(reqId, "fresh context interrupted: " + 
stageSuffix);
+
+            throw new IllegalStateException("Interrupted while loading fresh 
TeamCity context: " + stageSuffix, e);
+        }
+        catch (Exception e) {
+            aiPromptMonitor.stage(reqId, "fresh context failed, using stale 
cache: " + stageSuffix + " - " + e.getMessage());
+        }
+
+        return buildChainProcessor.loadFullChainContext(
+            tcIgnited,
+            hist,
+            rebuild,
+            ProcessLogsMode.CACHED_ONLY,
+            false,
+            baseBranchForTc,
+            SyncMode.NONE,
+            null, null);
+    }
 }
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
index 63c89686..db5633d9 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
@@ -25,7 +25,9 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 import javax.annotation.Nonnull;
@@ -41,9 +43,12 @@ import org.apache.ignite.tcbot.engine.chain.FullChainRunCtx;
 import org.apache.ignite.tcbot.engine.chain.LatestRebuildMode;
 import org.apache.ignite.tcbot.engine.chain.ProcessLogsMode;
 import org.apache.ignite.tcbot.engine.chain.SortOption;
+import org.apache.ignite.tcbot.engine.build.AiPromptRequestMonitor;
+import org.apache.ignite.tcbot.engine.build.TestFailuresAiPromptBuilder;
 import org.apache.ignite.tcbot.engine.conf.ITcBotConfig;
 import org.apache.ignite.tcbot.engine.conf.ITrackedBranch;
 import org.apache.ignite.tcbot.engine.conf.ITrackedChain;
+import org.apache.ignite.tcbot.engine.pool.TcUpdatePool;
 import org.apache.ignite.tcbot.engine.ui.DsChainUi;
 import org.apache.ignite.tcbot.engine.ui.DsSummaryUi;
 import org.apache.ignite.tcbot.engine.ui.GuardBranchStatusUi;
@@ -88,6 +93,164 @@ public class TrackedBranchChainsProcessor implements 
IDetailedStatusForTrackedBr
     /** Update Counters for branch-related changes storage. */
     @Inject private UpdateCountersStorage countersStorage;
 
+    /** AI prompt monitor. */
+    @Inject private AiPromptRequestMonitor aiPromptMonitor;
+
+    /** TC update pool for best-effort AI prompt refreshes. */
+    @Inject private TcUpdatePool tcUpdatePool;
+
+    /**
+     * @param branch Branch.
+     * @param buildResMergeCnt Build results merge count.
+     * @param creds Credentials.
+     * @param syncMode Sync mode.
+     * @param tagForHistSelected Selected tag for filtering history.
+     * @param sortOption Sort mode.
+     * @param maxDetailsChars Max chars to include for every test details 
block. Non-positive means default cap.
+     * @param testName Full test name to include.
+     * @param suiteId Suite id to include.
+     */
+    @Nonnull public String getTrackedBranchFailuresAiPrompt(
+        @Nullable String branch,
+        int buildResMergeCnt,
+        ICredentialsProv creds,
+        SyncMode syncMode,
+        @Nullable String tagForHistSelected,
+        @Nullable SortOption sortOption,
+        @Nullable Integer maxDetailsChars,
+        @Nullable String testName,
+        @Nullable String suiteId) {
+        long reqId = aiPromptMonitor.start("trackedBranch", branch, null, 
null, testName);
+        StringBuilder res = new StringBuilder();
+
+        try {
+            final String branchNn = isNullOrEmpty(branch) ? 
ITcServerConfig.DEFAULT_TRACKED_BRANCH_NAME : branch;
+            final ITrackedBranch tracked = 
tcBotCfg.getTrackedBranches().getBranchMandatory(branchNn);
+            final int maxDetails = 
TestFailuresAiPromptBuilder.restMaxDetailsChars(maxDetailsChars);
+
+            tracked.chainsStream()
+                .filter(chainTracked -> 
tcIgnitedProv.hasAccess(chainTracked.serverCode(), creds))
+                .forEach(chainTracked -> {
+                    String srvCodeOrAlias = chainTracked.serverCode();
+                    String branchForTc = chainTracked.tcBranch();
+                    String baseBranchTc = 
chainTracked.tcBaseBranch().orElse(branchForTc);
+                    String suiteIdMandatory = chainTracked.tcSuiteId();
+
+                    aiPromptMonitor.stage(reqId, "loading history: " + 
srvCodeOrAlias + "/" + suiteIdMandatory);
+
+                    ITeamcityIgnited tcIgnited = 
tcIgnitedProv.server(srvCodeOrAlias, creds);
+
+                    Map<Integer, Integer> requireParamVal = new HashMap<>();
+
+                    if (!Strings.isNullOrEmpty(tagForHistSelected))
+                        
requireParamVal.putAll(reverseTagToParametersRequired(tagForHistSelected, 
srvCodeOrAlias));
+
+                    List<Integer> chains = 
tcIgnited.getLastNBuildsFromHistory(suiteIdMandatory, branchForTc,
+                        Math.max(buildResMergeCnt, 1));
+
+                    LatestRebuildMode rebuild = buildResMergeCnt > 1 ? 
LatestRebuildMode.ALL : LatestRebuildMode.LATEST;
+
+                    aiPromptMonitor.stage(reqId, "loading chain context: " + 
srvCodeOrAlias + "/" + suiteIdMandatory);
+
+                    FullChainRunCtx ctx = loadAiPromptContextBestEffort(reqId, 
tcIgnited, chains, rebuild,
+                        buildResMergeCnt == 1, baseBranchTc, syncMode, 
sortOption, requireParamVal,
+                        srvCodeOrAlias + "/" + suiteIdMandatory);
+
+                    if (res.length() > 0)
+                        res.append("\n\n");
+
+                    aiPromptMonitor.stage(reqId, "building prompt: " + 
srvCodeOrAlias + "/" + suiteIdMandatory);
+
+                    res.append(new TestFailuresAiPromptBuilder(compactor)
+                        .buildPrompt(tcIgnited, ctx, baseBranchTc, maxDetails, 
testName, suiteId));
+                });
+
+            aiPromptMonitor.finish(reqId, "chars=" + res.length());
+
+            return res.toString();
+        }
+        catch (RuntimeException e) {
+            aiPromptMonitor.fail(reqId, e);
+
+            throw e;
+        }
+    }
+
+    /**
+     * @param reqId Monitor request id.
+     * @param tcIgnited TeamCity facade.
+     * @param chains Entry builds.
+     * @param rebuild Rebuild mode.
+     * @param includeScheduledInfo Include scheduled info.
+     * @param baseBranchTc Base branch.
+     * @param liveSyncMode Live sync mode.
+     * @param sortOption Sort option.
+     * @param requireParamVal Required parameter values.
+     * @param stageSuffix Stage suffix.
+     */
+    private FullChainRunCtx loadAiPromptContextBestEffort(
+        long reqId,
+        ITeamcityIgnited tcIgnited,
+        List<Integer> chains,
+        LatestRebuildMode rebuild,
+        boolean includeScheduledInfo,
+        String baseBranchTc,
+        SyncMode liveSyncMode,
+        @Nullable SortOption sortOption,
+        @Nullable Map<Integer, Integer> requireParamVal,
+        String stageSuffix) {
+        Future<FullChainRunCtx> live = null;
+
+        try {
+            aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s: " 
+ stageSuffix);
+
+            live = tcUpdatePool.getService().submit(() -> 
chainProc.loadFullChainContext(
+                tcIgnited,
+                chains,
+                rebuild,
+                ProcessLogsMode.CACHED_ONLY,
+                includeScheduledInfo,
+                baseBranchTc,
+                liveSyncMode,
+                sortOption,
+                requireParamVal
+            ));
+
+            return live.get(1, TimeUnit.SECONDS);
+        }
+        catch (TimeoutException e) {
+            if (live != null)
+                live.cancel(true);
+
+            aiPromptMonitor.stage(reqId, "fresh context timed out, using stale 
cache: " + stageSuffix);
+        }
+        catch (InterruptedException e) {
+            if (live != null)
+                live.cancel(true);
+
+            Thread.currentThread().interrupt();
+
+            aiPromptMonitor.stage(reqId, "fresh context interrupted: " + 
stageSuffix);
+
+            throw new IllegalStateException("Interrupted while loading fresh 
TeamCity context: " + stageSuffix, e);
+        }
+        catch (Exception e) {
+            aiPromptMonitor.stage(reqId, "fresh context failed, using stale 
cache: " + stageSuffix + " - " + e.getMessage());
+        }
+
+        return chainProc.loadFullChainContext(
+            tcIgnited,
+            chains,
+            rebuild,
+            ProcessLogsMode.CACHED_ONLY,
+            false,
+            baseBranchTc,
+            SyncMode.NONE,
+            sortOption,
+            requireParamVal
+        );
+    }
+
     /** {@inheritDoc} */
     @AutoProfiling
     @Nonnull
diff --git 
a/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
 
b/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
new file mode 100644
index 00000000..33793fa5
--- /dev/null
+++ 
b/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.tcbot.engine.build;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class TestFailuresAiPromptBuilderTest {
+    @Test
+    public void restMaxDetailsCharsUsesHardCap() {
+        assertEquals(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS,
+            TestFailuresAiPromptBuilder.restMaxDetailsChars(null));
+        assertEquals(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS,
+            TestFailuresAiPromptBuilder.restMaxDetailsChars(0));
+        assertEquals(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS,
+            TestFailuresAiPromptBuilder.restMaxDetailsChars(-1));
+        assertEquals(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS,
+            
TestFailuresAiPromptBuilder.restMaxDetailsChars(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS
 + 1));
+        assertEquals(1024, 
TestFailuresAiPromptBuilder.restMaxDetailsChars(1024));
+    }
+}
diff --git 
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/ci/teamcity/ignited/buildtype/ParametersCompacted.java
 
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/ci/teamcity/ignited/buildtype/ParametersCompacted.java
index 65ac3a10..6783e638 100644
--- 
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/ci/teamcity/ignited/buildtype/ParametersCompacted.java
+++ 
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/ci/teamcity/ignited/buildtype/ParametersCompacted.java
@@ -154,6 +154,9 @@ public class ParametersCompacted {
     }
 
     public void forEach(BiConsumer<Integer, Integer> consumer) {
+        if (keys == null || values == null)
+            return;
+
         int size = keys.size();
 
         for (int i = 0; i < size; i++) {
diff --git 
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/build/TestCompactedV2.java
 
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/build/TestCompactedV2.java
index cfd64f8b..f46e5704 100644
--- 
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/build/TestCompactedV2.java
+++ 
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/build/TestCompactedV2.java
@@ -260,28 +260,11 @@ public class TestCompactedV2 implements ITest {
     public void setDetails(String details, @Nullable ILogProductSpecific 
logSpecific) {
         this.details = null;
 
-        if (Strings.isNullOrEmpty(details) || logSpecific == null)
+        if (Strings.isNullOrEmpty(details))
             return;
 
-        StringBuilder sb = new StringBuilder();
-
-        //todo check integration with JIRA
-        for (String s : details.split("\n")) {
-            if(s.isEmpty())
-                continue;
-
-            if (logSpecific.needWarn(s)
-                || s.contains("http://issues.apache.org/jira/browse/";)
-                || s.contains("https://issues.apache.org/jira/browse/";)) {
-                sb.append(s);
-                sb.append("\n");
-            }
-        }
-
-        if (sb.length() > 0) {
-            this.details = new StringFieldCompacted();
-            this.details.setValue(sb.toString());
-        }
+        this.details = new StringFieldCompacted();
+        this.details.setValue(details);
     }
 
     public Boolean getIgnoredFlag() {
diff --git 
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/BuildLogProcessor.java
 
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/BuildLogProcessor.java
index 67bf01ed..513fcbac 100644
--- 
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/BuildLogProcessor.java
+++ 
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/BuildLogProcessor.java
@@ -94,6 +94,22 @@ class BuildLogProcessor implements IBuildLogProcessor {
         }
     }
 
+    @Nullable
+    @Override
+    public ILogCheckResult getCachedBuildLogAnalysis(String serverCode, 
Integer buildId) {
+        if (buildId == null)
+            return null;
+
+        long cacheKey = BuildLogCheckResultDao.getCacheKey(serverCode, 
buildId);
+
+        ILogCheckResult cached = logCheckResultCache.getIfPresent(cacheKey);
+
+        if (cached != null)
+            return cached;
+
+        return logCheckResultDao.get(serverCode, buildId);
+    }
+
     @Nullable
     @Override
     public String getThreadDumpCached(String serverCode, Integer buildId) {
diff --git 
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/IBuildLogProcessor.java
 
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/IBuildLogProcessor.java
index 6816004f..35d1539f 100644
--- 
a/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/IBuildLogProcessor.java
+++ 
b/tcbot-teamcity-ignited/src/main/java/org/apache/ignite/tcignited/buildlog/IBuildLogProcessor.java
@@ -23,6 +23,8 @@ import javax.annotation.Nullable;
 public interface IBuildLogProcessor {
     public ILogCheckResult analyzeBuildLog(ITeamcityIgnited teamcity, int 
buildId, boolean dumpLastTest);
 
+    @Nullable
+    public ILogCheckResult getCachedBuildLogAnalysis(String serverCode, 
Integer buildId);
 
     @Nullable
     public String getThreadDumpCached(String serverCode, Integer buildId);

Reply via email to