Class: Assistant::TechnicalArticleManagementToolBuilder

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

Overview

Builds permissioned Sunny tools over the existing Technical Article model,
ContentLink graph, and editorial state machine. The tools never maintain a
second knowledge store. Ordinary changes to approved content go through the
shared ArticleRevision workflow and preserve Article identity; successor
Articles remain an explicit operation for true consolidation/decommissioning.

Defined Under Namespace

Classes: AddRelatedMaterialTool, ClassifyKnowledgeTool, CreateRevisionTool, CreateTool, GetTool, ListTool, ManagementTool, MergeTool, RemoveRelatedMaterialTool, ReplacementTool, ToolError, TransitionRevisionTool, TransitionTool, UpdateRevisionTool, UpdateTool

Constant Summary collapse

EDITABLE_FIELDS =
%i[
  subject description solution problem_code warranty_parts warranty_labor
  serial_number_low_range serial_number_high_range time_required
  product_line_ids product_category_ids item_ids tags user_id
].freeze
REVISION_EDITABLE_FIELDS =
ArticleRevision::EDITABLE_FIELDS.excluding(:title).freeze
KNOWLEDGE_ROLES =
Article::KNOWLEDGE_ROLES.keys.freeze
APPROVAL_EVENTS =
%w[approve_internal publish request_changes archive].freeze
CONFIRMATION_EVENTS =
%w[approve_internal publish archive].freeze
TRANSITION_EVENTS =
%w[
  submit_for_review request_changes approve_internal publish archive redraft
].freeze
TARGET_CONFIG =
{
  'technical_article' => [ArticleTechnical, ContentLink::TECHNICAL_ARTICLE_WORKFLOW_LINK_TYPE],
  'faq' => [ArticleFaq, 'related_faq'],
  'post' => [Post, 'related_post'],
  'video' => [Video, 'related_video'],
  'showcase' => [Showcase, 'related_showcase'],
  'publication' => [Item, 'related_publication'],
  'support_case' => [SupportCase, 'case_evidence']
}.freeze

Class Method Summary collapse

Class Method Details

.edit_token(article) ⇒ String

Returns a stale-write token for the current article version.

Parameters:

Returns:

  • (String)

    microsecond-precision update timestamp



906
907
908
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 906

def edit_token(article)
  article.updated_at.iso8601(6)
end

Returns a compact ContentLink row.

Two type fields, deliberately, because they differ for STI targets:
+target_type+ is the CONCRETE class (Post, ArticleTechnical) for display,
while +storage_type+ is the exclusive-arc base class (Article) that the
staged +key+ is built from. Rebuilding a key from target_type yields
"Post:3:related_post", which never matches the staged
"Article:3:related_post" — and removal now accepts content_link_key, so
that mismatch silently fails to remove anything.

Parameters:

Returns:

  • (Hash)

    link metadata



1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 1056

def link_row(link)
  target = link.target
  {
    id: link.id,
    link_type: link.link_type,
    target_type: target&.class&.name,
    storage_type: link.target_type,
    target_id: target&.id,
    target_name: link.target_display_name,
    context: link.context,
    workflow_managed: link.workflow_managed?,
    created_by: link.created_by_type,
    position: link.position,
    crm_url: target && crm_url_for(target)
  }.compact
end

.queue_row(article, active_replacement_drafts:) ⇒ Hash

Returns a compact work-queue row.

Parameters:

  • article (ArticleTechnical)

    source article

  • active_replacement_drafts (Hash)

    batched replacements by source

Returns:

  • (Hash)

    work-queue metadata



970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 970

def queue_row(article, active_replacement_drafts:)
  open_revision = article.open_revision
  {
    id: article.id,
    subject: article.subject,
    state: article.state,
    knowledge_role: article.knowledge_role,
    review_assignee: employee_row(article.review_assignee),
    precursor_id: article.precursor_id,
    successor_article_id: article.successor_article_id,
    active_replacement_draft_id: active_replacement_drafts.fetch(article.id, []).first&.id,
    open_revision: open_revision && revision_queue_row(open_revision),
    updated_at: article.updated_at&.iso8601,
    crm_url: "#{CRM_URL}/article_technicals/#{article.id}"
  }.compact
end

.resolve_target!(target_type, target_id) ⇒ Array(ApplicationRecord, String)

Resolves and validates one curated related-material target.

Parameters:

  • target_type (String)

    public target key

  • target_id (Integer)

    record ID

Returns:

Raises:

  • (ToolError)

    if the target is missing or unsafe



