mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(tool): add renderDocxFromFile to bypass LLM token cost on large markdown
renderDocx requires the markdown body to flow through the LLM as a
tool argument. For an 80 KB project proposal that's ≈ 20 K tokens of
streaming output spent just to repeat back content the model already
wrote to disk a turn earlier — multi-minute generation, real money.
renderDocxFromFile takes a file path instead. The agent uses
write_file / edit_file to assemble the markdown locally, then calls
this tool with just the path. JVM reads the file in one IO syscall
and feeds it to the existing MarkdownDocxRenderer. Token cost drops
from ≈ 20 K to ≈ 50 (the path string).
Behavior:
- Path resolution honors WorkspacePathGuard, same boundary as
read_file / write_file. No path traversal.
- UTF-8 read; rejects empty / missing / non-regular paths with
typed error messages so the agent can recover.
- Output cached in GeneratedFileCache and returned as a relative
/api/v1/files/generated/{id} link, with the same anti-host-
hallucination instruction renderDocx already carries.
- Same supported markdown subset (headings, bold, lists, tables).
Image references () still render as raw text — full
image embedding (P1) and SVG → PNG conversion (also P1) need
Apache Batik plus image-rendering plumbing in MarkdownDocxRenderer
and is tracked separately. Chapter-mode merge (P2) likewise needs
its own plumbing.
The @Tool description tells the agent to prefer this path when
markdown exceeds ~5 KB and shows the full write_file →
renderDocxFromFile workflow inline.
This commit is contained in:
parent
cc3c9a8618
commit
9ed9ee6ca7
@ -7,6 +7,11 @@ import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.tool.document.GeneratedFileCache;
|
||||
import vip.mate.tool.document.MarkdownDocxRenderer;
|
||||
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Render a brand-new .docx from Markdown without ever forking a process.
|
||||
@ -83,6 +88,96 @@ public class DocxRenderTool {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based renderer — reads markdown from disk instead of taking it as a
|
||||
* tool argument. Bypasses the LLM token-cost cliff: a 80 KB markdown body
|
||||
* would otherwise be streamed through the chat completion as part of
|
||||
* {@code renderDocx.markdown} args (≈ 20 K tokens, several minutes of
|
||||
* generation just to repeat back content the LLM already wrote to disk).
|
||||
* <p>
|
||||
* Workflow: agent uses {@code write_file} / {@code edit_file} to assemble
|
||||
* the markdown locally → calls this tool with the file path → docx is
|
||||
* rendered from disk in one IO call. Token cost ≈ 50 (just the path).
|
||||
*/
|
||||
@Tool(description = """
|
||||
Render a .docx file from a markdown FILE on disk and return a one-time download URL.
|
||||
Use this instead of `renderDocx` when the markdown body is large (>5 KB) — the
|
||||
LLM does not need to repeat its own previous output as a tool argument.
|
||||
|
||||
Typical workflow:
|
||||
1. write_file(path="report.md", content="# Report\\n...") // assemble markdown
|
||||
2. renderDocxFromFile(filePath="report.md", filename="monthly-report")
|
||||
3. return the download link to the user
|
||||
|
||||
The markdown file is read with UTF-8. Path resolution honors the workspace
|
||||
boundary (same rules as read_file / write_file).
|
||||
|
||||
Same supported markdown subset as renderDocx (headings, bold, lists, tables).
|
||||
Image references () are NOT yet rendered into the docx — they will
|
||||
appear as raw markdown text. SVG inline embedding requires a follow-up tool.
|
||||
""")
|
||||
public String renderDocxFromFile(
|
||||
@ToolParam(description = "Absolute or workspace-relative path to a markdown file")
|
||||
String filePath,
|
||||
@ToolParam(description = "Output filename without extension, e.g. 'monthly-report'")
|
||||
String filename,
|
||||
@ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false)
|
||||
String pageSize) {
|
||||
|
||||
if (filePath == null || filePath.isBlank()) {
|
||||
return "Error: filePath parameter is empty.";
|
||||
}
|
||||
|
||||
Path resolved;
|
||||
try {
|
||||
resolved = WorkspacePathGuard.validatePath(filePath);
|
||||
} catch (Exception e) {
|
||||
return "Error: path validation failed — " + e.getMessage();
|
||||
}
|
||||
if (!Files.exists(resolved)) {
|
||||
return "Error: file not found at " + resolved;
|
||||
}
|
||||
if (!Files.isRegularFile(resolved) || !Files.isReadable(resolved)) {
|
||||
return "Error: path is not a readable regular file " + resolved;
|
||||
}
|
||||
|
||||
String markdown;
|
||||
long mdBytes;
|
||||
try {
|
||||
mdBytes = Files.size(resolved);
|
||||
markdown = Files.readString(resolved, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
log.error("[DocxRender] read markdown failed for {}: {}", resolved, e.getMessage(), e);
|
||||
return "Error: failed to read markdown — " + e.getMessage();
|
||||
}
|
||||
if (markdown.isBlank()) {
|
||||
return "Error: markdown file is empty " + resolved;
|
||||
}
|
||||
|
||||
String safeName = sanitizeFilename(filename);
|
||||
String displayName = safeName + ".docx";
|
||||
String size = (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim();
|
||||
|
||||
try {
|
||||
long t0 = System.currentTimeMillis();
|
||||
byte[] bytes = renderer.render(markdown, size);
|
||||
String id = cache.put(bytes, displayName, DOCX_MIME);
|
||||
long elapsed = System.currentTimeMillis() - t0;
|
||||
log.info("[DocxRender] generated {} ({} bytes from {} bytes md, {}ms, id={})",
|
||||
displayName, bytes.length, mdBytes, elapsed, id);
|
||||
|
||||
String url = "/api/v1/files/generated/" + id;
|
||||
return "Document generated: [" + displayName + "](" + url + ") (link valid for 10 minutes).\n"
|
||||
+ "IMPORTANT: when replying to the user you **must** use the relative path `"
|
||||
+ url + "` verbatim. Do **not** prepend any https://, http:// or domain — "
|
||||
+ "the frontend will resolve the current host automatically.";
|
||||
} catch (Exception e) {
|
||||
log.error("[DocxRender] render failed for {} (source: {}): {}",
|
||||
displayName, resolved, e.getMessage(), e);
|
||||
return "Render failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip path separators and other unsafe characters from a user-supplied
|
||||
* filename. Falls back to a generic name when nothing usable remains.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user