summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichael Peter Christen <mc@yacy.net>2026-07-04 14:49:44 +0200
committerMichael Peter Christen <mc@yacy.net>2026-07-04 14:49:44 +0200
commit51e6c98475b9cdb1b342f8b2735d71d5c2cf949e (patch)
tree75d0383c8168b3b7285f02c36425d7944f180e7e
parenta72b5194409c8d39e6f858d2372576d79fd5d036 (diff)
enhanced log reports generation
-rw-r--r--htroot/LogReports_p.html167
-rw-r--r--htroot/api/logreportstatus.json12
-rw-r--r--source/net/yacy/ai/LLM.java64
-rw-r--r--source/net/yacy/ai/LogReportService.java159
-rw-r--r--source/net/yacy/htroot/LogReports_p.java45
-rw-r--r--source/net/yacy/htroot/api/logreportstatus.java58
6 files changed, 462 insertions, 43 deletions
diff --git a/htroot/LogReports_p.html b/htroot/LogReports_p.html
index 45e86c16a..152a62ad3 100644
--- a/htroot/LogReports_p.html
+++ b/htroot/LogReports_p.html
@@ -26,17 +26,33 @@
list-style: none;
}
.logreport-nav li {
+ position: relative;
border-bottom: 1px solid #eee;
}
.logreport-nav a {
display: block;
- padding: 8px 10px;
+ padding: 8px 26px 8px 10px;
text-decoration: none;
color: #333;
}
.logreport-nav a:hover {
background: #f5f5f5;
}
+ /* small per-report delete button, deletes immediately without confirmation */
+ .logreport-nav a.nav-delete {
+ position: absolute;
+ top: 50%;
+ right: 4px;
+ transform: translateY(-50%);
+ padding: 0 6px;
+ font-size: 1.2em;
+ line-height: 1.2;
+ color: #999;
+ }
+ .logreport-nav a.nav-delete:hover {
+ color: #a94442;
+ background: none;
+ }
.logreport-nav li.selected a {
background: #eef4fa;
border-left: 4px solid #337ab7;
@@ -151,30 +167,135 @@
<h2>Log Reports</h2>
+ <!-- the report computation runs asynchronously on the server (LogReportService
+ manual job); this form only triggers it and returns immediately. While the
+ job is running the button is disabled, the progress box is shown and the
+ script below polls the page until the result is available -->
<form action="LogReports_p.html" method="post" accept-charset="UTF-8" class="logreport-actions" id="runReportForm">
- <button type="submit" name="runReportNow" value="1" class="btn btn-primary" id="runReportButton">run report now</button>
+ <button type="submit" name="runReportNow" value="1" class="btn btn-primary" id="runReportButton" #(reportRunning)#::disabled="disabled"#(/reportRunning)#>run report now</button>
</form>
- <!-- shown while the synchronous report generation request is running; the page
- reloads with the result when the server is done, which removes this again -->
- <div class="logreport-progress" id="runReportProgress">
+ <div class="logreport-progress" id="runReportProgress" #(reportRunning)#::style="display:flex"#(/reportRunning)#>
<div class="spinner"></div>
<div>Generating report from the current-hour log lines &mdash; the LLM call can take a while &hellip;
- <span id="runReportElapsed">0</span> seconds elapsed</div>
+ <span id="runReportElapsed">#(reportRunning)#0::#[elapsedSeconds]##(/reportRunning)#</span> seconds elapsed</div>
</div>
<script type="text/javascript">
- document.getElementById("runReportForm").addEventListener("submit", function () {
+ // Live view of the manual report generation: the job runs asynchronously on
+ // the server, the model output is streamed into a buffer there, and this
+ // script polls api/logreportstatus.json in the background. No hard page
+ // reloads - the partially generated report is rendered in place so the user
+ // can read and scroll in it while it is still being completed.
+ (function () {
var button = document.getElementById("runReportButton");
- // a submit button disabled during the submit event would drop its
- // runReportNow=1 value from the request, so disable it one tick later
- setTimeout(function () { button.disabled = true; }, 0);
- document.getElementById("runReportProgress").style.display = "flex";
- var started = Date.now();
- setInterval(function () {
- document.getElementById("runReportElapsed").textContent = Math.floor((Date.now() - started) / 1000);
- }, 1000);
- });
+ var progress = document.getElementById("runReportProgress");
+ var elapsedElement = document.getElementById("runReportElapsed");
+ var liveCard = document.getElementById("liveReportCard");
+ var liveBody = document.getElementById("liveReportMarkdown");
+ var liveTitle = document.getElementById("liveReportTitle");
+ var liveMeta = document.getElementById("liveReportMeta");
+ var selectedCard = document.getElementById("selectedReportCard");
+ var pollTimer = null;
+
+ function setRunningUI(running) {
+ button.disabled = running;
+ progress.style.display = running ? "flex" : "none";
+ }
+
+ function renderPartial(partial) {
+ // the shared markdown module is loaded at the end of the page and might
+ // not be ready when the very first poll response arrives
+ if (!liveCard || !partial || typeof YaCyMarkdown === "undefined") return;
+ liveCard.style.display = "block";
+ if (selectedCard) selectedCard.style.display = "none";
+ // re-rendering the grown markdown into the same element preserves the
+ // scroll position, so the user can read while the report is completed
+ liveBody.innerHTML = YaCyMarkdown.render(partial, { breaks: false });
+ liveBody.classList.add("markdown-body");
+ }
+
+ function showResultBox(cssClass, text) {
+ var box = document.createElement("div");
+ box.className = cssClass;
+ box.textContent = text;
+ progress.parentNode.insertBefore(box, progress);
+ }
+
+ function finish(status) {
+ clearInterval(pollTimer);
+ setRunningUI(false);
+ if (status.outcome === 1) {
+ if (liveTitle) liveTitle.textContent = "YaCy hourly log report (just generated)";
+ if (liveMeta) liveMeta.textContent = status.message + " - " + status.durationSeconds + " seconds";
+ showResultBox("logreport-message", "Current-hour report written: " + status.message + " (" + status.durationSeconds + " seconds)");
+ // add the new report to the navigation column; a delete button is
+ // omitted here on purpose, a page reload will offer it
+ var nav = document.querySelector(".logreport-nav");
+ if (nav) {
+ var li = document.createElement("li");
+ var a = document.createElement("a");
+ a.href = "LogReports_p.html?report=" + encodeURIComponent(status.message);
+ a.title = status.message;
+ var date = document.createElement("span");
+ date.className = "nav-date";
+ date.textContent = status.message.replace(/^report-(\d{4}-\d{2}-\d{2})-(\d{2})\.md$/, "$1 $2:00");
+ var type = document.createElement("span");
+ type.className = "nav-type";
+ type.textContent = "hourly report";
+ a.appendChild(date);
+ a.appendChild(type);
+ li.appendChild(a);
+ nav.insertBefore(li, nav.firstChild);
+ }
+ } else if (status.outcome === 2) {
+ showResultBox("logreport-warning", "No log lines were found for the current hour.");
+ } else if (status.outcome === 3) {
+ showResultBox("logreport-warning", "No production model is configured for the log-report role.");
+ } else if (status.outcome === 4) {
+ showResultBox("logreport-warning", "Report generation failed after " + status.durationSeconds + " seconds: " + status.message);
+ }
+ }
+
+ function poll() {
+ fetch("api/logreportstatus.json", { cache: "no-store" })
+ .then(function (response) { return response.json(); })
+ .then(function (status) {
+ if (status.running) {
+ elapsedElement.textContent = status.elapsedSeconds;
+ renderPartial(status.partial);
+ } else {
+ renderPartial(status.partial); // final render of the complete text
+ finish(status);
+ }
+ })
+ .catch(function (err) { console.warn("report status poll failed", err); });
+ }
+
+ function startPolling() {
+ setRunningUI(true);
+ if (pollTimer) clearInterval(pollTimer);
+ pollTimer = setInterval(poll, 2000);
+ poll();
+ }
+
+ // start the job via fetch instead of a regular form submit, so the page
+ // is not reloaded and the live view keeps its state
+ document.getElementById("runReportForm").addEventListener("submit", function (event) {
+ event.preventDefault();
+ setRunningUI(true);
+ fetch("LogReports_p.html", { method: "POST", body: new URLSearchParams({ runReportNow: "1" }) })
+ .then(function () { startPolling(); })
+ .catch(function (err) {
+ console.warn("could not start report generation", err);
+ setRunningUI(false);
+ });
+ });
+
+ // a job may already be running (started earlier or by another admin)
+ var reportRunning = #(reportRunning)#false::true#(/reportRunning)#;
+ if (reportRunning) startPolling();
+ })();
</script>
#(runReportResult)#
@@ -223,14 +344,26 @@
<span class="nav-date">#[date]#</span>
<span class="nav-type">#[type]# report</span>
</a>
+ <a class="nav-delete" href="LogReports_p.html?deleteReport=#[filename]#" title="delete this report">&times;</a>
</li>
#{/reports}#
</ul>
<div class="logreport-main">
+ <!-- live view of the report which is currently being generated: the polling
+ script streams the partial model output into this card. Intentionally
+ without a delete button - the report file does not exist yet -->
+ <div class="logreport-card" id="liveReportCard" style="display:none">
+ <div class="logreport-header">
+ <div class="logreport-title" id="liveReportTitle">Report generation in progress &hellip;</div>
+ <div class="logreport-meta" id="liveReportMeta">the report below is completed live while the model is writing</div>
+ </div>
+ <div class="logreport-body" id="liveReportMarkdown"></div>
+ </div>
+
#(selectedReport)#
::
- <div class="logreport-card">
+ <div class="logreport-card" id="selectedReportCard">
<div class="logreport-header">
<div class="logreport-title">#[title]#</div>
<div class="logreport-meta">
diff --git a/htroot/api/logreportstatus.json b/htroot/api/logreportstatus.json
new file mode 100644
index 000000000..ec9f3f00d
--- /dev/null
+++ b/htroot/api/logreportstatus.json
@@ -0,0 +1,12 @@
+{
+ #(authorized)#
+ "error": "authorization required",
+ ::
+ #(/authorized)#
+ "running": #(running)#false::true#(/running)#,
+ "elapsedSeconds": #[elapsedSeconds]#,
+ "partial": "#[partial]#",
+ "outcome": #[outcome]#,
+ "message": "#[message]#",
+ "durationSeconds": #[durationSeconds]#
+}
diff --git a/source/net/yacy/ai/LLM.java b/source/net/yacy/ai/LLM.java
index 2363230ab..e22020f8d 100644
--- a/source/net/yacy/ai/LLM.java
+++ b/source/net/yacy/ai/LLM.java
@@ -402,6 +402,70 @@ public class LLM {
throw new IOException(e.getMessage());
}
}
+
+ /**
+ * OpenAI chat client like chat(), but with streaming: the model output is read
+ * as server-sent events and every content delta is passed to the onDelta consumer
+ * as soon as it arrives. This allows callers (i.e. the log report generator) to
+ * show partially generated output live while the model is still working.
+ * @param onDelta receives each content fragment in order; may be null
+ * @return the complete concatenated model output
+ */
+ public String chatStream(final String model, final String systemPrompt, final String userPrompt, final int max_tokens, final java.util.function.Consumer<String> onDelta) throws IOException {
+ final JSONObject data = new JSONObject();
+ try {
+ final Context context = new Context(systemPrompt);
+ context.addPrompt(userPrompt);
+ data.put("model", model);
+ data.put("temperature", 0.1);
+ data.put("max_tokens", max_tokens);
+ data.put("messages", context);
+ data.put("stop", new JSONArray(STOPTOKENS));
+ data.put("stream", true);
+ applyNoThinkingParametersIfNeeded(model, data);
+
+ final URL url = new URI(this.hoststub + "/v1/chat/completions").toURL();
+ final HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("POST");
+ conn.setRequestProperty("Content-Type", "application/json");
+ conn.setRequestProperty("Accept", "text/event-stream");
+ if (this.api_key != null && !this.api_key.isEmpty()) {
+ conn.setRequestProperty("Authorization", "Bearer " + this.api_key);
+ }
+ conn.setDoOutput(true);
+ try (OutputStream os = conn.getOutputStream()) {
+ final byte[] input = data.toString().getBytes("utf-8");
+ os.write(input, 0, input.length);
+ }
+
+ final int responseCode = conn.getResponseCode();
+ if (responseCode != HttpURLConnection.HTTP_OK) {
+ throw new IOException("Request failed with response code " + responseCode);
+ }
+ final StringBuilder full = new StringBuilder();
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"))) {
+ String line;
+ while ((line = br.readLine()) != null) {
+ line = line.trim();
+ if (!line.startsWith("data:")) continue; // SSE frames only; keep-alive lines are skipped
+ final String payload = line.substring(5).trim();
+ if (payload.equals("[DONE]")) break;
+ final JSONObject event = new JSONObject(payload);
+ final JSONArray choices = event.optJSONArray("choices");
+ if (choices == null || choices.length() == 0) continue;
+ final JSONObject delta = choices.getJSONObject(0).optJSONObject("delta");
+ final String content = delta == null ? "" : delta.optString("content", "");
+ if (!content.isEmpty()) {
+ full.append(content);
+ if (onDelta != null) onDelta.accept(content);
+ }
+ }
+ }
+ return full.toString();
+ } catch (JSONException | URISyntaxException e) {
+ throw new IOException(e.getMessage());
+ }
+ }
public static String[] stringsFromChat(String chatanswer) throws JSONException {
final List<String> list = new ArrayList<>();
diff --git a/source/net/yacy/ai/LogReportService.java b/source/net/yacy/ai/LogReportService.java
index 81b7eb358..23662984e 100644
--- a/source/net/yacy/ai/LogReportService.java
+++ b/source/net/yacy/ai/LogReportService.java
@@ -133,6 +133,103 @@ public class LogReportService {
return LLM.llmFromUsageQuiet(LLMUsage.logreport) != null;
}
+ /*
+ * Manual "run report now" job, triggered from /LogReports_p.html. The report
+ * computation is a long-running LLM call which would exceed the browser/servlet
+ * timeout when executed synchronously, therefore the servlet only starts this
+ * job and returns; the front-end polls the page and reads the state below until
+ * the job is finished. The computation itself is the same
+ * generateCurrentHourReportOverwrite() that the scheduler environment uses.
+ */
+
+ private static final AtomicBoolean MANUAL_JOB_RUNNING = new AtomicBoolean(false);
+ /** partially generated report text, filled live from the model output stream */
+ private static final StringBuilder MANUAL_JOB_PARTIAL = new StringBuilder();
+ private static volatile long manualJobStart = 0L;
+ /** outcome of the last finished job, using the runReportResult template cases of
+ * LogReports_p.html: 0=none, 1=success, 2=no log lines, 3=no model, 4=failure */
+ private static volatile int manualJobOutcome = 0;
+ private static volatile String manualJobMessage = "";
+ private static volatile long manualJobDurationSeconds = 0L;
+
+ /** Snapshot of a finished manual report job for one-time display. */
+ public static final class ManualJobResult {
+ public final int outcome;
+ public final String message; // report filename on success, error message on failure
+ public final long durationSeconds;
+ private ManualJobResult(final int outcome, final String message, final long durationSeconds) {
+ this.outcome = outcome;
+ this.message = message;
+ this.durationSeconds = durationSeconds;
+ }
+ }
+
+ /**
+ * Start the manual report computation in a background thread.
+ * @return true if a new job was started, false if one is already running
+ */
+ public boolean startManualReportJob() {
+ if (!MANUAL_JOB_RUNNING.compareAndSet(false, true)) return false;
+ manualJobStart = System.currentTimeMillis();
+ manualJobOutcome = 0;
+ synchronized (MANUAL_JOB_PARTIAL) {
+ MANUAL_JOB_PARTIAL.setLength(0);
+ }
+ final Thread worker = new Thread(() -> {
+ final long start = System.currentTimeMillis();
+ try {
+ final File reportFile = generateCurrentHourReportOverwrite(delta -> {
+ synchronized (MANUAL_JOB_PARTIAL) {
+ MANUAL_JOB_PARTIAL.append(delta);
+ }
+ });
+ if (reportFile == null) {
+ manualJobMessage = "";
+ manualJobOutcome = hasConfiguredLogReportModel() ? 2 : 3;
+ } else {
+ manualJobMessage = reportFile.getName();
+ manualJobOutcome = 1;
+ }
+ } catch (final Exception e) {
+ manualJobMessage = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
+ manualJobOutcome = 4;
+ } finally {
+ manualJobDurationSeconds = Math.max(0L, (System.currentTimeMillis() - start) / 1000L);
+ MANUAL_JOB_RUNNING.set(false);
+ }
+ }, "LogReportService.manualReport");
+ worker.setDaemon(true);
+ worker.start();
+ return true;
+ }
+
+ public static boolean isManualReportJobRunning() {
+ return MANUAL_JOB_RUNNING.get();
+ }
+
+ public static long manualReportJobElapsedSeconds() {
+ return MANUAL_JOB_RUNNING.get() ? Math.max(0L, (System.currentTimeMillis() - manualJobStart) / 1000L) : 0L;
+ }
+
+ /** @return the report text generated so far by the running manual job */
+ public static String manualReportJobPartialReport() {
+ synchronized (MANUAL_JOB_PARTIAL) {
+ return MANUAL_JOB_PARTIAL.toString();
+ }
+ }
+
+ /**
+ * Fetch and clear the outcome of the last finished manual report job, so the
+ * result message is displayed exactly once on the next page render.
+ * @return the result snapshot, or null when no job finished since the last call
+ */
+ public static ManualJobResult consumeManualReportJobResult() {
+ if (MANUAL_JOB_RUNNING.get() || manualJobOutcome == 0) return null;
+ final ManualJobResult result = new ManualJobResult(manualJobOutcome, manualJobMessage, manualJobDurationSeconds);
+ manualJobOutcome = 0;
+ return result;
+ }
+
public static ScheduledExecutorService startScheduler(final Switchboard sb) {
if (sb == null) return null;
if (!sb.getConfigBool(CONFIG_ENABLED, true)) {
@@ -259,7 +356,7 @@ public class LogReportService {
final int maxTokens = Math.max(1, Math.min(model.llm.max_tokens, configuredMaxTokens));
log.info("runId=" + runId + " event=hourly-report phase=model-call bucket=" + bucket.getKey() + " model=" + LogRedaction.redact(model.model) + " backend=" + LogRedaction.redact(model.llm.hoststub) + " inputLines=" + bucket.getValue().size() + " promptChars=" + prompt.length() + " maxTokens=" + maxTokens);
final long modelStart = System.currentTimeMillis();
- final String report = model.llm.chat(model.model, SYSTEM_PROMPT, prompt, maxTokens);
+ final String report = model.llm.chatStream(model.model, SYSTEM_PROMPT, prompt, maxTokens, null);
final long modelDuration = elapsed(modelStart);
log.info("runId=" + runId + " event=hourly-report phase=model-return bucket=" + bucket.getKey() + " durationMs=" + modelDuration + " outputChars=" + (report == null ? 0 : report.length()));
if (report == null || report.trim().isEmpty()) {
@@ -278,6 +375,15 @@ public class LogReportService {
}
public File generateCurrentHourReportOverwrite() throws IOException {
+ return generateCurrentHourReportOverwrite(null);
+ }
+
+ /**
+ * Generate the current-hour report, streaming the model output into the given
+ * consumer while the generation is running (used for the live view of the
+ * manual "run report now" job).
+ */
+ public File generateCurrentHourReportOverwrite(final java.util.function.Consumer<String> onDelta) throws IOException {
final String runId = newRunId();
final long start = System.currentTimeMillis();
log.info("runId=" + runId + " event=current-hour-report phase=start mode=manual");
@@ -310,7 +416,7 @@ public class LogReportService {
final int maxTokens = Math.max(1, Math.min(model.llm.max_tokens, configuredMaxTokens));
log.info("runId=" + runId + " event=current-hour-report phase=model-call bucket=" + currentHour + " model=" + LogRedaction.redact(model.model) + " backend=" + LogRedaction.redact(model.llm.hoststub) + " inputLines=" + bucketLines.size() + " promptChars=" + prompt.length() + " maxTokens=" + maxTokens);
final long modelStart = System.currentTimeMillis();
- final String report = model.llm.chat(model.model, SYSTEM_PROMPT, prompt, maxTokens);
+ final String report = model.llm.chatStream(model.model, SYSTEM_PROMPT, prompt, maxTokens, onDelta);
log.info("runId=" + runId + " event=current-hour-report phase=model-return bucket=" + currentHour + " durationMs=" + elapsed(modelStart) + " outputChars=" + (report == null ? 0 : report.length()));
if (report == null || report.trim().isEmpty()) {
log.warn("runId=" + runId + " event=current-hour-report phase=model-return result=failure bucket=" + currentHour + " reason=empty-report");
@@ -373,7 +479,7 @@ public class LogReportService {
final int maxTokens = Math.max(1, Math.min(model.llm.max_tokens, configuredMaxTokens));
log.info("runId=" + runId + " event=daily-report phase=model-call day=" + day.getKey() + " model=" + LogRedaction.redact(model.model) + " backend=" + LogRedaction.redact(model.llm.hoststub) + " sourceReports=" + day.getValue().size() + " promptChars=" + prompt.length() + " maxTokens=" + maxTokens);
final long modelStart = System.currentTimeMillis();
- final String report = model.llm.chat(model.model, SYSTEM_PROMPT, prompt, maxTokens);
+ final String report = model.llm.chatStream(model.model, SYSTEM_PROMPT, prompt, maxTokens, null);
log.info("runId=" + runId + " event=daily-report phase=model-return day=" + day.getKey() + " durationMs=" + elapsed(modelStart) + " outputChars=" + (report == null ? 0 : report.length()));
if (report == null || report.trim().isEmpty()) {
throw new IOException("model returned an empty daily report");
@@ -498,13 +604,52 @@ public class LogReportService {
.append("6. Concrete actions\n\n")
.append("Each concrete action must identify the log trigger and expected effect. ")
.append("Do not include secrets, credentials, or raw user content beyond what already appears in the log metadata.\n\n")
- .append("Log lines:\n");
+ .append("Log lines (noise lines are omitted here, they are already summarized in the noise classification above):\n");
+ // Only non-noise lines go into the prompt: the classified noise lines are
+ // already represented by the category counts and examples above. Sending
+ // them again as raw text would blow the prompt up to hundreds of kilobytes,
+ // and the model-side prompt processing time grows with every token - this
+ // was the reason why a report generation took many minutes.
+ final StringBuilder linesText = new StringBuilder(lines.size() * 120);
+ int omittedNoise = 0;
for (final String line : lines) {
- prompt.append(line).append('\n');
+ if (noiseCategory(line) != null) {
+ omittedNoise++;
+ continue;
+ }
+ linesText.append(line).append('\n');
+ }
+ if (omittedNoise > 0) {
+ prompt.append("(").append(omittedNoise).append(" noise lines omitted)\n");
}
+ appendWithPromptBudget(prompt, linesText);
return prompt.toString();
}
+ /**
+ * Upper bound for the variable part of a report prompt. Local models process the
+ * prompt token by token before they generate anything, so an unbounded prompt
+ * makes the report generation arbitrarily slow. 64k chars are roughly 16k tokens.
+ */
+ private static final int MAX_PROMPT_PAYLOAD_CHARS = 65536;
+
+ /**
+ * Append the payload to the prompt, truncated to MAX_PROMPT_PAYLOAD_CHARS.
+ * When truncating, the most recent part (the tail) is kept because the newest
+ * log lines are the most relevant ones for the report.
+ */
+ private static void appendWithPromptBudget(final StringBuilder prompt, final StringBuilder payload) {
+ if (payload.length() <= MAX_PROMPT_PAYLOAD_CHARS) {
+ prompt.append(payload);
+ return;
+ }
+ int cut = payload.length() - MAX_PROMPT_PAYLOAD_CHARS;
+ final int lineStart = payload.indexOf("\n", cut);
+ if (lineStart >= 0) cut = lineStart + 1; // do not start with a partial line
+ prompt.append("(older content truncated to fit the prompt budget)\n")
+ .append(payload, cut, payload.length());
+ }
+
private static NoiseSummary classifyNoise(final List<String> lines) {
final NoiseSummary summary = new NoiseSummary(lines == null ? 0 : lines.size());
if (lines == null || lines.isEmpty()) return summary;
@@ -664,10 +809,12 @@ public class LogReportService {
.append("5. Possible improvements\n")
.append("6. Concrete actions\n\n")
.append("Find common patterns across the day. Each concrete action must identify the repeated log trigger and expected effect.\n\n");
+ final StringBuilder reportsText = new StringBuilder(hourlyReports.size() * 4096);
for (final File hourlyReport : hourlyReports) {
- prompt.append("\n\n## ").append(hourlyReport.getName()).append("\n\n")
+ reportsText.append("\n\n## ").append(hourlyReport.getName()).append("\n\n")
.append(new String(Files.readAllBytes(hourlyReport.toPath()), StandardCharsets.UTF_8));
}
+ appendWithPromptBudget(prompt, reportsText);
return prompt.toString();
}
diff --git a/source/net/yacy/htroot/LogReports_p.java b/source/net/yacy/htroot/LogReports_p.java
index 5ef559611..a70cf2a55 100644
--- a/source/net/yacy/htroot/LogReports_p.java
+++ b/source/net/yacy/htroot/LogReports_p.java
@@ -35,30 +35,35 @@ public class LogReports_p {
sb.setConfig("ui.LogReports_p.visited", "true");
+ // "run report now" starts the computation asynchronously (the LLM call can take
+ // minutes and would run into the request timeout); the page polls itself while
+ // LogReportService reports the job as running and shows the result afterwards
prop.put("runReportResult", "0");
- prop.putHTML("runReportFile", "");
prop.putHTML("runReportResult_runReportFile", "");
prop.putNum("runReportResult_runReportDurationSeconds", 0);
if (post != null && post.containsKey("runReportNow")) {
- final long start = System.currentTimeMillis();
- try {
- final File reportFile = service.generateCurrentHourReportOverwrite();
- final long durationSeconds = Math.max(0L, (System.currentTimeMillis() - start) / 1000L);
- prop.putNum("runReportResult_runReportDurationSeconds", durationSeconds);
- if (reportFile == null) {
- prop.put("runReportResult", LogReportService.hasConfiguredLogReportModel() ? "2" : "3");
- } else {
- prop.put("runReportResult", "1");
- prop.putHTML("runReportFile", reportFile.getName());
- prop.putHTML("runReportResult_runReportFile", reportFile.getName());
- }
- } catch (final Exception e) {
- final long durationSeconds = Math.max(0L, (System.currentTimeMillis() - start) / 1000L);
- prop.putNum("runReportResult_runReportDurationSeconds", durationSeconds);
- prop.put("runReportResult", "4");
- final String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
- prop.putHTML("runReportFile", message);
- prop.putHTML("runReportResult_runReportFile", message);
+ service.startManualReportJob(); // no-op if a job is already running
+ }
+ final boolean reportRunning = LogReportService.isManualReportJobRunning();
+ prop.put("reportRunning", reportRunning ? "1" : "0");
+ prop.putNum("reportRunning_elapsedSeconds", LogReportService.manualReportJobElapsedSeconds());
+ if (!reportRunning) {
+ final LogReportService.ManualJobResult result = LogReportService.consumeManualReportJobResult();
+ if (result != null) {
+ prop.put("runReportResult", result.outcome);
+ prop.putHTML("runReportResult_runReportFile", result.message);
+ prop.putNum("runReportResult_runReportDurationSeconds", result.durationSeconds);
+ }
+ }
+
+ // delete a report when requested from the navigation column (immediate, no
+ // confirmation); the filename is strictly validated against the report
+ // filename patterns, which excludes any path traversal
+ final String deleteReport = post == null ? "" : post.get("deleteReport", "");
+ if (deleteReport.matches("report-\\d{4}-\\d{2}-\\d{2}(-\\d{2})?\\.md")) {
+ final File deleteFile = new File(reportDirectory, deleteReport);
+ if (deleteFile.isFile() && !deleteFile.delete()) {
+ net.yacy.cora.util.ConcurrentLog.warn("LogReports", "could not delete log report " + deleteFile.getAbsolutePath());
}
}
diff --git a/source/net/yacy/htroot/api/logreportstatus.java b/source/net/yacy/htroot/api/logreportstatus.java
new file mode 100644
index 000000000..ab32a9180
--- /dev/null
+++ b/source/net/yacy/htroot/api/logreportstatus.java
@@ -0,0 +1,58 @@
+/**
+ * logreportstatus
+ * Copyright 2026 by contributors to the YaCy project
+ * First released 04.07.2026 at https://yacy.net
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ */
+
+package net.yacy.htroot.api;
+
+import net.yacy.ai.LogReportService;
+import net.yacy.cora.protocol.RequestHeader;
+import net.yacy.search.Switchboard;
+import net.yacy.server.serverObjects;
+import net.yacy.server.serverSwitch;
+
+/**
+ * Polling endpoint for the manual "run report now" job on /LogReports_p.html.
+ * Returns the job state and the partially generated report text, so the page can
+ * show the report growing live (via JavaScript, without hard page reloads) while
+ * the LLM is still streaming its output. When the job has finished, the first
+ * poll consumes the one-time job result and reports it in outcome/message.
+ */
+public class logreportstatus {
+
+ public static serverObjects respond(final RequestHeader header, @SuppressWarnings("unused") final serverObjects post, final serverSwitch env) {
+ final serverObjects prop = new serverObjects();
+ prop.put("authorized", "0");
+ prop.put("running", "0");
+ prop.putNum("elapsedSeconds", 0);
+ prop.putJSON("partial", "");
+ prop.putNum("outcome", 0);
+ prop.putJSON("message", "");
+ prop.putNum("durationSeconds", 0);
+
+ if (header == null || env == null) return prop;
+ final Switchboard sb = (Switchboard) env;
+ if (!sb.verifyAuthentication(header)) return prop;
+ prop.put("authorized", "1");
+
+ final boolean running = LogReportService.isManualReportJobRunning();
+ prop.put("running", running ? "1" : "0");
+ prop.putNum("elapsedSeconds", LogReportService.manualReportJobElapsedSeconds());
+ prop.putJSON("partial", LogReportService.manualReportJobPartialReport());
+ if (!running) {
+ final LogReportService.ManualJobResult result = LogReportService.consumeManualReportJobResult();
+ if (result != null) {
+ prop.putNum("outcome", result.outcome);
+ prop.putJSON("message", result.message);
+ prop.putNum("durationSeconds", result.durationSeconds);
+ }
+ }
+ return prop;
+ }
+}