1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 1111

def resolve_target!(target_type, target_id)
  klass, link_type = TARGET_CONFIG[target_type.to_s]
  raise ToolError, "Unknown related-material type: #{target_type}." unless klass

  target = if target_type.to_s == 'publication'
             Item.publications.find_by(id: target_id)
           else
             klass.find_by(id: target_id)
           end
  raise ToolError, "#{target_type.to_s.humanize} ##{target_id} was not found." unless target
  raise ToolError, "#{target_type.to_s.humanize} ##{target_id} is not approved/current." unless valid_target?(target)

  [target, link_type]
end

.revision_edit_token(revision) ⇒ String

Returns a stale-write token for an ArticleRevision proposal.

Parameters:

Returns:

  • (String)

    microsecond-precision update timestamp



914
915
916
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 914

def revision_edit_token(revision)
  revision.updated_at.iso8601(6)
end

Returns reviewer-visible related-material rows from a revision snapshot.

Carries the same target_type/storage_type pair as
link_row, and for
the same reason: +key+ is built from the storage type.

Parameters:

Returns:

  • (Array<Hash>)

    ordered staged link metadata



1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 1081

def revision_link_rows(revision)
  entries = revision.content_link_data
  targets = ContentLink.targets_for(entries)

  entries.map do |entry|
    target_id = entry['target_id'].to_i
    target = targets[[entry['target_type'], target_id]]
    {
      id: entry['id'],
      key: entry['key'],
      link_type: entry['link_type'],
      target_type: target&.class&.name || entry['target_type'],
      storage_type: entry['target_type'],
      target_id:,
      target_name: target ? ContentLink.display_name_for(target) : 'Unavailable related material',
      context: entry['context'],
      workflow_managed: false,
      created_by: entry['created_by_type'],
      position: entry['position'],
      crm_url: target && crm_url_for(target)
    }.compact
  end
end

.revision_queue_row(revision) ⇒ Hash

Returns only revision metadata needed by the list/work-queue tool.
Full content and related materials remain available through GetTool.

Parameters:

Returns:

  • (Hash)

    compact revision work-queue metadata



992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 992

def revision_queue_row(revision)
  {
    id: revision.id,
    article_id: revision.article_id,
    revision_number: revision.revision_number,
    review_state: revision.review_state,
    change_notes: revision.change_notes,
    review_notes: revision.review_notes,
    author: employee_row(revision.author),
    review_assignee: employee_row(revision.review_assignee),
    reviewer: employee_row(revision.reviewer),
    submitted_at: revision.&.iso8601,
    reviewed_at: revision.reviewed_at&.iso8601,
    crm_url: "#{CRM_URL}/articles/#{revision.article_id}/revisions/#{revision.id}"
  }.compact
end

.revision_snapshot(revision) ⇒ Hash

Returns the complete proposal snapshot used before revision writes.

Parameters:

Returns:

  • (Hash)

    revision content, review metadata, and stale-write token



1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 1013

def revision_snapshot(revision)
  {
    id: revision.id,
    article_id: revision.article_id,
    revision_number: revision.revision_number,
    review_state: revision.review_state,
    subject: revision.subject,
    description: revision.description,
    solution: revision.solution,
    meta_description: revision.meta_description,
    meta_keywords: revision.meta_keywords,
    has_toc: revision.has_toc,
    toc_selector: revision.toc_selector,
    article_data: revision.article_data.except(
      '_captured',
      ArticleRevision::CONTENT_LINK_DATA_FIELD.to_s
    ),
    related_materials: revision_link_rows(revision),
    pages: revision.page_attributes,
    change_notes: revision.change_notes,
    review_notes: revision.review_notes,
    author: employee_row(revision.author),
    review_assignee: employee_row(revision.review_assignee),
    reviewer: employee_row(revision.reviewer),
    edit_token: revision_edit_token(revision),
    submitted_at: revision.&.iso8601,
    reviewed_at: revision.reviewed_at&.iso8601,
    crm_url: "#{CRM_URL}/articles/#{revision.article_id}/revisions/#{revision.id}"
  }.compact
end

.snapshot(article, account:, active_replacement_drafts: nil) ⇒ Hash

Returns the complete management snapshot used before every write.

Parameters:

  • article (ArticleTechnical)

    source article

  • account (Account)

    current account for transition filtering

  • active_replacement_drafts (Hash, nil) (defaults to: nil)

    optional batched replacements

Returns:

  • (Hash)

    current content, associations, links, and workflow state



