Class: Assistant::PdfToolBuilder
- Inherits:
-
Object
- Object
- Assistant::PdfToolBuilder
- Defined in:
- app/services/assistant/pdf_tool_builder.rb
Overview
Builds RubyLLM::Tool subclasses for the Sunny pdf_tools service — a curated
surface over Pdf::Toolkit (HexaPDF 1.9) for inspecting, editing, assembling,
and generating PDFs.
Provides 8 tools:
pdf_inspect — read structure, AcroForm fields, text preview
pdf_edit — overlay text / cover (redact) / image / watermark on pages
pdf_fill_form — set AcroForm field values
pdf_merge — concatenate several PDFs
pdf_pages — keep/reorder pages and/or rotate (extract, delete, reorder)
pdf_compress — reduce file size
pdf_generate — build a new branded PDF from a structured layout
extract_document — layout-aware document → Markdown via DoclingClient
(PDF incl. scanned/OCR, docx, xlsx, pptx, images, …)
I/O model
Input — source is either the id of an Upload attached to the current
conversation (the safe boundary: the user explicitly dropped it
into this chat) or a public http(s) URL. Arbitrary upload ids from
other resources are refused to avoid cross-record data exposure.
Output — written as a new assistant_attachment Upload linked to the
conversation; the tool returns a presigned download URL.
Usage (via ChatToolBuilder):
tools = Assistant::PdfToolBuilder.tools(audit_context: { conversation_id:, user_id: })
Defined Under Namespace
Classes: InputError
Constant Summary collapse
- MAX_BYTES =
Hard cap on a fetched/produced PDF (bytes). Inputs above this are refused.
60 * 1024 * 1024
- IPV6_GLOBAL_UNICAST =
IPv6 is validated by ALLOWLIST: only global-unicast space (2000::/3) is
even a candidate — everything outside it (site-local fec0::/10, NAT64
64:ff9b::, discard-only 100::/64, SRv6 5f00::/16, multicast, loopback,
unassigned space like 4000::/3, …) is non-global by IANA policy, and
denylisting an open-ended space is a losing game. IPAddr.new('2000::/3').freeze
- BLOCKED_IP_RANGES =
Special-use carve-outs INSIDE otherwise-routable space that IPAddr's
loopback?/private?/link_local? predicates do NOT cover, rejected by
public_address?. IPv4: "this network", CGNAT shared space, IETF
protocol assignments, documentation and benchmarking nets, 6to4 relay
anycast, multicast, reserved space. IPv6 (inside 2000::/3): the ENTIRE
IETF protocol-assignment block 2001::/23 (Teredo, ORCHID, benchmarking —
mostly non-global, and its few globally-reachable suballocations are
infrastructure anycast that will never serve a document), documentation,
new documentation space, 6to4 (embeds arbitrary IPv4). %w[ 0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4 2001::/23 2001:db8::/32 2002::/16 3fff::/20 ].map { |cidr| IPAddr.new(cidr) }.freeze
- FETCH_DEADLINE_SECONDS =
Whole-download wall-clock cap for a pinned remote fetch. read_timeout only
bounds each individual socket read, so a slow-drip server could otherwise
hold the worker and tempfile open indefinitely. 120- COMPRESS_LEVELS =
Friendly compression presets → Ghostscript pdf settings.
{ 'screen' => '/screen', # smallest, 72 dpi 'ebook' => '/ebook', # balanced, 150 dpi 'printer' => '/printer', # high quality, 300 dpi 'prepress' => '/prepress' # largest, color-preserving }.freeze
- FIND_REPLACE_MAX_ENQUEUE =
Hard cap on documents enqueued per pdf_find_replace call, even with
confirm: true. The bulk-op rule (count first, narrow scope, explicit
confirmation) is enforced by the dry-run → confirm flow; this is the
backstop. Defined at module level so tool descriptions can interpolate it. 200
Class Method Summary collapse
-
.build_compress_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_compresstool. -
.build_edit_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_editoverlay/stamp tool. -
.build_extract_tool(ctx) ⇒ RubyLLM::Tool
Build the
extract_documentlayout-aware Markdown extraction tool. -
.build_fill_form_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_fill_formtool. -
.build_find_replace_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_find_replacecross-publication find/replace tool. -
.build_generate_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_generatebranded-document tool. -
.build_inspect_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_inspecttool. -
.build_merge_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_mergetool. -
.build_pages_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_pagesselect/reorder/rotate tool. -
.build_translate_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_translateDeepL translation tool. -
.build_typst_edit_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_typst_editTypst-source edit tool. -
.build_typst_generate_tool(ctx) ⇒ RubyLLM::Tool
Build the
pdf_typst_generateTypst authoring tool. - .tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>
Class Method Details
.build_compress_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_compress tool.
383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 383 def build_compress_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Reduce a PDF's file size with Ghostscript. `level` trades size vs quality: "screen" (smallest), "ebook" (balanced, default), "printer" (high quality), "prepress" (largest, preserves color). Returns the smaller file; if the level wouldn't help, the original is returned unchanged. DESC parameters type: 'object', properties: { source: { type: 'string', description: 'Upload id of a PDF attached to this conversation, or a public http(s) URL.' }, level: { type: 'string', enum: %w[screen ebook printer prepress], description: 'Compression preset (default ebook).' }, filename: { type: 'string', description: 'Optional output filename.' } }, required: %w[source] define_method(:name) { 'pdf_compress' } define_method(:execute) do |source:, level: 'ebook', filename: nil, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) path = builder.send(:resolve_pdf_path, source, conv) gs = Assistant::PdfToolBuilder::COMPRESS_LEVELS.fetch(level.to_s, '/ebook') result = Pdf::Toolkit.compress(path, level: gs) builder.send(:persist!, conv, result, filename, source, 'compressed').to_json rescue Pdf::Toolkit::Error, Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_compress failed: #{e.class} #{e.}") { error: "Compress failed: #{e.}" }.to_json ensure Assistant::PdfToolBuilder.send(:cleanup_temps!) end end klass.new end |
.build_edit_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_edit overlay/stamp tool.
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 155 def build_edit_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Overlay content on top of an existing PDF's pages — the tool for editing a PDF we did NOT generate from data (e.g. a third-party spec sheet). COORDINATES: PDF points (1/72 inch) from the page's LOWER-LEFT corner, x →right, y →up. US Letter is 612×792 pt. Call pdf_inspect first to get page sizes. `operations` is an ordered array; each item has a `type`: • "cover" — draw a filled rectangle to hide existing content (x, y, width, height, color hex default FFFFFF=white) • "text" — draw text (text, x, y, size, color hex, font, bold) • "image" — place an image (source = upload id/url, x, y, width, height) • "watermark" — diagonal translucent text across the page (text, size, color, opacity 0-1, angle) REDACT-AND-REPLACE pattern (e.g. change a phone number in a footer): one "cover" op over the old text, then a "text" op with the new value at the same spot. This is reliable for a known location; it does NOT find text for you — use pdf_inspect's text preview to locate it. NOTE: "cover" hides text VISUALLY but does not remove the original glyphs from the file — they can still be extracted. Use it to change a displayed value, not to redact sensitive/confidential data. fonts: sofia, sofia_bold (Sofia Pro — the WarmlyYours website font, use this to match brand text), orpheus, orpheus_bold (Orpheus Pro serif, for elegant headings), helvetica, helvetica_bold, nimbus, nimbus_bold. `pages`: "all" (default), a number, or a list like "1,3-4". DESC parameters type: 'object', properties: { source: { type: 'string', description: 'Upload id of a PDF attached to this conversation, or a public http(s) URL.' }, operations: { type: 'array', description: 'Ordered overlay operations applied to each target page.', items: { type: 'object', properties: { type: { type: 'string', enum: %w[text cover image watermark] }, text: { type: 'string', description: 'Text content (type=text or watermark).' }, x: { type: 'number', description: 'X in points from lower-left.' }, y: { type: 'number', description: 'Y in points from lower-left.' }, width: { type: 'number', description: 'Width in points (cover/image).' }, height: { type: 'number', description: 'Height in points (cover/image).' }, size: { type: 'number', description: 'Font size in points (text/watermark).' }, color: { type: 'string', description: 'Hex color e.g. "FFFFFF" or "323232".' }, font: { type: 'string', enum: %w[sofia sofia_bold orpheus orpheus_bold helvetica helvetica_bold nimbus nimbus_bold] }, bold: { type: 'boolean' }, opacity: { type: 'number', description: 'Watermark opacity 0-1.' }, angle: { type: 'number', description: 'Watermark angle in degrees.' }, source: { type: 'string', description: 'For type=image: upload id (this conversation) or public image URL.' } }, required: %w[type] } }, pages: { type: 'string', description: 'Target pages: "all" (default), a number, or a list like "1,3-4".' }, filename: { type: 'string', description: 'Optional output filename.' }, stage_for_review: { type: 'boolean', description: 'Send the result to the CRM PDF studio for review and import into the publication library, instead of returning a chat download. Returns a studio URL.' } }, required: %w[source operations] define_method(:name) { 'pdf_edit' } define_method(:execute) do |source:, operations:, pages: 'all', filename: nil, stage_for_review: false, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) path = builder.send(:resolve_pdf_path, source, conv) ops = builder.send(:resolve_image_ops, operations, conv) result = Pdf::Toolkit.stamp(path, operations: ops, pages: builder.send(:parse_pages, pages)) if stage_for_review builder.send(:stage_for_review!, conv, result.bytes, layout: { 'operations' => operations, 'source' => source }, kind: 'edited', title: filename || "edited-#{source}").to_json else builder.send(:persist!, conv, result, filename, source, 'edited').to_json end rescue Pdf::Toolkit::Error, Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_edit failed: #{e.class} #{e.}") { error: "Edit failed: #{e.}" }.to_json ensure Assistant::PdfToolBuilder.send(:cleanup_temps!) end end klass.new end |
.build_extract_tool(ctx) ⇒ RubyLLM::Tool
Build the extract_document layout-aware Markdown extraction tool.
515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 515 def build_extract_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Extract a document's full content as clean, layout-aware Markdown — real tables with rows/columns intact, headings, reading order — via the docling extraction service. Handles PDFs (scanned pages are OCR'd automatically), Word (.docx), Excel (.xlsx), PowerPoint (.pptx), HTML, CSV, and images. USE THIS to read or answer questions over an attached document — supplier spec sheets, datasheets, price lists, invoices, retailer workbooks. pdf_inspect only previews the raw text layer (table structure is lost); extract_document reconstructs it. The result is cached on the upload, so repeat calls are instant. Long documents are returned in slices: if `truncated` is true, call again with `offset` = `next_offset` to continue reading. `source` is the upload id of a file attached to THIS conversation, or a public http(s) URL ending in a supported file extension. DESC parameters type: 'object', properties: { source: { type: 'string', description: 'Upload id of a file attached to this conversation, or a public http(s) URL with a file extension.' }, offset: { type: 'integer', description: 'Character offset to continue reading a long document from (default 0).' }, force: { type: 'boolean', description: 'Re-extract even when a cached extraction exists (default false).' } }, required: %w[source] define_method(:name) { 'extract_document' } define_method(:execute) do |source:, offset: 0, force: false, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) builder.send(:extract_markdown_result, source, conv, offset: offset, force: force).to_json rescue DoclingClient::Error, Pdf::Toolkit::Error, Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] extract_document failed: #{e.class} #{e.}") { error: "Extraction failed: #{e.}" }.to_json ensure Assistant::PdfToolBuilder.send(:cleanup_temps!) end end klass.new end |
.build_fill_form_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_fill_form tool.
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 249 def build_fill_form_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Fill an interactive PDF form (AcroForm) by setting field values by name. Use pdf_inspect first to get the exact field names and types. This is the clean way to fill a fillable template; for flat PDFs with no form fields, use pdf_edit instead. `values` maps field name → value. Text fields take a string; checkboxes take true/false. Set `flatten` true to bake the values in permanently (the form is no longer editable afterward). DESC parameters type: 'object', properties: { source: { type: 'string', description: 'Upload id of a PDF attached to this conversation, or a public http(s) URL.' }, values: { type: 'object', description: 'Map of field_name => value. Text → string; checkbox → true/false.', additionalProperties: true }, flatten: { type: 'boolean', description: 'Bake values in permanently (default false).' }, filename: { type: 'string', description: 'Optional output filename.' } }, required: %w[source values] define_method(:name) { 'pdf_fill_form' } define_method(:execute) do |source:, values:, flatten: false, filename: nil, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) path = builder.send(:resolve_pdf_path, source, conv) result = Pdf::Toolkit.fill_form(path, values: values.to_h, flatten: flatten) builder.send(:persist!, conv, result, filename, source, 'filled').to_json rescue Pdf::Toolkit::Error, Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_fill_form failed: #{e.class} #{e.}") { error: "Fill form failed: #{e.}" }.to_json ensure Assistant::PdfToolBuilder.send(:cleanup_temps!) end end klass.new end |
.build_find_replace_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_find_replace cross-publication find/replace tool.
621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 621 def build_find_replace_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Find publications whose PDF text mentions a phrase and, ONLY after the user confirms the blast radius, queue a find-and-replace across them (e.g. "everywhere the spec says 240V, make it 208V"). TWO-STEP PROTOCOL — always follow it: 1. Call WITHOUT confirm (dry run): returns the COUNT of affected publications and a sample. Report this to the user and ask for confirmation. Never skip this step. 2. Only when the user explicitly confirms, call again with confirm: true and the replacement text. Each document is edited in the background (redact + overlay in its text layer) and staged in the PDF studio for HUMAN REVIEW — nothing is auto-imported; the reviewer imports each as a revision. Limits: only single-run text matches are replaced (fragmented or rasterized text is reported per document, not edited); the overlay uses Helvetica and does not match the original font; redaction is visual — the old text remains in the text layer under the cover, so text extraction reads both values until re-indexed by hand. DESC parameters type: 'object', properties: { query: { type: 'string', description: 'Literal phrase to find in publication PDF text (e.g. a spec value).' }, replacement: { type: 'string', description: 'Replacement text. Required when confirm is true.' }, confirm: { type: 'boolean', description: 'false (default) = dry run, just count and sample; true = queue the edits after explicit user confirmation.' }, limit: { type: 'integer', description: "Max documents to queue (default 25, max #{Assistant::PdfToolBuilder::FIND_REPLACE_MAX_ENQUEUE})." } }, required: %w[query] define_method(:name) { 'pdf_find_replace' } define_method(:execute) do |query:, replacement: nil, confirm: false, limit: 25, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) builder.send(:find_replace, conv, captured, query: query, replacement: replacement, confirm: confirm, limit: limit).to_json rescue Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_find_replace failed: #{e.class} #{e.}") { error: "Find-and-replace failed: #{e.}" }.to_json end end klass.new end |
.build_generate_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_generate branded-document tool.
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 425 def build_generate_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Create a NEW branded PDF (WarmlyYours logo + brand font) from structured content — a one-pager, summary, or simple spec sheet. For editing an existing PDF, use pdf_edit/pdf_fill_form instead. `blocks` is an ordered array; each item has a `type`: • "heading" — section heading (text) • "paragraph" — body text (text) • "bullets" — a bulleted list (items: [string, ...]) • "spacer" — vertical gap (size in points, default 8) • "image" — embed an image (source = upload id attached to this conversation, or a public image URL; optional caption) • "video" — a "scan to watch" QR code + a link (url, optional caption) • "link" — a labelled link (text, url) Content flows top-to-bottom and paginates automatically. For a summary sheet from a video, read its transcript and write the heading/paragraph blocks yourself, then add a "video" block linking back to it. Set `template: "letterhead"` to render a formal WarmlyYours cover letter: a branded header band (logo + "Modern Radiant Heating Solutions" tagline) and a phone · address · website footer on every page, body in the brand font, and "heading" blocks as a burgundy serif. Compose the letter as blocks — the date (a paragraph with `bold: true`), salutation, body paragraphs, an "About WarmlyYours" heading, and the closing; title/ subtitle/logo are ignored in letterhead mode. DESC parameters type: 'object', properties: { title: { type: 'string', description: 'Document title (shown large under the logo).' }, subtitle: { type: 'string', description: 'Optional subtitle.' }, blocks: { type: 'array', description: 'Ordered content blocks.', items: { type: 'object', properties: { type: { type: 'string', enum: %w[heading paragraph bullets spacer image video link] }, text: { type: 'string', description: 'Text for heading/paragraph, or label for link.' }, bold: { type: 'boolean', description: 'Render a paragraph in bold (e.g. the date line on a letterhead).' }, items: { type: 'array', items: { type: 'string' }, description: 'List items for type=bullets.' }, size: { type: 'number', description: 'Gap in points for type=spacer.' }, source: { type: 'string', description: 'For type=image: upload id (this conversation) or public image URL.' }, url: { type: 'string', description: 'For type=video/link: the URL.' }, caption: { type: 'string', description: 'Optional caption for type=image/video.' } }, required: %w[type] } }, orientation: { type: 'string', enum: %w[portrait landscape], description: 'Default portrait.' }, template: { type: 'string', enum: %w[standard letterhead], description: 'Layout: "standard" (logo + title, default) or "letterhead" (formal cover-letter chrome).' }, logo: { type: 'boolean', description: 'Include the WarmlyYours logo header (default true; ignored for letterhead).' }, filename: { type: 'string', description: 'Optional output filename.' }, stage_for_review: { type: 'boolean', description: 'Send the result to the CRM PDF studio for review and import into the publication library, instead of returning a chat download. Returns a studio URL.' } }, required: %w[title blocks] define_method(:name) { 'pdf_generate' } define_method(:execute) do |title:, blocks:, subtitle: nil, orientation: 'portrait', template: 'standard', logo: true, filename: nil, stage_for_review: false, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) layout = { title: title, subtitle: subtitle, blocks: blocks, orientation: orientation, template: template, logo: logo } # Resolve image/video references for rendering; keep the original # blocks in the stored layout so the studio can iterate on them. render_layout = layout.merge(blocks: builder.send(:resolve_generate_blocks, blocks, conv)) result = Pdf::Toolkit.generate(layout: render_layout) if stage_for_review builder.send(:stage_for_review!, conv, result.bytes, layout: layout, kind: 'generated', title: title).to_json else builder.send(:persist!, conv, result, filename, nil, title.to_s.parameterize.presence || 'document').to_json end rescue Pdf::Toolkit::Error, Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_generate failed: #{e.class} #{e.}") { error: "Generate failed: #{e.}" }.to_json ensure Assistant::PdfToolBuilder.send(:cleanup_temps!) end end klass.new end |
.build_inspect_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_inspect tool.
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 109 def build_inspect_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Inspect a PDF without changing it: page count, page sizes and rotation, whether it has fillable AcroForm fields (and their names/types/values), embedded image count, document metadata, and a per-page text preview. Use this FIRST when the user attaches a PDF and wants to edit it — the field list tells you whether to use pdf_fill_form (it has form fields) or pdf_edit (overlay text at coordinates). The text preview helps you locate what to change. `source` is the upload id of a PDF attached to THIS conversation, or a public http(s) URL. DESC parameters type: 'object', properties: { source: { type: 'string', description: 'Upload id of a PDF attached to this conversation, or a public http(s) URL.' } }, required: %w[source] define_method(:name) { 'pdf_inspect' } define_method(:execute) do |source:, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) path = builder.send(:resolve_pdf_path, source, conv) result = Pdf::Toolkit.inspect_pdf(path) Assistant::ChatToolBuilder.truncate_result(result..merge(source: source).to_json) rescue Pdf::Toolkit::Error, Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_inspect failed: #{e.class} #{e.}") { error: "Inspect failed: #{e.}" }.to_json ensure Assistant::PdfToolBuilder.send(:cleanup_temps!) end end klass.new end |
.build_merge_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_merge tool.
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 295 def build_merge_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Merge several PDFs into one, in the order given. Each source is an upload id attached to this conversation or a public http(s) URL. DESC parameters type: 'object', properties: { sources: { type: 'array', items: { type: 'string' }, description: 'Ordered list of upload ids / public http(s) URLs to concatenate.' }, filename: { type: 'string', description: 'Optional output filename.' } }, required: %w[sources] define_method(:name) { 'pdf_merge' } define_method(:execute) do |sources:, filename: nil, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) raise Assistant::PdfToolBuilder::InputError, 'provide at least two sources to merge' if Array(sources).size < 2 paths = Array(sources).map { |s| builder.send(:resolve_pdf_path, s, conv) } result = Pdf::Toolkit.merge(paths: paths) builder.send(:persist!, conv, result, filename, nil, 'merged').to_json rescue Pdf::Toolkit::Error, Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_merge failed: #{e.class} #{e.}") { error: "Merge failed: #{e.}" }.to_json ensure Assistant::PdfToolBuilder.send(:cleanup_temps!) end end klass.new end |
.build_pages_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_pages select/reorder/rotate tool.
335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 335 def build_pages_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Reorganize a PDF's pages. Combines two operations: • keep_pages — an ordered list of page numbers to KEEP. This covers extraction (a subset, e.g. [1,2]), deletion (omit a page), and reordering (e.g. [3,1,2]). Omit to keep all pages. • rotate_degrees — rotate pages by a multiple of 90 (clockwise), applied to rotate_pages ("all" by default). At least one of keep_pages or rotate_degrees must be provided. DESC parameters type: 'object', properties: { source: { type: 'string', description: 'Upload id of a PDF attached to this conversation, or a public http(s) URL.' }, keep_pages: { type: 'array', items: { type: 'integer' }, description: 'Ordered 1-based page numbers to keep (extract/delete/reorder).' }, rotate_degrees: { type: 'integer', description: 'Rotate by a multiple of 90 (clockwise).' }, rotate_pages: { type: 'string', description: 'Pages to rotate: "all" (default), a number, or a list like "1,3-4".' }, filename: { type: 'string', description: 'Optional output filename.' } }, required: %w[source] define_method(:name) { 'pdf_pages' } define_method(:execute) do |source:, keep_pages: nil, rotate_degrees: nil, rotate_pages: 'all', filename: nil, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) raise Assistant::PdfToolBuilder::InputError, 'provide keep_pages and/or rotate_degrees' if keep_pages.blank? && rotate_degrees.nil? path = builder.send(:resolve_pdf_path, source, conv) result = builder.send(:apply_page_ops, path, keep_pages, rotate_degrees, rotate_pages) builder.send(:persist!, conv, result, filename, source, 'pages').to_json rescue Pdf::Toolkit::Error, Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_pages failed: #{e.class} #{e.}") { error: "Page operation failed: #{e.}" }.to_json ensure Assistant::PdfToolBuilder.send(:cleanup_temps!) end end klass.new end |
.build_translate_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_translate DeepL translation tool.
567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 567 def build_translate_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Translate a publication's PDF into another language while preserving its images and approximate layout with DeepL, then stage the result for human review in the PDF studio as a language variant of the source publication. DeepL may reflow pages and substitute fonts. USE THIS for requests like "translate this document to French" or "translate this document to Spanish". Only the PDF text layer is translated by default — text rasterized INTO images is not; use `translate_images` when the user explicitly wants those regenerated. Canadian French is supported: use `fr-CA` (not generic `fr`) for Québec/Canadian publications. Runs in the background (minutes for large manuals). When finished, a message appears IN THIS CONVERSATION with a link to review the translated PDF in the PDF studio (or the reason it failed). Tell the user to watch for it rather than re-asking. `source` is a publication SKU or id (defaults to the publication this conversation is working on). `lang_out` is the target language code (`fr-CA`, `fr`, `es`, `de`, `it`, `nl`, `pl`, or `sv`). DESC parameters type: 'object', properties: { source: { type: 'string', description: 'Publication SKU or id. Omit to use the publication attached to this conversation.' }, lang_out: { type: 'string', enum: DeepLClient::DOCUMENT_TARGETS.keys, description: 'DeepL PDF target: use "fr-CA" for Canadian French; "fr" is generic French.' }, translate_images: { type: 'boolean', description: 'Also translate text rasterized INTO images (diagrams, callouts) via vision + image regeneration (default false; slower, capped at 10 images).' } }, required: %w[lang_out] define_method(:name) { 'pdf_translate' } define_method(:execute) do |lang_out:, source: nil, translate_images: false, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) builder.send(:queue_translation, conv, captured, source: source, lang_out: lang_out, translate_images: translate_images).to_json rescue Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_translate failed: #{e.class} #{e.}") { error: "Translation could not be queued: #{e.}" }.to_json end end klass.new end |
.build_typst_edit_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_typst_edit Typst-source edit tool.
731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 731 def build_typst_edit_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Edit an existing publication whose master is Typst source (uploads.typst_source — set for documents authored via pdf_typst_generate). TWO MODES: 1. READ (no source_typst): returns the publication's current .typ master source so you can plan the edit. 2. WRITE (source_typst given): compiles your modified source and stages the result in the PDF studio as an edit of the source publication (reviewer imports as a revision). USE THIS for "update the spec in this publication", "fix the warranty wording" — whenever a Typst master exists. If the publication has no Typst master, say so and fall back to pdf_edit / pdf_translate. DESC parameters type: 'object', properties: { publication: { type: 'string', description: 'Publication SKU or id. Omit to use the publication attached to this conversation.' }, source_typst: { type: 'string', description: 'The full modified Typst source. Omit to just READ the current master.' }, title: { type: 'string', description: 'Document title (used for the staged filename).' } }, required: %w[] define_method(:name) { 'pdf_typst_edit' } define_method(:execute) do |publication: nil, source_typst: nil, title: nil, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) pub = builder.send(:resolve_translation_publication, publication, conv) builder.send(:typst_edit, conv, pub, source_typst: source_typst, title: title).to_json rescue Assistant::PdfToolBuilder::InputError => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_typst_edit failed: #{e.class} #{e.}") { error: "Typst edit failed: #{e.}" }.to_json end end klass.new end |
.build_typst_generate_tool(ctx) ⇒ RubyLLM::Tool
Build the pdf_typst_generate Typst authoring tool.
674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 674 def build_typst_generate_tool(ctx) captured = ctx klass = Class.new(RubyLLM::Tool) do description <<~DESC Author a NEW branded PDF from Typst markup and compile it — the PREFERRED way to create documents (over pdf_generate): the .typ source is stored as the document's master, so future edits and translations recompile from source instead of patching a rendered PDF. Write a complete Typst document. ALWAYS start from the brand prelude (chrome, fonts, colors, helpers): #import "/data/typst/warmly.typ": warmly-doc, spec-table, checklist #show: warmly-doc.with(title: "…", subtitle: "…") Then use = headings, paragraphs, #spec-table([Label], [Value], …) for spec tables, and #checklist[Item][Item] for checkbox lists. If compilation fails, the error contains typst's diagnostics (line:column + hint) — fix the markup and call again. The compiled PDF is staged in the PDF studio for review by default. DESC parameters type: 'object', properties: { source_typst: { type: 'string', description: 'Complete Typst document source (must import the brand prelude).' }, title: { type: 'string', description: 'Document title (used for the staged filename).' }, stage_for_review: { type: 'boolean', description: 'Stage in the PDF studio (default true); false returns a download instead.' } }, required: %w[source_typst] define_method(:name) { 'pdf_typst_generate' } define_method(:execute) do |source_typst:, title: nil, stage_for_review: true, **_| builder = Assistant::PdfToolBuilder conv = builder.send(:conversation!, captured) result = Pdf::Typst.compile(source_typst) if stage_for_review.to_b builder.send(:stage_for_review!, conv, result.bytes, layout: { 'typst_source' => source_typst }, kind: 'generated', title: title.presence || 'Typst document').to_json else builder.send(:persist!, conv, result, "#{title.to_s.parameterize.presence || 'document'}.pdf", nil, 'document.pdf').to_json end rescue Pdf::Typst::Error => e { error: e. }.to_json rescue StandardError => e Rails.logger.error("[PdfToolBuilder] pdf_typst_generate failed: #{e.class} #{e.}") { error: "Typst generation failed: #{e.}" }.to_json end end klass.new end |
.tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'app/services/assistant/pdf_tool_builder.rb', line 88 def tools(audit_context: {}) [ build_inspect_tool(audit_context), build_edit_tool(audit_context), build_fill_form_tool(audit_context), build_merge_tool(audit_context), build_pages_tool(audit_context), build_compress_tool(audit_context), build_generate_tool(audit_context), build_extract_tool(audit_context), build_translate_tool(audit_context), build_find_replace_tool(audit_context), build_typst_generate_tool(audit_context), build_typst_edit_tool(audit_context) ] end |