Class: Assistant::ProductSpecToolBuilder

Inherits:
Object
  • Object
show all
Defined in:
app/services/assistant/product_spec_tool_builder.rb

Overview

Builds RubyLLM::Tool subclasses for product specification management.
All tools require item_manager role — enforced at the service level by
AssistantChatController#available_chat_services.

Provides 6 tools:
search_specs — find specs by token/name/grouping/blurb
get_spec — full spec detail + scope summary
get_spec_scope — enumerate affected items
update_spec — update spec fields in place
clone_spec_to_item — clone and isolate to a single SKU
clone_spec_to_product_line — clone and narrow to a PL ± PC

Usage (via ChatToolBuilder):
tools = Assistant::ProductSpecToolBuilder.tools(audit_context: { user_id: 42 })

Constant Summary collapse

CRM_SPEC_URL =
"#{CRM_URL}/en-US/product_specifications".freeze
SPEC_SYSTEM_GUIDE =
<<~GUIDE
  ## Product Specification System Guide

  ### What is a product specification?
  A ProductSpecification defines ONE data point (a "token") for items —
  e.g. `voltage`, `watts`, `width`, `coverage`, `sku`. Tokens are unique per scope.
  The rendered value is stored per-item in `rendered_product_specifications` JSONB.

  ### How a spec is latched to an item (scope types)

  **1. Item-specific** (most specific — wins over everything else)
    Direct link via `items_product_specifications` join table.
    Has direct `item_ids`. No `product_line_id` or `product_category_id`.

  **2. Product-line + category** (second priority)
    `product_line_id` AND `product_category_id` both set.
    Matches items whose PL ancestry includes spec.product_line_id
    AND whose PC ancestry includes spec.product_category_id.
    Resolution: most specific (deepest) PL × PC combination wins first.

  **3. Category-only** (third priority)
    `product_line_id` nil, `product_category_id` set.
    Matches all items in that PC (and descendants) regardless of PL.

  **4. Product-line-only** (fourth priority)
    `product_category_id` nil, `product_line_id` set.
    Matches all items in that PL ancestry.

  **SKU regexp filter** — optional `sku_regexp` further restricts scope to items
    whose SKU matches the regex. Applied at step 2–4 above.

  **X-group specs** (grouping starts with "X-", e.g. "X-Amazon")
    Only apply to the main item — not to kit component items.

  **Propagation enum**
    unrestricted(0) — resolves for any matching item
    item(1)         — item-specific specs only
    product_line(2) — PL-scoped only

  **Resolution priority per token**
  When multiple specs share the same token, the most specific wins:
    item-specific → PL+PC (deepest match first) → secondary PL+PC
    → category-only → product-line-only
  Once a spec wins a token, no lower-priority spec for that token applies.

  ### Spec method types
    text          — static value from `text_blurb` (may contain Liquid)
    calculate_*   — computed from item fields (e.g. calculate_watts, calculate_ohms)
    sku, upc      — pulled directly from item attributes
    (others)      — see SAFE_METHODS list on ProductSpecification

  ### Units (the `units` field)
    `units` is a RubyUnits string that declares what unit the raw numeric value is in.
    It drives two things:
      1. The display symbol appended to the raw value (via UnitHelper.unit_symbol):
           "in"    →  ″   (e.g. "23.6″")
           "ft"    →  ′   (e.g. "5′")
           "sqft"  →  ft²
           "degF"  →  °F   (also "degf", case-insensitive)
           "degC"  →  °C
           "ohm"   →  Ω
           "W", "V", "A", "lb", "oz", "kg", "m", "cm", "mm", "sqm", etc. — appended with a space
      2. Unit conversion: when viewing in the CA locale, RubyUnits converts values
         (e.g. inches → cm, lbs → kg). Formatters are skipped for non-English locales.
    Common unit values used in this catalog: "in", "ft", "lb", "oz", "W", "V", "A",
      "ohm", "sqft", "sqm", "degF", "degC", "m", "cm", "mm", "kg", "y" (years).

  ### Formatters (the `formatter` field)
    Formatters post-process the raw numeric value AFTER unit conversion.
    Only applied when `units` is set AND locale is English (en-*).
    Three formatters are available:

    FeetAndInches
      Converts an inch value to feet+inches display.
      Input:  raw value in "in" units (e.g. text_blurb "20.5", units "in")
      Output: "1′ 8.5″"  — feet + remaining inches
      Use for: widths, heights, lengths when you want the feet+inches breakdown.
      NOTE: if other specs for the same field show just "X″" (plain inches), they have
      NO formatter set. To make all specs consistent, either add FeetAndInches to all
      or remove it (set formatter to blank) from the outlier.

    PoundsAndOunces
      Converts an ounce value to "X lbs, Y oz" display.
      Input:  raw value in "oz" or "lb" units
      Output: "10 lbs, 4 oz"
      Use for: weights displayed in lbs + oz breakdown.

    ClosestRational
      Approximates the raw number to its nearest simple fraction + unit symbol.
      Input:  any numeric value + units
      Output: e.g. "1/2 in", "3/4 oz"
      Use for: measurements best expressed as fractions rather than decimals.

    No formatter (blank)
      Falls through to default RubyUnits formatting: the number is stripped of
      insignificant zeros, then the unit symbol is appended.
      e.g. units "in", raw "23.6" → "23.6″"
      This is the most common case for straightforward dimension specs.

    DIAGNOSIS PATTERN — "spec renders differently from siblings":
      1. Use get_spec (by token + product_line_slug) to compare formatter fields.
      2. The outlier likely has a formatter set that others don't (or vice versa).
      3. Use update_spec with formatter: "" (empty string) to clear it, or
         set formatter: "FeetAndInches" to match the feet+inches siblings.
      4. Use enqueue_spec_refresh to re-render affected items without waiting for
         the next background pass.

  ### Liquid variables in text_blurb
  When `has_liquid_blurb = true`, `text_blurb` is rendered as a Liquid template.
  Available variables include:
    {{ sku }}                     → item SKU
    {{ width }}, {{ length }}     → other spec tokens by token name (output value)
    {{ width_raw }}, {{ width_units }}   → raw number and unit string for any token
    {{ width_in_raw }}, {{ width_ft_raw }}  → converted values in specific units
                                          (supports: ft, in, m, cm, mm, lb, oz, kg, g, sqft, sqm)
    {{ item_name }}, {{ short_description }}, {{ feature_1 }} … {{ feature_5 }}
    {{ calculate_watts }}, {{ calculate_ohms }}, {{ coverage_at_3_in }}, etc.
      (all SAFE_METHODS are callable as Liquid variables)

  ### Visibility enum
    internal(0)       — only visible to internal CRM users
    hidden(1)         — used in formulas but not shown
    open_visibility(2) — shown on the public product page

  ### Cloning strategy
  When you need a different value for a subset of items but want to keep the
  general spec for the rest:
    1. clone_spec_to_item — creates an item-specific override for exactly one SKU
    2. clone_spec_to_product_line — creates a more targeted PL or PL+PC spec
       that wins over the broader original due to higher specificity
  The original spec continues to apply to items not covered by the clone.

  ### Re-rendering
  After any spec change, `async_refresh_items` automatically enqueues
  `ItemAttributeWorker` for all affected items. Changes appear in
  `rendered_product_specifications` after those jobs complete (~minutes).