924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 924

def snapshot(article, account:, active_replacement_drafts: nil)
  article.reload
  related_materials = article.outbound_content_links.ordered.with_targets.to_a
  open_revision = article.open_revision
  active_replacement_drafts ||= ArticleTechnical.active_replacement_drafts_by_source_id([article.id])
  active_replacement_draft = active_replacement_drafts.fetch(article.id, []).first
  {
    id: article.id,
    subject: article.subject,
    description: article.description,
    solution: article.solution,
    state: article.state,
    knowledge_role: article.knowledge_role,
    edit_token: edit_token(article),
    editable: article.draft? && EmployeeToolAuthorization.allowed?(, :update, article),
    requires_content_revision: article.state.in?(ArticleTechnical::APPROVED_STATES),
    open_revision: open_revision && revision_snapshot(open_revision),
    precursor_id: article.precursor_id,
    successor_article_id: article.successor_article_id,
    active_replacement_draft_id: active_replacement_draft&.id,
    review_assignee: employee_row(article.review_assignee),
    original_author: employee_row(article.original_author),
    problem_code: article.problem_code,
    warranty_parts: article.warranty_parts,
    warranty_labor: article.warranty_labor,
    serial_number_low_range: article.serial_number_low_range,
    serial_number_high_range: article.serial_number_high_range,
    time_required: article.time_required,
    product_lines: article.product_lines.map { |record| record.slice(:id, :name) },
    product_categories: article.product_categories.map { |record| record.slice(:id, :name) },
    items: article.items.map { |record| record.slice(:id, :sku, :name) },
    tags: article.tags,
    related_materials: related_materials.map { |link| link_row(link) },
    consolidation_source_ids: consolidation_source_ids(related_materials),
    permitted_events: permitted_events(article, , active_replacement_draft:),
    crm_url: "#{CRM_URL}/article_technicals/#{article.id}",
    revisions_url: "#{CRM_URL}/articles/#{article.id}/revisions",
    updated_at: article.updated_at.iso8601
  }.compact
end

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

Builds all management tools for an authorized employee.

Parameters:

  • account (Account, nil)

    current CRM account

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

    conversation and current employee IDs

Returns:

  • (Array<RubyLLM::Tool>)

    account-bound management tools



873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 873

def tools(account:, audit_context: {})
  allowed = EmployeeToolAuthorization.allowed?(, :create, ArticleTechnical) ||
            EmployeeToolAuthorization.allowed?(, :update, ArticleTechnical)
  return [] unless allowed

  # Authorization and attribution share one identity boundary. Never trust
  # a separate conversation metadata ID to choose the employee credited
  # with a write.
  actor = .party
  return [] unless actor.is_a?(Employee)
  return [] if audit_context[:user_id].present? && audit_context[:user_id].to_i != actor.id

  [
    ListTool,
    GetTool,
    CreateTool,
    UpdateTool,
    CreateRevisionTool,
    UpdateRevisionTool,
    TransitionRevisionTool,
    ClassifyKnowledgeTool,
    ReplacementTool,
    TransitionTool,
    AddRelatedMaterialTool,
    RemoveRelatedMaterialTool,
    MergeTool
  ].map { |tool_class| tool_class.new(account:, actor:) }
end

.validate_association_ids!(attributes) ⇒ void

This method returns an undefined value.

Validates association IDs supplied to create/update operations.

Parameters:

  • attributes (Hash)

    pending Technical Article attributes

Raises:

  • (ToolError)

    when IDs do not resolve to the requested model



1131
1132
1133
1134
1135
1136
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 1131

def validate_association_ids!(attributes)
  validate_ids!(ProductLine, attributes[:product_line_ids], 'Product Lines')
  validate_ids!(ProductCategory, attributes[:product_category_ids], 'Product Categories')
  validate_ids!(Item, attributes[:item_ids], 'Items')
  validate_technical_support_reviewer!(attributes[:user_id]) if attributes.key?(:user_id)
end

.validate_revision_assignee!(employee_id) ⇒ void

This method returns an undefined value.

Validates an ArticleRevision assignee against the Technical Support team.

Parameters:

  • employee_id (Integer, nil)

    proposed review assignee ID

Raises:

  • (ToolError)

    when the employee is missing or ineligible



1143
1144
1145
# File 'app/services/assistant/technical_article_management_tool_builder.rb', line 1143

def validate_revision_assignee!(employee_id)
  validate_technical_support_reviewer!(employee_id)
end