feat(wiki): page-input transformations — run a template against an existing page

This commit is contained in:
matevip 2026-05-12 14:10:40 +08:00
parent 4c8a42b0e2
commit e1759e0e3c
3 changed files with 204 additions and 45 deletions

View File

@ -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<WikiTransformationRunEntity> apply(@PathVariable Long id,
@RequestBody Map<String, Object> 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();
}

View File

@ -93,6 +93,141 @@ public class WikiTransformationExecutor {
() -> runOnRawSync(transformation, rawId, triggeredBy), WORKER);
}
public CompletableFuture<WikiTransformationRunEntity> 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) {

View File

@ -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) {