Class: Assistant::TechnicalArticleManagementToolBuilder
- Inherits:
-
Object
- Object
- Assistant::TechnicalArticleManagementToolBuilder
- 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
-
.edit_token(article) ⇒ String
Returns a stale-write token for the current article version.
-
.link_row(link) ⇒ Hash
Returns a compact ContentLink row.
-
.queue_row(article, active_replacement_drafts:) ⇒ Hash
Returns a compact work-queue row.
-
.resolve_target!(target_type, target_id) ⇒ Array(ApplicationRecord, String)
Resolves and validates one curated related-material target.
-
.revision_edit_token(revision) ⇒ String
Returns a stale-write token for an ArticleRevision proposal.
-
.revision_link_rows(revision) ⇒ Array<Hash>
Returns reviewer-visible related-material rows from a revision snapshot.
-
.revision_queue_row(revision) ⇒ Hash
Returns only revision metadata needed by the list/work-queue tool.
-
.revision_snapshot(revision) ⇒ Hash
Returns the complete proposal snapshot used before revision writes.
-
.snapshot(article, account:, active_replacement_drafts: nil) ⇒ Hash
Returns the complete management snapshot used before every write.
-
.tools(account:, audit_context: {}) ⇒ Array<RubyLLM::Tool>
Builds all management tools for an authorized employee.
-
.validate_association_ids!(attributes) ⇒ void
Validates association IDs supplied to create/update operations.
-
.validate_revision_assignee!(employee_id) ⇒ void
Validates an ArticleRevision assignee against the Technical Support team.
Class Method Details
.edit_token(article) ⇒ String
Returns a stale-write token for the current article version.
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 |
.link_row(link) ⇒ Hash
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.
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.
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.
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.
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 |
.revision_link_rows(revision) ⇒ Array<Hash>
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.
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.
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.), review_assignee: employee_row(revision.review_assignee), reviewer: employee_row(revision.reviewer), submitted_at: revision.submitted_at&.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.
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_keywords: revision., 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.), review_assignee: employee_row(revision.review_assignee), reviewer: employee_row(revision.reviewer), edit_token: revision_edit_token(revision), submitted_at: revision.submitted_at&.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.
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 = 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?(account, :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.), 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., related_materials: .map { |link| link_row(link) }, consolidation_source_ids: consolidation_source_ids(), permitted_events: permitted_events(article, account, 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.
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?(account, :create, ArticleTechnical) || EmployeeToolAuthorization.allowed?(account, :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 = account.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.
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.
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 |