From e1759e0e3ce5a18cd9bb1d58e209e18e9efe1031 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 12 May 2026 14:10:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(wiki):=20page-input=20transformations=20?= =?UTF-8?q?=E2=80=94=20run=20a=20template=20against=20an=20existing=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WikiTransformationController.java | 29 +-- .../service/WikiTransformationExecutor.java | 171 ++++++++++++++---- .../java/vip/mate/wiki/tool/WikiTool.java | 49 +++++ 3 files changed, 204 insertions(+), 45 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java index d973ceae..3f7084e2 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java @@ -102,10 +102,10 @@ public class WikiTransformationController { // ==================== Apply ==================== @RequireWorkspaceRole("member") - @Operation(summary = "Run a transformation against a raw material", - description = "Set sync=true to block until the LLM call returns " - + "(the response carries the populated run). " - + "When false (default) the call returns immediately with the pending run row.") + @Operation(summary = "Run a transformation against a raw material or wiki page", + description = "Body accepts exactly one of {rawId, pageId}. Set sync=true to block " + + "until the LLM call returns; when false (default) the call returns " + + "immediately with the pending run row.") @PostMapping("/{id}/apply") public R apply(@PathVariable Long id, @RequestBody Map body, @@ -116,16 +116,23 @@ public class WikiTransformationController { verifyTemplateWorkspace(t, workspaceId); Object rawIdRaw = body == null ? null : body.get("rawId"); - if (rawIdRaw == null) { - return R.fail("rawId is required"); + Object pageIdRaw = body == null ? null : body.get("pageId"); + if (rawIdRaw == null && pageIdRaw == null) { + return R.fail("One of rawId / pageId is required"); + } + if (rawIdRaw != null && pageIdRaw != null) { + return R.fail("Pass only one of rawId / pageId, not both"); } - Long rawId = Long.valueOf(rawIdRaw.toString()); - if (sync) { - return R.ok(executor.runOnRawSync(t, rawId, "manual")); + if (rawIdRaw != null) { + Long rawId = Long.valueOf(rawIdRaw.toString()); + if (sync) return R.ok(executor.runOnRawSync(t, rawId, "manual")); + executor.runOnRawAsync(t, rawId, "manual"); + } else { + Long pageId = Long.valueOf(pageIdRaw.toString()); + if (sync) return R.ok(executor.runOnPageSync(t, pageId, "manual")); + executor.runOnPageAsync(t, pageId, "manual"); } - executor.runOnRawAsync(t, rawId, "manual"); - // Async path — caller polls /runs to see the result land. return R.ok(); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java index 045a397c..6b0775fe 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiTransformationExecutor.java @@ -93,6 +93,141 @@ public class WikiTransformationExecutor { () -> runOnRawSync(transformation, rawId, triggeredBy), WORKER); } + public CompletableFuture runOnPageAsync( + WikiTransformationEntity transformation, Long pageId, String triggeredBy) { + return CompletableFuture.supplyAsync( + () -> runOnPageSync(transformation, pageId, triggeredBy), WORKER); + } + + /** + * Run the transformation against an existing wiki page (e.g. a previous + * synthesis page or a manually-authored page). Mirrors + * {@link #runOnRawSync} but uses page content as the source. Output is + * not auto-saved as a wiki page even when the template has + * {@code outputTarget=page} — overwriting the input page would be + * surprising; users can still save manually from the run history. + */ + public WikiTransformationRunEntity runOnPageSync( + WikiTransformationEntity transformation, Long pageId, String triggeredBy) { + if (transformation == null) { + throw new IllegalArgumentException("transformation is required"); + } + if (pageId == null) { + throw new IllegalArgumentException("pageId is required"); + } + if (pageService == null) { + throw new IllegalStateException("Page service unavailable"); + } + WikiPageEntity page = pageService.getById(pageId); + if (page == null) { + throw new IllegalArgumentException("Page not found: " + pageId); + } + if (Boolean.FALSE.equals(transformation.getEnabled())) { + log.debug("[WikiTransformation] skipping disabled template id={} name={}", + transformation.getId(), transformation.getName()); + return null; + } + + long startNanos = System.nanoTime(); + WikiTransformationRunEntity run = new WikiTransformationRunEntity(); + run.setTransformationId(transformation.getId()); + run.setKbId(page.getKbId()); + run.setWorkspaceId(transformation.getWorkspaceId()); + run.setInputKind("page"); + run.setPageId(pageId); + run.setStatus("running"); + run.setTriggeredBy(triggeredBy == null ? "manual" : triggeredBy); + run.setStartedAt(LocalDateTime.now()); + transformationService.insertRun(run); + + try { + String inputText = page.getContent(); + if (inputText == null || inputText.isBlank()) { + throw new IllegalStateException("Page has no content"); + } + String output = renderAndCallLlm(transformation, page.getKbId(), + page.getTitle() == null ? ("page#" + pageId) : page.getTitle(), + inputText, run); + + WikiTransformationRunEntity current = transformationService.getRun(run.getId()); + if (current != null && "cancelled".equalsIgnoreCase(current.getStatus())) { + log.info("[WikiTransformation] run={} was cancelled mid-flight; discarding {} chars of LLM output", + run.getId(), output.length()); + return current; + } + + run.setOutput(output); + run.setStatus("completed"); + run.setCompletedAt(LocalDateTime.now()); + run.setDurationMs(Duration.ofNanos(System.nanoTime() - startNanos).toMillis()); + // For page input we never auto-save back to a page — the call site + // can use the manual save-as-page endpoint with a derived slug. + transformationService.updateRun(run); + + metrics.recordCompileStage("transformation_run", page.getKbId(), + Duration.ofNanos(System.nanoTime() - startNanos)); + log.info("[WikiTransformation] ok run={} transformation={} pageId={} kbId={} ({} ms)", + run.getId(), transformation.getName(), pageId, page.getKbId(), + run.getDurationMs()); + } catch (Exception e) { + WikiTransformationRunEntity current = transformationService.getRun(run.getId()); + if (current != null && "cancelled".equalsIgnoreCase(current.getStatus())) { + log.info("[WikiTransformation] run={} was cancelled before failure could be recorded ({})", + run.getId(), e.getMessage()); + return current; + } + run.setStatus("failed"); + String msg = e.getMessage(); + run.setError(msg == null ? e.getClass().getSimpleName() : msg); + run.setCompletedAt(LocalDateTime.now()); + run.setDurationMs(Duration.ofNanos(System.nanoTime() - startNanos).toMillis()); + transformationService.updateRun(run); + log.warn("[WikiTransformation] failed run={} transformation={} pageId={}: {}", + run.getId(), transformation.getName(), pageId, msg); + } + return run; + } + + /** + * Shared prompt-render + LLM-call + output-cleanup stage used by both + * raw-input and page-input entry points. Sets {@code run.modelId} as a + * side-effect so the run row reflects which model produced the output. + */ + private String renderAndCallLlm(WikiTransformationEntity transformation, Long kbId, + String sourceTitle, String sourceText, + WikiTransformationRunEntity run) { + String trimmedInput = sourceText.length() > MAX_INPUT_CHARS + ? sourceText.substring(0, MAX_INPUT_CHARS) + "\n…(truncated)" + : sourceText; + + String systemPrompt = PromptLoader.loadPrompt("wiki/transformation-system"); + String instruction = (transformation.getPromptTemplate() == null ? "" : transformation.getPromptTemplate()) + .replace("{input_text}", trimmedInput) + .replace("{title}", sourceTitle); + String userPrompt = PromptLoader.loadPrompt("wiki/transformation-user") + .replace("{instruction}", instruction) + .replace("{source_title}", sourceTitle) + .replace("{source_text}", trimmedInput); + + Long resolvedModelId = resolveModelId(transformation, kbId); + ChatModel chatModel = buildChatModel(resolvedModelId); + run.setModelId(resolvedModelId); + + ChatResponse resp = chatModel.call(new Prompt(List.of( + new SystemMessage(systemPrompt), new UserMessage(userPrompt)))); + String rawOutput = (resp == null || resp.getResult() == null + || resp.getResult().getOutput() == null) + ? null : resp.getResult().getOutput().getText(); + if (rawOutput == null || rawOutput.isBlank()) { + throw new IllegalStateException("LLM returned empty output"); + } + String output = cleanLlmOutput(rawOutput); + if (output.isBlank()) { + throw new IllegalStateException("LLM output was empty after cleanup"); + } + return output; + } + /** * Run the transformation against the given raw material and persist * the outcome. The returned entity is the persisted run row, regardless @@ -134,32 +269,8 @@ public class WikiTransformationExecutor { if (inputText == null || inputText.isBlank()) { throw new IllegalStateException("Raw material has no extractable text yet"); } - String trimmedInput = inputText.length() > MAX_INPUT_CHARS - ? inputText.substring(0, MAX_INPUT_CHARS) + "\n…(truncated)" - : inputText; - - String systemPrompt = PromptLoader.loadPrompt("wiki/transformation-system"); - String userPrompt = PromptLoader.loadPrompt("wiki/transformation-user") - .replace("{instruction}", renderTemplate(transformation.getPromptTemplate(), raw, trimmedInput)) - .replace("{source_title}", safeTitle(raw)) - .replace("{source_text}", trimmedInput); - - Long resolvedModelId = resolveModelId(transformation, raw.getKbId()); - ChatModel chatModel = buildChatModel(resolvedModelId); - run.setModelId(resolvedModelId); - - ChatResponse resp = chatModel.call(new Prompt(List.of( - new SystemMessage(systemPrompt), new UserMessage(userPrompt)))); - String rawOutput = (resp == null || resp.getResult() == null - || resp.getResult().getOutput() == null) - ? null : resp.getResult().getOutput().getText(); - if (rawOutput == null || rawOutput.isBlank()) { - throw new IllegalStateException("LLM returned empty output"); - } - String output = cleanLlmOutput(rawOutput); - if (output.isBlank()) { - throw new IllegalStateException("LLM output was empty after cleanup"); - } + String output = renderAndCallLlm(transformation, raw.getKbId(), + safeTitle(raw), inputText, run); // Honour a mid-flight cancel: the cancel endpoint flipped the run // row to 'cancelled' while the LLM was still working. Drop the // output and stop here rather than overwrite the cancelled state. @@ -234,14 +345,6 @@ public class WikiTransformationExecutor { return true; } - private String renderTemplate(String template, WikiRawMaterialEntity raw, String inputText) { - if (template == null) return ""; - String result = template; - result = result.replace("{input_text}", inputText); - result = result.replace("{title}", safeTitle(raw)); - return result; - } - private Long resolveModelId(WikiTransformationEntity transformation, Long kbId) { if (transformation.getModelId() != null) return transformation.getModelId(); if (modelRoutingService == null) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index cfc1c2fa..4078cc4b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -713,6 +713,55 @@ public class WikiTool { } } + @Tool(description = """ + Run a transformation template against an existing wiki page and return + the generated text. Use this when you want to derive a new artifact + from an existing page — e.g. "summarize the contract-review page", + "extract action items from this meeting-notes page". The run output + is persisted in the wiki UI; pass slug (not page id) for convenience. + """) + public String wiki_apply_transformation_to_page( + @ToolParam(description = "Agent ID") Long agentId, + @ToolParam(description = "Transformation name (from wiki_list_transformations)") String name, + @ToolParam(description = "Source wiki page slug to run the transformation against") String slug) { + if (name == null || name.isBlank()) return error("name is required"); + if (slug == null || slug.isBlank()) return error("slug is required"); + Long kbId = resolveKbId(agentId); + if (kbId == null) return error("No wiki knowledge base found for this agent"); + if (transformationService == null || transformationExecutor == null) { + return error("Transformations not available"); + } + + WikiPageEntity page = pageService.getBySlug(kbId, slug); + if (page == null) return error("Page not found: " + slug); + + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + Long wsId = (kb == null || kb.getWorkspaceId() == null) ? 1L : kb.getWorkspaceId(); + + WikiTransformationEntity template = transformationService.findByName(kbId, wsId, name).orElse(null); + if (template == null) return error("Transformation not found: " + name); + + try { + WikiTransformationRunEntity run = transformationExecutor.runOnPageSync(template, page.getId(), "agent_tool"); + if (run == null) return error("Transformation is disabled: " + name); + if ("failed".equals(run.getStatus())) { + return error("Transformation failed: " + run.getError()); + } + return JSONUtil.createObj() + .set("ok", true) + .set("runId", run.getId()) + .set("transformation", template.getName()) + .set("inputPage", slug) + .set("output", run.getOutput()) + .toString(); + } catch (IllegalStateException | IllegalArgumentException e) { + return error(e.getMessage()); + } catch (Exception e) { + log.warn("[WikiTool] wiki_apply_transformation_to_page failed: {}", e.getMessage()); + return error("Apply failed: " + e.getMessage()); + } + } + // ==================== Helpers ==================== private Long resolveKbId(Long agentId) {