GUIDE

Class Method Summary collapse

Class Method Details

.read_toolsArray<RubyLLM::Tool>

Read-only spec tools — safe to expose to all authenticated users.

Returns:

  • (Array<RubyLLM::Tool>)


163
164
165
166
167
168
169
# File 'app/services/assistant/product_spec_tool_builder.rb', line 163

def read_tools
  [
    build_search_specs_tool,
    build_get_spec_tool,
    build_get_spec_scope_tool
  ]
end

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

Build all product spec management tools (read + write).

Parameters:

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

    must include :user_id

Returns:

  • (Array<RubyLLM::Tool>)


186
187
188
# File 'app/services/assistant/product_spec_tool_builder.rb', line 186

def tools(audit_context: {})
  read_tools + write_tools(audit_context: audit_context)
end

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

Write tools — require item_manager role.

Parameters:

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

    must include :user_id

Returns:

  • (Array<RubyLLM::Tool>)


174
175
176
177
178
179
180
181
# File 'app/services/assistant/product_spec_tool_builder.rb', line 174

def write_tools(audit_context: {})
  [
    build_update_spec_tool(audit_context),
    build_clone_spec_to_item_tool(audit_context),
    build_clone_spec_to_product_line_tool(audit_context),
    build_enqueue_spec_refresh_tool
  ]
end