Class: Assistant::PdfToolBuilder

Inherits:
Object
  • Object
show all
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

Class Method Details

.build_compress_tool(ctx) ⇒ RubyLLM::Tool

Build the pdf_compress tool.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_compress failed: #{e.class} #{e.message}")
      { error: "Compress failed: #{e.message}" }.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.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_edit failed: #{e.class} #{e.message}")
      { error: "Edit failed: #{e.message}" }.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.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] extract_document failed: #{e.class} #{e.message}")
      { error: "Extraction failed: #{e.message}" }.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.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_fill_form failed: #{e.class} #{e.message}")
      { error: "Fill form failed: #{e.message}" }.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.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_find_replace failed: #{e.class} #{e.message}")
      { error: "Find-and-replace failed: #{e.message}" }.to_json
    end
  end
  klass.new
end

.build_generate_tool(ctx) ⇒ RubyLLM::Tool

Build the pdf_generate branded-document tool.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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:  }
      # 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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_generate failed: #{e.class} #{e.message}")
      { error: "Generate failed: #{e.message}" }.to_json
    ensure
      Assistant::PdfToolBuilder.send(:cleanup_temps!)
    end
  end
  klass.new
end

.build_inspect_tool(ctx) ⇒ RubyLLM::Tool

Build the pdf_inspect tool.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.meta.merge(source: source).to_json)
    rescue Pdf::Toolkit::Error, Assistant::PdfToolBuilder::InputError => e
      { error: e.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_inspect failed: #{e.class} #{e.message}")
      { error: "Inspect failed: #{e.message}" }.to_json
    ensure
      Assistant::PdfToolBuilder.send(:cleanup_temps!)
    end
  end
  klass.new
end

.build_merge_tool(ctx) ⇒ RubyLLM::Tool

Build the pdf_merge tool.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_merge failed: #{e.class} #{e.message}")
      { error: "Merge failed: #{e.message}" }.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.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_pages failed: #{e.class} #{e.message}")
      { error: "Page operation failed: #{e.message}" }.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.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_translate failed: #{e.class} #{e.message}")
      { error: "Translation could not be queued: #{e.message}" }.to_json
    end
  end
  klass.new
end

.build_typst_edit_tool(ctx) ⇒ RubyLLM::Tool

Build the pdf_typst_edit Typst-source edit tool.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_typst_edit failed: #{e.class} #{e.message}")
      { error: "Typst edit failed: #{e.message}" }.to_json
    end
  end
  klass.new
end

.build_typst_generate_tool(ctx) ⇒ RubyLLM::Tool

Build the pdf_typst_generate Typst authoring tool.

Parameters:

  • ctx (Hash)

    audit context with :conversation_id and :user_id

Returns:

  • (RubyLLM::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.message }.to_json
    rescue StandardError => e
      Rails.logger.error("[PdfToolBuilder] pdf_typst_generate failed: #{e.class} #{e.message}")
      { error: "Typst generation failed: #{e.message}" }.to_json
    end
  end
  klass.new
end

.tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>

Parameters:

  • audit_context (Hash) (defaults to: {})

    must include :conversation_id and :user_id

Returns:

  • (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