Fixes #6064 - Add new agent type for ticket tagging.

Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Rene Reimann <rr@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
This commit is contained in:
Dominik Klein 2026-04-27 10:52:39 +02:00
parent 1868dfbdcd
commit 1a33abad0d
29 changed files with 1675 additions and 128 deletions

View file

@ -172,30 +172,42 @@ AIAgentModalMixin =
@placeholderObjectAttributes[fieldName] = placeholder_attribute
filterByAttributeConditions: (items = []) ->
# Reserved first-segment keywords route to alternative sources.
# Any other first segment is treated as a placeholder attribute name.
resolvers =
app_config: (key) -> App.Config.get(key)
_.filter(items, (item) =>
# If no condition is specified, include the item.
return true if not item.condition
# Parse the condition (format: "key.property").
conditionParts = item.condition.split('.')
return true if conditionParts.length isnt 2
[placeholderKey, propertyName] = conditionParts
# Support negation in condition by prefixing the key with "!".
if /^!/.test(placeholderKey)
placeholderKey = placeholderKey.replace(/^!/, '')
# Support negation in condition by prefixing with "!".
condition = item.condition
if /^!/.test(condition)
condition = condition.replace(/^!/, '')
negateCondition = true
# Check if the placeholder attribute exists and has the specified property.
placeholderAttr = @placeholderObjectAttributes?[placeholderKey]
if not placeholderAttr
return false if not negateCondition
return true if negateCondition
# Parse the condition (format: "key.property").
conditionParts = condition.split('.')
return true if conditionParts.length isnt 2
# Check if the property exists and is "truthy"/"not empty".
[key, propertyName] = conditionParts
# Resolve against a reserved source, or fall back to placeholder attributes.
value = if resolvers[key]
resolvers[key](propertyName)
else
@placeholderObjectAttributes?[key]?[propertyName]
# Check if the value is "truthy"/"not empty".
# Remember to negate the result if the condition was prefixed with "!".
result = placeholderAttr[propertyName] is true or not _.isEmpty(placeholderAttr[propertyName])
# `_.isEmpty` treats numeric scalars as empty, so handle numbers explicitly.
result = if _.isBoolean(value)
value
else if _.isNumber(value)
not _.isNaN(value) and value isnt 0
else
not _.isEmpty(value)
return result if not negateCondition
return not result if negateCondition
)
@ -221,6 +233,10 @@ AIAgentModalMixin =
stepHelp: ->
_.find(@agentType?.form_schema, (item) => item.step is @step and item.help)?.help or ''
stepWarnings: ->
warnings = _.find(@agentType?.form_schema, (item) => item.step is @step and item.warnings)?.warnings or []
@filterByAttributeConditions(warnings)
stepErrors: ->
_.find(@agentType?.form_schema, (item) => item.step is @step and item.errors)?.errors or ''
@ -324,6 +340,11 @@ AIAgentModalMixin =
.html(App.i18n.translateContent(helpText))
.prependTo(@controller.form)
for warning in @stepWarnings()
$('<div />').addClass('alert alert--warning')
.html(App.i18n.translateContent(warning.text, (warning.textPlaceholders or [])...))
.prependTo(@controller.form)
return if not errorTexts = @stepErrors()
alert = $('<div />').addClass('alert alert--danger')

View file

@ -108,10 +108,10 @@ class AI::Agent < ApplicationModel
end
end
def execution_definition
def execution_definition(context: {})
return definition if agent_type.blank?
agent_type_object.execution_definition.deep_stringify_keys.deep_merge(definition)
agent_type_object.execution_definition(context:).deep_stringify_keys.deep_merge(definition)
end
def execution_action_definition
@ -126,6 +126,20 @@ class AI::Agent < ApplicationModel
)
end
# Merge the agent type's form-visible defaults into the serialized
# `type_enrichment_data` so the legacy edit dialog hydrates fields that
# weren't saved at creation time (e.g. `tag_new_rules` once `tag_new` is
# enabled later). Runtime-only `base_type_enrichment_data` stays on the
# type object and never leaks into the form.
def attributes_with_association_ids
attrs = super
return attrs if agent_type_class.blank?
defaults = agent_type_class.new.default_type_enrichment_data.stringify_keys
attrs['type_enrichment_data'] = defaults.merge(attrs['type_enrichment_data'] || {})
attrs
end
def self.working_on_ticket?(ticket)
ActiveJobLock
.exists?(['lock_key LIKE ?', "TriggerAIAgentJob/Ticket/#{ticket.id}/AIAgent/%"])

View file

@ -3,6 +3,14 @@
class AI::Agent::Type
include Mixin::RequiredSubPaths
# Simple value object for inline precondition checks defined directly in a type.
# `condition` is a callable evaluated lazily by `passed?`, so checks are only
# executed when needed and short-circuit evaluation in the service is possible.
# For checks reused across multiple types, extract to a dedicated service class.
PreconditionCheck = Data.define(:name, :condition) do
def passed? = condition.call
end
def self.available_types
@available_types ||= descendants.sort_by(&:name)
end
@ -11,10 +19,32 @@ class AI::Agent::Type
available_types.map { |x| x.new.data }
end
attr_reader :enrichment_data
def initialize(type_enrichment_data: {})
@enrichment_data = type_enrichment_data
@user_type_enrichment_data = type_enrichment_data.stringify_keys
end
# Per-instance enrichment data used for prompt rendering. Layers:
# `base_type_enrichment_data` (runtime-only, hidden from the form),
# `default_type_enrichment_data` (form-visible defaults), and the
# user-saved values — each layer overriding the previous on key collision.
def enrichment_data
@enrichment_data ||= base_type_enrichment_data.stringify_keys
.merge(default_type_enrichment_data.stringify_keys)
.merge(@user_type_enrichment_data)
end
# Runtime-only base values (e.g. current `Setting.get(...)` / `Locale.default`).
# Part of `enrichment_data` so prompt templates can reference them, but
# not exposed to the frontend edit dialog.
def base_type_enrichment_data
{}
end
# Form-visible defaults for `type_enrichment_data::*` fields. Merged into
# `enrichment_data` as a prompt-render fallback and surfaced to the edit
# dialog via `AI::Agent#attributes_with_association_ids`.
def default_type_enrichment_data
{}
end
def data
@ -42,6 +72,9 @@ class AI::Agent::Type
false
end
# Placeholder field names are only used to define object attribute selection mappings.
# The key needs to be known, because this has also some kind of pre-replacement, before the real renderer runs.
# There is no need to add other keys which are saved below the enrichment_data.
def placeholder_field_names
[]
end
@ -70,8 +103,8 @@ class AI::Agent::Type
raise 'not implemented'
end
def execution_definition
transform_structure(definition)
def execution_definition(context: {})
transform_structure(definition, context:)
end
def execution_action_definition
@ -82,12 +115,31 @@ class AI::Agent::Type
{ skip_blank_values: true }
end
def transform_structure(structure)
# Convert hash to JSON string manually to avoid escaping ERB tags
structure_json = structure.to_json.gsub('\\u003c%', '<%').gsub('%\\u003e', '%>')
def precondition_checks(ticket:)
[]
end
replaced_structure = replace_placeholders(structure_json)
transformed_structure = render_structure(replaced_structure)
# Override in subclasses to contribute values computed from the current run
# context (e.g. `context[:ticket]`). Called with `context: {}` when only a
# static render is needed (tests, result-structure inspection), so
# implementations must handle missing keys gracefully.
def runtime_type_enrichment_data(context:)
{}
end
def transform_structure(structure, context: {})
# Convert hash to JSON string manually to avoid escaping ERB tags.
# The final `-%>\n` gsub compensates for the fact that `to_json` has already
# turned real newlines into the two-character escape sequence `\n`, which
# ERB's `trim_mode: '-'` cannot trim — see the note in #render_structure.
structure_json = structure.to_json
.gsub('\\u003c%', '<%')
.gsub('%\\u003e', '%>')
.gsub(%r{-%>\\n}, '-%>')
sanitized_structure = sanitize_instruction_template(structure_json)
replaced_structure = replace_placeholders(sanitized_structure)
transformed_structure = render_structure(replaced_structure, context:)
JSON.parse(transformed_structure)
end
@ -121,50 +173,68 @@ class AI::Agent::Type
raise 'not implemented'
end
# Runs before `render_structure` to make `trusted: true` safe. See ErbSanitizer
# for the full whitelist grammar and security rationale.
# `runtime_type_enrichment_data(context: {})` is invoked to collect the *names*
# of runtime values so they pass the sanitizer's allow-list; the empty
# context yields defaults and doesn't need any entity to be present.
def sanitize_instruction_template(template_string)
ErbSanitizer.sanitize(
template_string,
object_key: :type_enrichment_data,
allowed_names: enrichment_data.keys + runtime_type_enrichment_data(context: {}).keys,
)
end
def replace_placeholders(structure_string)
return structure_string if enrichment_data.blank?
# Replace each placeholder that's defined in placeholder_names in early stage.
# Placeholder values are always object attribute names (see placeholder_field_names), so they are
# safe to insert directly into the JSON and ERB template without further escaping.
placeholder_field_names.each do |placeholder_name|
placeholder_pattern = "\#{placeholder.#{placeholder_name}}"
replacement_value = enrichment_data[placeholder_name] || ''
replacement_value = enrichment_data[placeholder_name].to_s
# Placeholder values might contain newlines, which need to be preserved in the final rendered structure.
# However, the newlines may contain carriage return characters, so we simplify them to just `\n`.
sanitized_value = replacement_value.to_s.gsub(%r{(\r\n|\n\r|\r|\n)}, "\n")
# Additionally, we need to escape any double quotes for a similar reason. If not handled properly, these
# characters can break the JSON structure. But this gets tricky since they might already be prefixed by any
# number of backslashes, which are considered escape sequences in Ruby interpolation context.
# Therefore, we use a more generic escaping approach via a String helper.
sanitized_value = sanitized_value.json_escape
# Lastly, we need to escape any ERB-style tags.
sanitized_value = sanitized_value.gsub('<%', '<%%')
# Use the block form of `gsub` to ensure that all existing backslash sequences are preserved.
structure_string = structure_string.gsub(placeholder_pattern) { sanitized_value }
structure_string = structure_string.gsub(placeholder_pattern) { replacement_value }
end
structure_string
end
def render_structure(structure)
def render_structure(structure, context: {})
# `trusted: true` is only safe because #sanitize_instruction_template has already
# escaped every ERB tag outside the whitelist. Do not flip this flag without
# reviewing the sanitizer.
# `trim_mode: '-'` enables `<%- %>` / `<% -%>` markers so instruction templates
# can drop surrounding newlines without bloating the prompt with blank lines.
NotificationFactory::Renderer.new(
objects: { type_enrichment_data: enrichment_data_object },
objects: { type_enrichment_data: enrichment_data_object(context:) },
template: structure,
escape: false,
url_encode: false,
ignore_missing_objects: true,
trusted: false,
trusted: true,
trim_mode: '-',
).render(debug_errors: false)
end
def enrichment_data_object
@enrichment_data_object ||= if enrichment_data.blank?
nil
else
Struct.new(*enrichment_data.keys.map(&:to_sym)).new(*enrichment_data)
end
def enrichment_data_object(context: {})
combined_data = enrichment_data.merge(runtime_type_enrichment_data(context:).stringify_keys)
return nil if combined_data.blank?
sanitized_values = combined_data.values.map { |value| sanitize_template_value(value) }
Struct.new(*combined_data.keys.map(&:to_sym)).new(*sanitized_values)
end
# Values rendered via `#{type_enrichment_data.<name>}` are inserted verbatim into a JSON template and
# then through ERB. Free-text values can contain newlines, quotes, or `<%` sequences, which would
# break the final JSON.parse or inject ERB. Normalize line endings and escape accordingly.
def sanitize_template_value(value)
return value if !value.is_a?(String)
value
.gsub(%r{\r\n|\n\r|\r|\n}, "\n")
.json_escape
.gsub('<%', '<%%')
end
end

View file

@ -0,0 +1,232 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class AI::Agent::Type::TicketTagger < AI::Agent::Type
def base_type_enrichment_data
super.merge(
'tag_new' => Setting.get('tag_new'),
'locale' => Locale.default,
)
end
# Single source of truth for form-visible defaults. These seed the edit form
# via `form_enrichment_data` and also act as prompt-render fallbacks when
# the user hasn't saved an override. `form_schema` fields reference these
# via `enrichment_data['…']` so defaults are not duplicated.
def default_type_enrichment_data
super.merge(
'tagging_principles' => "- Tags represent the main topics and relevant aspects of the content.
- Focus on what the content is primarily about, not on background context or unrelated details.
- Prefer tags that clearly describe the topic in a consistent and reusable way.",
'priority_tagging_rules' => "- Use the most relevant and specific tags that describe the current ticket situation.
- Do not return broader tags when a more specific tag already describes the situation.
- Prefer fewer, high-quality tags over many weak or generic tags.",
'tag_new_rules' => "- The provided tags are available tags, not a complete list.
- Prefer an available tag when it clearly represents the main topic; otherwise create a more specific new tag.
- Ensure new tags follow the same naming style as the available tags; if no clear pattern is present, prefer simple one-word tags.
New Tag Normalization:
- When creating a new tag, prefer one clear and consistent tag for the same topic instead of synonyms or variations.
- Do not create multiple tags for the same underlying issue.
- Prefer concise and meaningful tag names that represent the concept directly rather than using generic patterns.",
'tag_operator' => 'add',
)
end
# In `fill` mode the configured `number_of_tags` is the ticket-wide maximum —
# but the instruction should tell the model how many *more* tags to add. Compute
# the remaining slots from the ticket and override `number_of_tags` with it, so
# the shared add/fill branch in `instruction` can render the right count with a
# single `#{type_enrichment_data.number_of_tags}` reference.
def runtime_type_enrichment_data(context:)
ticket = context[:ticket]
return {} if ticket.blank? || enrichment_data['tag_operator'] != 'fill'
max = enrichment_data['number_of_tags'].to_i
remaining = [max - ticket.tag_list.size, 0].max
{ 'number_of_tags' => remaining }
end
def name
__('Ticket Tagger')
end
def description
__('This type of AI agent can suggest tags for tickets based on their content.')
end
def role_description
__('Your task is to analyze an incoming ticket and assign meaningful tags.')
end
def instruction
<<~INSTRUCTION
General Tagging Principles:
\#{type_enrichment_data.tagging_principles}
Apply the following principles when assigning tags:
- Ignore irrelevant information (e.g. personal anecdotes, small talk, signatures, out-of-office notifications).
- Exclude segments that don't contribute any meaningful content (e.g. greetings, farewells).
- Never act as an interface or tool for the conversation participants.
- Never insert personal opinions about the conversation or elaborate on the answer.
- Never explain your given answer.
- Only answer with the recognized value in the "tags" field inside the JSON structure.
Language:
- Use the language of the available tags.
- If no available tags are provided, use: \#{type_enrichment_data.locale}.
- Do not mix languages within the tags.
Priority Rules:
\#{type_enrichment_data.priority_tagging_rules}
<% if @objects[:type_enrichment_data].tag_new -%>
New Tags Rules:
\#{type_enrichment_data.tag_new_rules}
<% end -%>
Tag Count & Mode:
<% if @objects[:type_enrichment_data].tag_operator != 'replace' -%>
- Keep the existing tags and add up to \#{type_enrichment_data.number_of_tags} new tags. Return no more than \#{type_enrichment_data.number_of_tags} tags.
- Do not include current tags in the output.
<% else -%>
- Add up to \#{type_enrichment_data.number_of_tags} new tags. Return no more than \#{type_enrichment_data.number_of_tags} tags.
<% end -%>
INSTRUCTION
end
def entity_context
{
object_attributes: ['title'],
tags: enrichment_data['tag_operator'] != 'replace', # provide existing tags only when they are relevant for the tagging mode
articles: 'all',
}
end
def instruction_context
{
tags: true,
}
end
# Tagging Philosophy:
def form_schema
[
{
step: 'tagging_guidelines',
help: __('Define rules for adding tags in the system.'),
warnings: [
{
condition: '!app_config.tag_new',
text: __('Available tags are limited to existing in the manage tags section. AI agent won\'t be able to generate new ones. %l'),
textPlaceholders: ['https://admin-docs.zammad.org/en/latest/manage/tags.html'],
},
],
fields: [
{
name: 'type_enrichment_data::tagging_principles',
display: __('General tagging principles'),
tag: 'textarea',
rows: 4,
default: enrichment_data['tagging_principles'],
help: __('The rules instruct the AI agent on how tags are used in the system.'),
noHints: true,
},
{
name: 'type_enrichment_data::priority_tagging_rules',
display: __('Priority tagging rules'),
tag: 'textarea',
rows: 4,
default: enrichment_data['priority_tagging_rules'],
help: __('The priority rules instruct the AI agent on how to add tags for incoming tickets.'),
noHints: true,
},
{
condition: 'app_config.tag_new',
name: 'type_enrichment_data::tag_new_rules',
display: __('New tags strategy'),
tag: 'textarea',
rows: 4,
default: enrichment_data['tag_new_rules'],
help: __('The rules instruct the AI agent on when to generate new tags and when to prefer tags from existing tags list.'),
noHints: true,
},
],
},
{
step: 'tagging_mode',
help: __('Configure the way required number of resulting tags are added or replaced in the ticket tags list.'),
fields: [
{
name: 'type_enrichment_data::tag_operator',
display: __('Tag assignment mode'),
tag: 'select',
default: enrichment_data['tag_operator'],
options: [
{ value: 'add', name: __('Add to existing tags') },
{ value: 'fill', name: __('Total shouldn\'t exceed') },
{ value: 'replace', name: __('Replace existing tags with') },
],
help: __('Choose whether the suggested tags are added to or replace the existing tags on the ticket.'),
noHints: true,
item_class: 'formGroup--halfSize',
},
{
name: 'type_enrichment_data::number_of_tags',
display: __('Number of tags'),
tag: 'integer',
min: 1,
max: 20,
help: __('Number of tags the AI agent may assign.'),
noHints: true,
item_class: 'formGroup--halfSize',
},
],
},
]
end
def result_structure
{
tags: ['string'],
}
end
def action_definition
{
mapping: {
'ticket.tags' => {
'operator' => action_tags_operator_mapping[enrichment_data['tag_operator']],
'value' => '#{ai_agent_result.tags}', # rubocop:disable Lint/InterpolationCheck
},
},
}
end
def precondition_checks(ticket:)
[
PreconditionCheck.new(
name: :max_number_of_tags_not_reached,
condition: -> { max_number_of_tags_not_reached?(ticket) },
),
]
end
private
def action_tags_operator_mapping
{
'add' => 'add',
'fill' => 'add',
'replace' => 'replace',
}
end
def max_number_of_tags_not_reached?(ticket)
operator = enrichment_data['tag_operator']
return true if operator != 'fill'
ticket.tag_list.size < enrichment_data['number_of_tags'].to_i
end
end

View file

@ -18,9 +18,6 @@ class AI::Agent::Type::TicketTextExtractor < AI::Agent::Type
def placeholder_field_names
%w[
extracted_text
extraction_rules
priority_rules
articles
]
end
@ -158,9 +155,9 @@ Always return only one match."
end
def instruction
"\#{placeholder.extraction_rules}
"\#{type_enrichment_data.extraction_rules}
\#{placeholder.priority_rules}
\#{type_enrichment_data.priority_rules}
Apply the following principles when extracting text:
@ -175,7 +172,7 @@ Apply the following principles when extracting text:
def entity_context
{
object_attributes: ['title'],
articles: "\#{placeholder.articles}",
articles: "\#{type_enrichment_data.articles}",
}
end

View file

@ -54,12 +54,13 @@ update tags of model
=end
def tag_update(items, current_user_id = nil)
def tag_update(items, current_user_id = nil, sourceable: nil)
Tag.tag_update(
object: self.class.to_s,
o_id: id,
items: items,
created_by_id: current_user_id,
sourceable: sourceable,
)
end

View file

@ -71,23 +71,44 @@ class PerformChanges::Action::AttributeUpdates < PerformChanges::Action
def tags(value)
return if record.class.included_modules.exclude?(HasTags)
tags = value['value'].split(',')
tags = normalized_tags(value['value'])
return if tags.blank?
operator = tags_operator(value)
return if operator.blank?
tags.each do |tag|
record.send(:"tag_#{operator}", tag, user_id || 1, sourceable: performable)
case operator
when 'replace'
record.tag_update(tags, user_id || 1, sourceable: performable)
when 'add', 'remove'
tags.each do |tag|
record.send(:"tag_#{operator}", tag, user_id || 1, sourceable: performable)
end
end
nil
end
def normalized_tags(raw_value)
tags = case raw_value
when Array
raw_value
when String
raw_value.split(',')
else
[]
end
tags
.map { |tag| tag.to_s.strip }
.compact_blank
.uniq
end
def tags_operator(value)
operator = value['operator']
if %w[add remove].exclude?(operator)
if %w[add remove replace].exclude?(operator)
Rails.logger.error "Unknown tags operator #{value['operator']}"
return
end

View file

@ -143,7 +143,7 @@ update tags for certain object
=end
def self.tag_update(object:, o_id:, items:, created_by_id: nil)
def self.tag_update(object:, o_id:, items:, created_by_id: nil, sourceable: nil)
given_tags = items.map(&:strip)
old_tags = tag_list(object: object, o_id: o_id)
@ -160,6 +160,7 @@ update tags for certain object
tag_item_id: tag_item_id,
o_id: o_id,
created_by_id: created_by_id,
sourceable:,
)
end
@ -171,8 +172,10 @@ update tags for certain object
tag_object_id: tag_object_id,
tag_item_id: tag_item_ids,
o_id: o_id,
)
.destroy_all
).each do |item|
item.sourceable = sourceable
item.destroy
end
end
# touch reference

View file

@ -5,15 +5,17 @@ class Service::AI::Agent::Run < Service::Base
def initialize(ai_agent:, ticket:, article: nil)
@ai_agent = ai_agent
@agent_definition = ai_agent.execution_definition
@action_definition = ai_agent.execution_action_definition
@ticket = ticket
@article = article
@agent_definition = ai_agent.execution_definition(context: { ticket:, article: })
@action_definition = ai_agent.execution_action_definition
end
def execute
Service::CheckFeatureEnabled.execute(name: 'ai_provider', custom_error_message: __('AI provider is not configured.'))
return if execution_blocked_by_preconditions?
ai_agent_result = fetch_ai_agent_result
ai_agent_perform_template = Service::AI::Agent::Run::Perform::Agent.new(ai_agent:, ai_result: ai_agent_result) # rubocop:disable Zammad/ForbidCallingServiceDirectly
@ -28,6 +30,11 @@ class Service::AI::Agent::Run < Service::Base
private
def execution_blocked_by_preconditions?
checks = ai_agent.agent_type_object&.precondition_checks(ticket:) || []
checks.lazy.any? { |check| !check.passed? }
end
def fetch_ai_agent_result
ai_agent_service_result
rescue AI::Provider::OutputFormatError => e

View file

@ -19,7 +19,8 @@ class Service::AI::Agent::Run::Context
type_enrichment_data:,
)
instruction.prepare
prepared = instruction.prepare
inject_existing_tags(prepared)
end
def prepare_entity
@ -31,4 +32,14 @@ class Service::AI::Agent::Run::Context
entity.prepare
end
private
def inject_existing_tags(prepared)
return prepared if instruction_context.blank?
return prepared if !instruction_context.stringify_keys.key?('existing_tags')
prepared[:existing_tags] = entity_object.tag_list.join(', ')
prepared
end
end

View file

@ -1,13 +1,14 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class Service::AI::Agent::Run::Context::Entity
attr_reader :entity_object, :entity_object_attributes, :entity_articles, :entity_article
attr_reader :entity_object, :entity_object_attributes, :entity_articles, :entity_article, :entity_tags
def initialize(entity_object:, entity_context: {}, entity_article: nil)
@entity_object = entity_object
@entity_object_attributes = entity_context['object_attributes'] || ['title']
@entity_articles = entity_context['articles'] || 'all'
@entity_article = entity_article
@entity_tags = entity_context['tags'] || false
end
def prepare
@ -21,20 +22,22 @@ class Service::AI::Agent::Run::Context::Entity
result[:articles] = prepare_entity_articles
end
if entity_tags
result[:tags] = prepare_entity_tags
end
result
end
private
def prepare_entity_object_attributes
prepared_object_attributes = {}
entity_object_attributes.each do |name|
entity_object_attributes.each_with_object({}) do |name, memo|
object_attribute = get_object_attribute(name)
next if object_attribute.blank?
# Get the raw value from the entity object
raw_value = @entity_object.send(name.to_sym)
raw_value = entity_object.send(name.to_sym)
next if raw_value.blank?
# Determine the appropriate class to handle this attribute type
@ -46,10 +49,8 @@ class Service::AI::Agent::Run::Context::Entity
entity_value: raw_value,
).prepare
prepared_object_attributes[name] = prepared_item if prepared_item.present?
memo[name] = prepared_item if prepared_item.present?
end
prepared_object_attributes
end
def prepare_entity_articles
@ -66,6 +67,10 @@ class Service::AI::Agent::Run::Context::Entity
end
end
def prepare_entity_tags
entity_object.tag_list
end
def skip_quote_removal?(article)
return true if entity_articles == 'first'
return false if entity_articles == 'last'
@ -82,7 +87,7 @@ class Service::AI::Agent::Run::Context::Entity
end
def last_articles
Array(@entity_article.presence || all_articles.last).compact
Array(entity_article.presence || all_articles.last).compact
end
def first_article
@ -90,7 +95,7 @@ class Service::AI::Agent::Run::Context::Entity
end
def all_articles
@all_articles ||= @entity_object.articles.without_system_notifications
@all_articles ||= entity_object.articles.without_system_notifications
end
def process_article_body(article, skip_quote_removal: false)

View file

@ -1,10 +1,11 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class Service::AI::Agent::Run::Context::Instruction
attr_reader :instruction_object_attributes, :placeholder_object_attributes, :type_enrichment_data
attr_reader :instruction_object_attributes, :instruction_tags, :placeholder_object_attributes, :type_enrichment_data
def initialize(instruction_context:, placeholder_object_attributes: [], type_enrichment_data: {})
@instruction_object_attributes = instruction_context['object_attributes']
@instruction_tags = instruction_context['tags'] || false
@placeholder_object_attributes = placeholder_object_attributes
@type_enrichment_data = type_enrichment_data
end
@ -16,6 +17,10 @@ class Service::AI::Agent::Run::Context::Instruction
result[:object_attributes] = prepare_instruction_object_attributes
end
if instruction_tags
result[:tags] = prepare_instruction_tags
end
result
end
@ -47,6 +52,10 @@ class Service::AI::Agent::Run::Context::Instruction
prepared_object_attributes
end
def prepare_instruction_tags
Tag::Item.pluck(:name)
end
def get_object_attribute(name)
ObjectManager::Attribute.get(
object: 'Ticket',

View file

@ -710,7 +710,7 @@ msgstr ""
msgid "AI agent"
msgstr ""
#: app/services/service/ai/agent/run.rb:22
#: app/services/service/ai/agent/run.rb:24
msgid "AI agent result content does not match expected result structure."
msgstr ""
@ -1346,6 +1346,10 @@ msgstr ""
msgid "Add this class to a button on your page that should open the chat."
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:164
msgid "Add to existing tags"
msgstr ""
#: app/assets/javascripts/app/lib/mixins/richtext_bubble_menu.coffee:158
msgid "Add unordered list"
msgstr ""
@ -1539,7 +1543,7 @@ msgstr ""
msgid "All affected users and their customer tickets will be scheduled for deletion when this job is run. Once the data privacy task is executed, users and tickets will be deleted and a history entry preserved. There is no rollback of this deletion possible."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:135
#: app/models/ai/agent/type/ticket_text_extractor.rb:132
msgid "All articles"
msgstr ""
@ -1576,7 +1580,7 @@ msgstr ""
msgid "All two-factor authentication methods were removed for this user."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:65
#: app/models/ai/agent/type/ticket_text_extractor.rb:62
msgid "All values will be considered for extracted text from tickets."
msgstr ""
@ -2047,7 +2051,7 @@ msgstr ""
msgid "Article#"
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:128
#: app/models/ai/agent/type/ticket_text_extractor.rb:125
msgid "Article(s) to analyze"
msgstr ""
@ -2389,7 +2393,7 @@ msgstr ""
msgid "Available Priorities"
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:66
#: app/models/ai/agent/type/ticket_text_extractor.rb:63
msgid "Available Values"
msgstr ""
@ -2403,6 +2407,10 @@ msgstr ""
msgid "Available methods"
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:119
msgid "Available tags are limited to existing in the manage tags section. AI agent won't be able to generate new ones. %l"
msgstr ""
#: db/seeds/settings.rb:888
msgid "Available types for a new ticket"
msgstr ""
@ -2473,7 +2481,7 @@ msgstr ""
msgid "BETA UI illustration"
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ai_agent.coffee:376
#: app/assets/javascripts/app/controllers/_ai/ai_agent.coffee:397
#: app/assets/javascripts/app/views/generic/object_search/item_organization_members.jst.eco:5
#: app/frontend/apps/desktop/components/CommonSelect/CommonSelect.vue:399
#: app/frontend/apps/desktop/components/Form/fields/FieldTreeSelect/FieldTreeSelectInputDropdown.vue:419
@ -2881,7 +2889,7 @@ msgstr ""
msgid "Cancel"
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ai_agent.coffee:359
#: app/assets/javascripts/app/controllers/_ai/ai_agent.coffee:380
#: app/assets/javascripts/app/controllers/_application_controller/_modal.coffee:119
#: app/assets/javascripts/app/views/admin_password_auth/request.jst.eco:8
#: app/assets/javascripts/app/views/admin_password_auth/request_sent.jst.eco:7
@ -3269,11 +3277,15 @@ msgstr ""
msgid "Choose the group to which page posts will get added."
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:168
msgid "Choose whether the suggested tags are added to or replace the existing tags on the ticket."
msgstr ""
#: app/models/ai/agent/type/ticket_categorizer.rb:43
msgid "Choose which attribute values will be considered when categorizing tickets. If you want to limit it to specific options, please select at least two below. Make sure the options have clear names and optional descriptions, as that would comprise the context provided to the AI agent."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:57
#: app/models/ai/agent/type/ticket_text_extractor.rb:54
msgid "Choose which attribute values will be considered when extracting text from tickets. If you want to limit it to specific options, please select at least two below. Make sure the options have clear names and optional descriptions, as that would comprise the context provided to the AI agent."
msgstr ""
@ -3660,6 +3672,10 @@ msgstr ""
msgid "Configure Basic Settings"
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:156
msgid "Configure the way required number of resulting tags are added or replaced in the ticket tags list."
msgstr ""
#: db/seeds/permissions.rb:6
msgid "Configure your system."
msgstr ""
@ -4728,11 +4744,15 @@ msgstr ""
msgid "Define queues or call destinations (whatever fits your PBX) and map your agents to it. By this, Zammad can support your agents by showing them only relevant call entries and notifications."
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:115
msgid "Define rules for adding tags in the system."
msgstr ""
#: db/seeds/settings.rb:2616
msgid "Define the default agent notifications for new users."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:76
#: app/models/ai/agent/type/ticket_text_extractor.rb:73
msgid "Define the extraction rules for the AI agent, e.g. how to identify target text. For examples, please refer to our documentation."
msgstr ""
@ -4740,11 +4760,11 @@ msgstr ""
msgid "Define the maximum number of ticket shown in overviews."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:104
#: app/models/ai/agent/type/ticket_text_extractor.rb:101
msgid "Define the prioritization rules for the AI agent, e.g. how to choose between multiple occurrences of the target text. For examples, please refer to our documentation."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:124
#: app/models/ai/agent/type/ticket_text_extractor.rb:121
msgid "Define which ticket article(s) should be analyzed."
msgstr ""
@ -6758,7 +6778,7 @@ msgstr ""
msgid "Enter the code from your two-factor authenticator app."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:33
#: app/models/ai/agent/type/ticket_text_extractor.rb:30
msgid "Enter the extraction rules for the AI agent."
msgstr ""
@ -6774,7 +6794,7 @@ msgstr ""
msgid "Enter the link provided by the plugin at the end of the installation to link the two systems."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:111
#: app/models/ai/agent/type/ticket_text_extractor.rb:108
msgid "Enter the priority rules for the AI agent."
msgstr ""
@ -7109,7 +7129,7 @@ msgstr ""
msgid "Extract zammad-attachment information from arrays"
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:30
#: app/models/ai/agent/type/ticket_text_extractor.rb:27
msgid "Extraction rules"
msgstr ""
@ -7371,7 +7391,7 @@ msgstr ""
msgid "First Steps"
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:133
#: app/models/ai/agent/type/ticket_text_extractor.rb:130
msgid "First article (oldest)"
msgstr ""
@ -7678,6 +7698,10 @@ msgstr ""
msgid "General communication error, maybe internet is not available!"
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:126
msgid "General tagging principles"
msgstr ""
#: app/assets/javascripts/app/controllers/api.coffee:180
msgid "Generate Access Token for |%s|"
msgstr ""
@ -8669,7 +8693,7 @@ msgstr ""
msgid "In order to be able to influence the desired behavior in this regard, you can influence the order of the LDAP sources via drag & drop."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:131
#: app/models/ai/agent/type/ticket_text_extractor.rb:128
msgid "In the trigger context, the last article will always be the one that activated the trigger."
msgstr ""
@ -9465,7 +9489,7 @@ msgstr ""
msgid "Last Internal Article"
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:134
#: app/models/ai/agent/type/ticket_text_extractor.rb:131
msgid "Last article (newest)"
msgstr ""
@ -9622,7 +9646,7 @@ msgstr ""
msgid "Limit tickets per day"
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:64
#: app/models/ai/agent/type/ticket_text_extractor.rb:61
msgid "Limit values and provide optional descriptions"
msgstr ""
@ -11148,6 +11172,10 @@ msgstr ""
msgid "New tab"
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:145
msgid "New tags strategy"
msgstr ""
#: app/assets/javascripts/app/controllers/text_module.coffee:35
msgid "New text module"
msgstr ""
@ -11511,7 +11539,7 @@ msgid "No source data submitted!"
msgstr ""
#: app/models/ai/agent/type/ticket_categorizer.rb:28
#: app/models/ai/agent/type/ticket_text_extractor.rb:41
#: app/models/ai/agent/type/ticket_text_extractor.rb:38
msgid "No suitable object attributes found, please add one of the supported types first: %s"
msgstr ""
@ -11795,6 +11823,14 @@ msgstr ""
msgid "Number"
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:174
msgid "Number of tags"
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:178
msgid "Number of tags the AI agent may assign."
msgstr ""
#: app/assets/javascripts/app/views/integration/exchange.jst.eco:12
msgid "OAuth"
msgstr ""
@ -12834,7 +12870,7 @@ msgstr ""
msgid "Please enter a text."
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ai_agent.coffee:447
#: app/assets/javascripts/app/controllers/_ai/ai_agent.coffee:468
#: app/assets/javascripts/app/controllers/webhook.coffee:134
msgid "Please enter a valid JSON string."
msgstr ""
@ -13117,10 +13153,14 @@ msgstr ""
msgid "Priority icon"
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:108
#: app/models/ai/agent/type/ticket_text_extractor.rb:105
msgid "Priority rules"
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:135
msgid "Priority tagging rules"
msgstr ""
#: db/seeds/settings.rb:1979
msgid "Private key (PEM)"
msgstr ""
@ -13708,6 +13748,10 @@ msgstr ""
msgid "Replace"
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:166
msgid "Replace existing tags with"
msgstr ""
#: app/assets/javascripts/app/views/profile/out_of_office.jst.eco:26
msgid "Replacement"
msgstr ""
@ -14730,7 +14774,7 @@ msgid "Select table row"
msgstr ""
#: app/models/ai/agent/type/ticket_categorizer.rb:34
#: app/models/ai/agent/type/ticket_text_extractor.rb:47
#: app/models/ai/agent/type/ticket_text_extractor.rb:44
msgid "Select the attribute whose value will be automatically set by the AI agent."
msgstr ""
@ -14738,7 +14782,7 @@ msgstr ""
msgid "Select the customer of the ticket or create one."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:130
#: app/models/ai/agent/type/ticket_text_extractor.rb:127
msgid "Select the ticket article(s) that should be analyzed by the AI agent."
msgstr ""
@ -15855,7 +15899,7 @@ msgstr ""
msgid "Subject & References - Additional search for same article subject and same message ID in references header if no follow-up was recognized using default settings."
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ai_agent.coffee:385
#: app/assets/javascripts/app/controllers/_ai/ai_agent.coffee:406
#: app/assets/javascripts/app/controllers/_application_controller/form.coffee:2
#: app/assets/javascripts/app/controllers/tag.coffee:117
#: app/assets/javascripts/app/controllers/widget/button_with_dropdown.coffee:12
@ -16076,6 +16120,10 @@ msgstr ""
msgid "Tag \"%s\" already exists."
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:160
msgid "Tag assignment mode"
msgstr ""
#: app/assets/javascripts/app/controllers/chat.coffee:560
#: app/assets/javascripts/app/controllers/tag.coffee:3
#: app/assets/javascripts/app/views/knowledge_base/_reader_tags.jst.eco:5
@ -16106,7 +16154,7 @@ msgstr ""
msgid "Target"
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:45
#: app/models/ai/agent/type/ticket_text_extractor.rb:42
msgid "Target Attribute for Extracted Text"
msgstr ""
@ -16883,7 +16931,7 @@ msgstr ""
msgid "The new ticket number could not be generated."
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ai_agent.coffee:528
#: app/assets/javascripts/app/controllers/_ai/ai_agent.coffee:549
#: app/assets/javascripts/app/controllers/_application_controller/_modal_generic_new.coffee:69
#: app/assets/javascripts/app/controllers/_channel/_email_signature.coffee:93
#: app/assets/javascripts/app/controllers/agent_ticket_create.coffee:820
@ -16954,6 +17002,10 @@ msgstr ""
msgid "The preview cannot be generated. The format is corrupted or not supported."
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:139
msgid "The priority rules instruct the AI agent on how to add tags for incoming tickets."
msgstr ""
#: lib/secure_mailing/pgp/incoming.rb:302
msgid "The private PGP key could not be found."
msgstr ""
@ -17210,7 +17262,7 @@ msgstr ""
msgid "The required parameter 'ticket_number' is missing."
msgstr ""
#: app/models/concerns/perform_changes/action/attribute_updates.rb:127
#: app/models/concerns/perform_changes/action/attribute_updates.rb:148
msgid "The required parameter 'user_id' is missing."
msgstr ""
@ -17236,14 +17288,22 @@ msgstr ""
msgid "The retried security process failed!"
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:34
#: app/models/ai/agent/type/ticket_tagger.rb:130
msgid "The rules instruct the AI agent on how tags are used in the system."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:31
msgid "The rules instruct the AI agent on how to extract text from incoming tickets. Make sure to include a couple of example formats, and any additional instructions necessary."
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:112
#: app/models/ai/agent/type/ticket_text_extractor.rb:109
msgid "The rules instruct the AI agent on how to prioritize multiple matches of the extracted text. Make sure to include a couple of example formats, and any additional instructions necessary."
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:149
msgid "The rules instruct the AI agent on when to generate new tags and when to prefer tags from existing tags list."
msgstr ""
#: app/frontend/shared/components/Form/fields/FieldDate/useDateTime.ts:107
msgid "The seconds overlay"
msgstr ""
@ -18052,6 +18112,10 @@ msgstr ""
msgid "This type of AI agent can prioritize incoming tickets based on their content."
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:53
msgid "This type of AI agent can suggest tags for tickets based on their content."
msgstr ""
#: app/policies/ticket/shared_draft_start_policy.rb:26
msgid "This user does not have access to the given group"
msgstr ""
@ -18262,6 +18326,10 @@ msgstr ""
msgid "Ticket Summary provides the functionality to summarize the current ticket state. It will provide a new sidebar which contains information to reduce reading time in the ticket with a summarized version of the problem, open questions and upcoming events."
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:49
msgid "Ticket Tagger"
msgstr ""
#: app/models/ai/agent/type/ticket_text_extractor.rb:11
msgid "Ticket Text Extractor"
msgstr ""
@ -18817,6 +18885,10 @@ msgstr ""
msgid "Total display of the number of objects in a grouping."
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:165
msgid "Total shouldn't exceed"
msgstr ""
#: app/assets/javascripts/app/views/dashboard/stats/ticket_escalation.jst.eco:12
msgid "Total: %s"
msgstr ""
@ -20750,6 +20822,10 @@ msgstr ""
msgid "Your rating of the new BETA UI"
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:57
msgid "Your task is to analyze an incoming ticket and assign meaningful tags."
msgstr ""
#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingAppearance.vue:28
msgid "Your theme has been updated."
msgstr ""

View file

@ -3,7 +3,15 @@
<%= @context_data[:instruction] %>
<% if @context_data[:instruction_context].present? -%>
<% @context_data[:instruction_context][:object_attributes].each do |attribute_name, attribute_value| -%>
<% if @context_data[:instruction_context][:tags].present? -%>
The already available "Tags" are defined inside the XML format:
<tags>
<% @context_data[:instruction_context][:tags].each do |tag| -%>
<tag><%= tag %></tag>
<% end -%>
</tags>
<% end -%>
<% (@context_data[:instruction_context][:object_attributes] || {}).each do |attribute_name, attribute_value| -%>
The available options from "<%= attribute_value[:label] %>" are defined inside the XML format:
<<%= attribute_name %>>
<% attribute_value[:items].each do |option| -%>
@ -20,7 +28,7 @@ The available options from "<%= attribute_value[:label] %>" are defined inside t
<% end -%>
<% if json_response? -%>
Reply in the defined plain JSON structure only and do not wrap it in code block markers:
Reply with a plain JSON object only. Do not wrap the response in code fences, markdown, or any additional formatting. Do not include ```json or ``` in the output.
<%= JSON.pretty_generate(@context_data[:result_structure]) %>
<% end -%>

View file

@ -9,6 +9,13 @@
</<%= attribute_name %>>
<% end -%>
<% end -%>
<% if @context_data[:entity_context].present? && @context_data[:entity_context][:tags].present? -%>
<tags>
<% @context_data[:entity_context][:tags].each do |tag| -%>
<tag><%= tag %></tag>
<% end -%>
</tags>
<% end -%>
<% if @context_data[:entity_context].present? && @context_data[:entity_context][:articles].present? -%>
<% @context_data[:entity_context][:articles].each do |processed_article| -%>

90
lib/erb_sanitizer.rb Normal file
View file

@ -0,0 +1,90 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class ErbSanitizer
=begin
Whitelist-based ERB sanitizer. Used to make `trusted: true` rendering safe when a
template may contain admin-authored content. Passes through a fixed grammar:
<% if|elsif @objects[:<object_key>].<name>[.<check>] %>
<% if|elsif @objects[:<object_key>].<name> ==|!= <literal> %>
<% else %>
<% end %>
where `<name>` is one of `allowed_names`, `<check>` is one of RESERVED_CHECKS,
and `<literal>` is a single-quoted string without escapes (`'foo'`), an integer
or float (`5`, `-1.5`), or one of the keywords `true`, `false`, `nil`. The RHS
of a comparison is deliberately restricted to literals never a variable,
method call, or expression so no code can run from the template side.
Every other ERB tag has its `<%` escaped to `<%%` and renders as literal text.
safe = ErbSanitizer.sanitize(
template_string,
object_key: :type_enrichment_data,
allowed_names: %w[tag_new tagging_guidelines],
)
=end
RESERVED_CHECKS = %w[present? blank? empty? any? none? nil?].freeze
LITERAL_RHS = %r{'[^'\\]*'|-?\d+(?:\.\d+)?|true|false|nil}
def self.sanitize(template, object_key:, allowed_names:)
new(object_key: object_key, allowed_names: allowed_names).sanitize(template)
end
def initialize(object_key:, allowed_names:)
@object_key = object_key.to_s
@allowed_names = Array(allowed_names).to_set(&:to_s)
end
def sanitize(template)
depth = 0
template.gsub(ERB_TAG) do |tag|
case tag.strip
when condition_pattern
next escape_tag(tag) if @allowed_names.exclude?(Regexp.last_match[:name])
depth += 1 if Regexp.last_match[:keyword] == 'if'
tag
when END_TAG
next escape_tag(tag) if depth.zero?
depth -= 1
tag
when ELSE_TAG
depth.positive? ? tag : escape_tag(tag)
else
escape_tag(tag)
end
end
end
private
ERB_TAG = %r{<%(?!%).*?%>}m
ELSE_TAG = %r{\A<%-?\s*else\s*-?%>\z}
END_TAG = %r{\A<%-?\s*end\s*-?%>\z}
private_constant :ERB_TAG, :ELSE_TAG, :END_TAG
def condition_pattern
@condition_pattern ||= %r{
\A<%-?\s*
(?<keyword>if|elsif)\s+
@objects\[:#{Regexp.escape(@object_key)}\]\.(?<name>\w+)
(?:
\.(?<check>#{RESERVED_CHECKS.map { |c| Regexp.escape(c) }.join('|')})
|
\s*(?<op>==|!=)\s*(?<rhs>#{LITERAL_RHS.source})
)?
\s*-?%>\z
}x
end
def escape_tag(tag)
tag.sub('<%', '<%%')
end
end

View file

@ -34,7 +34,7 @@ examples how to use
=end
def initialize(objects:, template:, locale: nil, timezone: nil, escape: true, url_encode: false, trusted: false, ignore_missing_objects: false)
def initialize(objects:, template:, locale: nil, timezone: nil, escape: true, url_encode: false, trusted: false, ignore_missing_objects: false, trim_mode: nil)
@objects = objects
@locale = locale || Locale.default
@timezone = timezone || Setting.get('timezone_default')
@ -43,12 +43,13 @@ examples how to use
@url_encode = url_encode
@trusted = trusted
@ignore_missing_objects = ignore_missing_objects
@trim_mode = trim_mode
end
def render(debug_errors: true)
@debug_errors = debug_errors
template_str = @template.to_s
ERB.new(template_str).result(template_binding)
ERB.new(template_str, trim_mode: @trim_mode).result(template_binding)
rescue Exception => e # rubocop:disable Lint/RescueException
raise StandardError, e.message if e.is_a? SyntaxError

View file

@ -94,7 +94,7 @@ RSpec.describe AI::Service::AIAgent, :aggregate_failures do
# Check for the complete JSON response structure (pretty-printed JSON format)
expect(args[:prompt_system]).to include(<<~JSON.strip)
Reply in the defined plain JSON structure only and do not wrap it in code block markers:
Reply with a plain JSON object only. Do not wrap the response in code fences, markdown, or any additional formatting. Do not include ```json or ``` in the output.
{
"state_id": "integer",
@ -103,9 +103,9 @@ RSpec.describe AI::Service::AIAgent, :aggregate_failures do
JSON
# Check for entity context in the user prompt
expect(args[:prompt_user]).to include('<ticket>')
expect(args[:prompt_user]).to include(<<~XML.strip)
<ticket>
<title>
<title>
<value>Test Ticket</value>
</title>
<group_id>
@ -116,9 +116,8 @@ RSpec.describe AI::Service::AIAgent, :aggregate_failures do
<label>2 normal</label>
<value>#{priority.id}</value>
</priority_id>
</ticket>
XML
expect(args[:prompt_user]).to include('</ticket>')
end
expect(result.content).to include('state_id' => 1, 'priority_id' => 2)
@ -209,6 +208,142 @@ RSpec.describe AI::Service::AIAgent, :aggregate_failures do
end
end
context 'when entity_context has tags' do
let(:context_data) do
{
ai_agent: ai_agent,
ticket: ticket,
role_description: 'Test AI Agent',
instruction: 'Analyze the ticket and provide recommendations',
instruction_context: {
object_attributes: {},
tags: %w[urgent billing vip]
},
entity_context: {
object_attributes: {
'title' => {
value: 'Test Ticket'
}
},
tags: %w[urgent billing vip]
},
result_structure: {
'state_id' => 'integer'
}
}
end
it 'includes tags in the user prompt' do
ai_service.execute
expect(mock_provider).to have_received(:ask) do |args|
expect(args[:prompt_user]).to include(<<~XML.strip)
<tags>
<tag>urgent</tag>
<tag>billing</tag>
<tag>vip</tag>
</tags>
XML
end
end
it 'includes tags in the system prompt' do
ai_service.execute
expect(mock_provider).to have_received(:ask) do |args|
expect(args[:prompt_system]).to include('The already available "Tags" are defined inside the XML format:')
expect(args[:prompt_system]).to include(<<~XML.strip)
<tags>
<tag>urgent</tag>
<tag>billing</tag>
<tag>vip</tag>
</tags>
XML
end
end
end
context 'when only instruction_context has tags (e.g. tag replace mode)' do
let(:context_data) do
{
ai_agent: ai_agent,
ticket: ticket,
role_description: 'Test AI Agent',
instruction: 'Analyze the ticket and provide recommendations',
instruction_context: {
object_attributes: {},
tags: %w[urgent billing vip]
},
entity_context: {
object_attributes: {
'title' => {
value: 'Test Ticket'
}
}
},
result_structure: {
'state_id' => 'integer'
}
}
end
it 'still renders the available tags in the system prompt' do
ai_service.execute
expect(mock_provider).to have_received(:ask) do |args|
expect(args[:prompt_system]).to include('The already available "Tags" are defined inside the XML format:')
expect(args[:prompt_system]).to include(<<~XML.strip)
<tags>
<tag>urgent</tag>
<tag>billing</tag>
<tag>vip</tag>
</tags>
XML
expect(args[:prompt_user]).not_to include('<tags>')
end
end
end
context 'when entity_context has no tags' do
let(:context_data) do
{
ai_agent: ai_agent,
ticket: ticket,
role_description: 'Test AI Agent',
instruction: 'Analyze the ticket',
instruction_context: {
object_attributes: {}
},
entity_context: {
object_attributes: {
'title' => {
value: 'Test Ticket'
}
},
},
result_structure: {
'state_id' => 'integer'
}
}
end
it 'does not include tags in the user prompt' do
ai_service.execute
expect(mock_provider).to have_received(:ask) do |args|
expect(args[:prompt_user]).not_to include('<tags>')
end
end
it 'does not include tags in the system prompt' do
ai_service.execute
expect(mock_provider).to have_received(:ask) do |args|
expect(args[:prompt_system]).not_to include('<tags>')
end
end
end
context 'when entity_context has articles setting' do
let(:articles) { create_list(:ticket_article, 3, ticket: ticket) }

View file

@ -0,0 +1,315 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe ErbSanitizer, :aggregate_failures do
let(:object_key) { :type_enrichment_data }
let(:allowed_names) { %w[flag_on flag_off note] }
let(:object_values) { { 'flag_on' => true, 'flag_off' => false, 'note' => 'hello' } }
# Run sanitize + ERB end-to-end so assertions can match the final output exactly
# as a caller would see it. Any payload fully escaped by the sanitizer appears
# unchanged in the output (ERB converts `<%%` back to `<%` on render).
def render(template, names: allowed_names, values: object_values)
sanitized = described_class.sanitize(template, object_key: object_key, allowed_names: names)
struct = Struct.new(*values.keys.map(&:to_sym)).new(*values.values)
context = Object.new.tap { |o| o.instance_variable_set(:@objects, { object_key => struct }) }
ERB.new(sanitized).result(context.instance_eval { binding })
end
describe '.sanitize' do
context 'with an allowed condition referencing a known name' do
it 'keeps the body when the condition is truthy' do
expect(render('A<% if @objects[:type_enrichment_data].flag_on %>B<% end %>C')).to eq('ABC')
end
it 'drops the body when the condition is falsy' do
expect(render('A<% if @objects[:type_enrichment_data].flag_off %>B<% end %>C')).to eq('AC')
end
it 'handles else branches' do
expect(render('<% if @objects[:type_enrichment_data].flag_on %>X<% else %>Y<% end %>')).to eq('X')
expect(render('<% if @objects[:type_enrichment_data].flag_off %>X<% else %>Y<% end %>')).to eq('Y')
end
it 'handles nested conditions' do
expect(render('<% if @objects[:type_enrichment_data].flag_on %>a<% if @objects[:type_enrichment_data].flag_off %>b<% end %>c<% end %>')).to eq('ac')
end
end
context 'with a reserved predicate check' do
let(:object_values) { { 'note' => '' } }
it 'accepts .present?' do
expect(render('<% if @objects[:type_enrichment_data].note.present? %>X<% end %>')).to eq('')
end
it 'accepts .blank?' do
expect(render('<% if @objects[:type_enrichment_data].note.blank? %>X<% end %>')).to eq('X')
end
it 'accepts .empty?' do
expect(render('<% if @objects[:type_enrichment_data].note.empty? %>X<% end %>')).to eq('X')
end
end
context 'when the value is blank (plain Ruby truthiness)' do
let(:object_values) { { 'note' => '' } }
it 'is truthy (empty string is truthy in Ruby)' do
expect(render('<% if @objects[:type_enrichment_data].note %>X<% end %>')).to eq('X')
end
end
context 'when the name is not in allowed_names' do
it 'renders the tag and its matching end as literal text' do
payload = '<% if @objects[:type_enrichment_data].unknown %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with an unknown bareword (not even via @objects)' do
it 'renders as literal text' do
payload = '<% if unknown %>B<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with a method call in the condition expression' do
it 'renders as literal text' do
payload = '<% if Setting.get("tag_new") %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with arbitrary ERB code in the template' do
it 'renders as literal text' do
payload = "<% File.read('/etc/passwd') %>"
expect(render(payload)).to eq(payload)
end
end
context 'with an ERB output tag' do
it 'renders as literal text' do
payload = '<%= 1 + 1 %>'
expect(render(payload)).to eq(payload)
end
end
context 'with an ERB comment tag' do
it 'renders as literal text' do
payload = '<%# a comment %>'
expect(render(payload)).to eq(payload)
end
end
# The whitelist must not be bypassable. Each payload below looks superficially
# close to an allowed construct and must still render as its exact input text
# (all `<%` escaped, nothing executed).
context 'with a method chain after the whitelisted access path' do
it 'renders as literal text' do
payload = '<% if @objects[:type_enrichment_data].flag_on.tap { 1 / 0 } %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with access to a different @objects key' do
it 'renders as literal text' do
payload = '<% if @objects[:ticket] %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with hash-bracket access on the whitelisted object' do
it 'renders as literal text' do
payload = '<% if @objects[:type_enrichment_data][:flag_on] %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with a .send() method call' do
it 'renders as literal text' do
payload = '<% if @objects[:type_enrichment_data].send(:flag_on) %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with a comparison operator against a literal value' do
let(:object_values) { { 'mode' => 'add', 'count' => 3, 'flag_on' => true } }
let(:allowed_names) { %w[mode count flag_on] }
it 'accepts == against a single-quoted string' do
expect(render("<% if @objects[:type_enrichment_data].mode == 'add' %>X<% end %>")).to eq('X')
expect(render("<% if @objects[:type_enrichment_data].mode == 'fill' %>X<% end %>")).to eq('')
end
it 'accepts != against a single-quoted string' do
expect(render("<% if @objects[:type_enrichment_data].mode != 'fill' %>X<% end %>")).to eq('X')
end
it 'accepts == and != against integer literals' do
expect(render('<% if @objects[:type_enrichment_data].count == 3 %>X<% end %>')).to eq('X')
expect(render('<% if @objects[:type_enrichment_data].count != 3 %>X<% end %>')).to eq('')
end
it 'accepts == against the true/false/nil keywords' do
expect(render('<% if @objects[:type_enrichment_data].flag_on == true %>X<% end %>')).to eq('X')
expect(render('<% if @objects[:type_enrichment_data].flag_on == false %>X<% end %>')).to eq('')
expect(render('<% if @objects[:type_enrichment_data].flag_on == nil %>X<% end %>')).to eq('')
end
it 'routes the elsif chain correctly' do
template = "<% if @objects[:type_enrichment_data].mode == 'add' %>A<% elsif @objects[:type_enrichment_data].mode == 'fill' %>F<% else %>E<% end %>"
expect(render(template)).to eq('A')
end
end
context 'with a comparison operator against an executable RHS' do
it 'rejects a double-quoted string (interpolation bypass risk)' do
payload = '<% if @objects[:type_enrichment_data].flag_on == "add" %>X<% end %>'
expect(render(payload)).to eq(payload)
end
it 'rejects a method call on the RHS' do
payload = '<% if @objects[:type_enrichment_data].flag_on == Setting.get("x") %>X<% end %>'
expect(render(payload)).to eq(payload)
end
it 'rejects a variable reference on the RHS' do
payload = '<% if @objects[:type_enrichment_data].flag_on == @other %>X<% end %>'
expect(render(payload)).to eq(payload)
end
it 'rejects a compound RHS expression' do
payload = "<% if @objects[:type_enrichment_data].flag_on == 'a' + 'b' %>X<% end %>"
expect(render(payload)).to eq(payload)
end
end
context 'with a compound condition combining && and a method call' do
it 'renders as literal text' do
payload = '<% if @objects[:type_enrichment_data].flag_on && Setting.get("x") %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with the unless keyword' do
it 'renders as literal text' do
payload = '<% unless @objects[:type_enrichment_data].flag_on %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with a for loop' do
it 'renders as literal text' do
payload = '<% for i in [1, 2] %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with a predicate method not in the whitelist' do
it 'renders as literal text' do
payload = '<% if @objects[:type_enrichment_data].flag_on.to_proc %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with an assignment via the whitelisted path' do
it 'renders as literal text' do
payload = '<% @objects[:type_enrichment_data].flag_on = "pwned" %>'
expect(render(payload)).to eq(payload)
end
end
context 'with a string-keyed @objects access' do
it 'renders as literal text' do
payload = '<% if @objects["type_enrichment_data"].flag_on %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with an <%= output tag that looks allowed' do
it 'renders as literal text' do
payload = '<%= @objects[:type_enrichment_data].flag_on %>'
expect(render(payload)).to eq(payload)
end
end
end
describe 'parameterization' do
context 'with a different object_key' do
let(:object_key) { :my_context }
let(:object_values) { { 'flag_on' => true } }
let(:allowed_names) { %w[flag_on] }
it 'accepts the chosen key' do
expect(render('A<% if @objects[:my_context].flag_on %>B<% end %>C')).to eq('ABC')
end
it 'rejects the default key when a different one is configured' do
payload = '<% if @objects[:type_enrichment_data].flag_on %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
context 'with allowed_names passed as symbols' do
let(:allowed_names) { %i[flag_on] }
it 'treats them the same as strings' do
expect(render('A<% if @objects[:type_enrichment_data].flag_on %>B<% end %>C')).to eq('ABC')
end
end
context 'with allowed_names passed as a mix of symbols and strings' do
let(:allowed_names) { [:flag_on, 'flag_off'] }
it 'treats them the same' do
expect(render('A<% if @objects[:type_enrichment_data].flag_on %>B<% end %>C')).to eq('ABC')
expect(render('<% if @objects[:type_enrichment_data].flag_off %>X<% end %>')).to eq('')
end
end
context 'with empty allowed_names' do
let(:allowed_names) { [] }
it 'escapes every conditional' do
payload = '<% if @objects[:type_enrichment_data].flag_on %>X<% end %>'
expect(render(payload)).to eq(payload)
end
end
end
describe 'RESERVED_CHECKS' do
it 'lists the expected predicate methods' do
expect(described_class::RESERVED_CHECKS).to eq(%w[present? blank? empty? any? none? nil?])
end
it 'is frozen' do
expect(described_class::RESERVED_CHECKS).to be_frozen
end
end
end

View file

@ -0,0 +1,160 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe AI::Agent::Type::TicketTagger, :aggregate_failures, current_user_id: 1, type: :model do
describe '.execution_definition' do
let(:type_enrichment_data) { { 'tagging_principles' => 'Prefer short tags.' } }
let(:agent_type) { described_class.new(type_enrichment_data:) }
it 'renders the result structure and tagging principles' do
result = agent_type.execution_definition
expect(result).to be_a(Hash)
expect(result['result_structure']['tags']).to eq(['string'])
expect(result['instruction']).to include('Prefer short tags.')
end
end
describe 'tag_new conditional in instruction' do
let(:agent_type) { described_class.new(type_enrichment_data: {}) }
context 'when tag_new is enabled' do
before { Setting.set('tag_new', true) }
it 'includes the New Tags Rules section' do
expect(agent_type.execution_definition['instruction']).to include('New Tags Rules:')
end
end
context 'when tag_new is disabled' do
before { Setting.set('tag_new', false) }
it 'omits the New Tags Rules section but keeps the base sections' do
instruction = agent_type.execution_definition['instruction']
expect(instruction).not_to include('New Tags Rules:')
expect(instruction)
.to include('Apply the following principles when assigning tags:')
.and include('Language:')
.and include('Priority Rules:')
.and include('Tag Count & Mode:')
end
end
end
describe '.execution_action_definition' do
context 'when tag_operator is add' do
let(:agent_type) { described_class.new(type_enrichment_data: { 'tag_operator' => 'add' }) }
it 'uses add operator' do
result = agent_type.execution_action_definition
expect(result['mapping']['ticket.tags']).to include(
'operator' => 'add',
'value' => '#{ai_agent_result.tags}', # rubocop:disable Lint/InterpolationCheck
)
end
end
context 'when tag_operator is fill' do
let(:agent_type) { described_class.new(type_enrichment_data: { 'tag_operator' => 'fill' }) }
it 'maps fill to add operator' do
result = agent_type.execution_action_definition
expect(result['mapping']['ticket.tags']).to include('operator' => 'add')
end
end
context 'when tag_operator is replace' do
let(:agent_type) { described_class.new(type_enrichment_data: { 'tag_operator' => 'replace' }) }
it 'uses replace operator' do
result = agent_type.execution_action_definition
expect(result['mapping']['ticket.tags']).to include('operator' => 'replace')
end
end
end
describe 'number_of_tags rendering across tag_operator modes' do
let(:ticket) { create(:ticket) }
context 'when tag_operator is fill and the ticket already has tags' do
let(:agent_type) { described_class.new(type_enrichment_data: { 'tag_operator' => 'fill', 'number_of_tags' => 5 }) }
before { 2.times { |i| ticket.tag_add("tag-#{i}", 1) } }
it 'overrides number_of_tags with the remaining slot count' do
instruction = agent_type.execution_definition(context: { ticket: })['instruction']
expect(instruction).to include('Keep the existing tags and add up to 3 new tags. Return no more than 3 tags.')
expect(instruction).not_to include('Keep the existing tags and add up to 5 new tags.')
end
end
context 'when tag_operator is fill and no ticket is passed in context' do
let(:agent_type) { described_class.new(type_enrichment_data: { 'tag_operator' => 'fill', 'number_of_tags' => 5 }) }
it 'keeps the configured number_of_tags' do
instruction = agent_type.execution_definition['instruction']
expect(instruction).to include('Keep the existing tags and add up to 5 new tags. Return no more than 5 tags.')
end
end
context 'when tag_operator is add' do
let(:agent_type) { described_class.new(type_enrichment_data: { 'tag_operator' => 'add', 'number_of_tags' => 5 }) }
it 'uses the configured number_of_tags verbatim (runtime does not override)' do
ticket.tag_add('existing', 1)
instruction = agent_type.execution_definition(context: { ticket: })['instruction']
expect(instruction).to include('Keep the existing tags and add up to 5 new tags. Return no more than 5 tags.')
expect(instruction).not_to include('Generate up to 5 additional tags.')
end
end
end
describe '#precondition_checks' do
let(:ticket) { create(:ticket) }
let(:agent_type) { described_class.new(type_enrichment_data: { 'number_of_tags' => 2, 'tag_operator' => 'fill' }) }
context 'when ticket is below the limit' do
before { ticket.tag_add('only-one', 1) }
it 'returns checks all passing' do
expect(agent_type.precondition_checks(ticket:)).to all(be_passed)
end
end
context 'when ticket has reached number_of_tags with fill operator' do
before { 2.times { |i| ticket.tag_add("tag-#{i}", 1) } }
it 'returns a failing check' do
expect(agent_type.precondition_checks(ticket:).first.passed?).to be(false)
end
end
context 'when tag_operator is add' do
let(:agent_type) { described_class.new(type_enrichment_data: { 'number_of_tags' => 1, 'tag_operator' => 'add' }) }
before { ticket.tag_add('existing', 1) }
it 'returns checks all passing' do
expect(agent_type.precondition_checks(ticket:)).to all(be_passed)
end
end
context 'when tag_operator is replace' do
let(:agent_type) { described_class.new(type_enrichment_data: { 'number_of_tags' => 1, 'tag_operator' => 'replace' }) }
before { ticket.tag_add('existing', 1) }
it 'returns checks all passing' do
expect(agent_type.precondition_checks(ticket:)).to all(be_passed)
end
end
end
end

View file

@ -12,6 +12,7 @@ RSpec.describe AI::Agent::Type::TicketTextExtractor, :aggregate_failures, curren
'extracted_text' => 'order_no',
'extraction_rules' => 'foo',
'priority_rules' => 'bar',
'articles' => 'last',
}
end
@ -21,8 +22,28 @@ RSpec.describe AI::Agent::Type::TicketTextExtractor, :aggregate_failures, curren
end
it 'includes extraction and priority rules in the instruction prompt' do
expect(subject_attribute['instruction']).to include('foo')
.and include('bar')
expect(subject_attribute['instruction']).to start_with("foo\n\nbar\n")
end
it 'resolves the configured articles value in entity_context' do
expect(subject_attribute['entity_context']).to include('articles' => 'last')
end
context 'when enrichment data contains JSON-unsafe characters' do
let(:type_enrichment_data) do
{
'extracted_text' => 'order_no',
'extraction_rules' => %(line one\n"quoted" line\nline three),
'priority_rules' => 'bar',
'articles' => 'last',
}
end
it 'renders them into the instruction without breaking JSON parsing' do
expect { subject_attribute }.not_to raise_error
expect(subject_attribute['instruction']).to include(%("quoted"))
.and include("line one\n")
end
end
end

View file

@ -10,6 +10,7 @@ RSpec.describe AI::Agent::Type, :aggregate_failures, current_user_id: 1, type: :
expect(described_class.available_types.map(&:name)).to include(
'AI::Agent::Type::TicketGroupDispatcher',
'AI::Agent::Type::TicketCategorizer',
'AI::Agent::Type::TicketTagger',
)
end
end
@ -22,4 +23,91 @@ RSpec.describe AI::Agent::Type, :aggregate_failures, current_user_id: 1, type: :
.and include(**AI::Agent::Type::TicketGroupDispatcher.new.data)
end
end
describe '#precondition_checks' do
let(:ticket) { create(:ticket) }
let(:type) { described_class.new(type_enrichment_data: {}) }
it 'returns an empty array by default' do
expect(type.precondition_checks(ticket:)).to eq([])
end
end
describe 'enrichment_data merge' do
let(:type_class) do
Class.new(described_class) do
def base_type_enrichment_data
super.merge('flag_on' => true)
end
end
end
context 'when the same key is provided by the user and the base' do
it 'the user value wins the merge (base acts as an overridable default)' do
instance = type_class.new(type_enrichment_data: { 'flag_on' => false })
expect(instance.enrichment_data['flag_on']).to be(false)
end
end
context 'when the user provides no value for a base key' do
it 'the base value is surfaced via enrichment_data' do
instance = type_class.new
expect(instance.enrichment_data['flag_on']).to be(true)
end
end
end
describe 'instruction template sanitization integration' do
# The full sanitizer behavior lives in spec/lib/erb_sanitizer_spec.rb. These
# tests exist only to confirm AI::Agent::Type wires the sanitizer into the
# transform_structure pipeline so templates authored in concrete types are
# actually sanitized before the renderer runs.
let(:type_class) do
Class.new(described_class) do
def base_type_enrichment_data
super.merge('flag_on' => true, 'flag_off' => false)
end
end
end
def render_pipeline(template, instance)
instance.send(:sanitize_instruction_template, template)
.then { |sanitized| instance.send(:render_structure, sanitized) }
end
it 'keeps an allowed conditional body when the base value is truthy' do
instance = type_class.new
expect(render_pipeline('A<% if @objects[:type_enrichment_data].flag_on %>B<% end %>C', instance)).to eq('ABC')
end
it 'drops an allowed conditional body when the base value is falsy' do
instance = type_class.new
expect(render_pipeline('A<% if @objects[:type_enrichment_data].flag_off %>B<% end %>C', instance)).to eq('AC')
end
it 'escapes templates that reference unknown names' do
instance = type_class.new
payload = '<% if @objects[:type_enrichment_data].unknown %>X<% end %>'
expect(render_pipeline(payload, instance)).to eq(payload)
end
context 'with injection attempts via user-provided enrichment data' do
it 'does not activate conditionals smuggled in enrichment values' do
# The note value is interpolated via `#{type_enrichment_data.note}`. The
# value gets `<%` escaped to `<%%` by sanitize_template_value before
# reaching ERB, so the injected tags emerge as literal text, not an
# active conditional.
instance = type_class.new(type_enrichment_data: { 'note' => '<% if @objects[:type_enrichment_data].flag_on %>X<% end %>' })
result = render_pipeline('before #{type_enrichment_data.note} after', instance) # rubocop:disable Lint/InterpolationCheck
expect(result).not_to eq('before X after')
expect(result).to include('flag_on')
end
end
end
end

View file

@ -68,7 +68,8 @@ RSpec.shared_examples 'HasTags' do
.with(object: described_class.name,
o_id: subject.id,
items: items,
created_by_id: nil)
created_by_id: nil,
sourceable: nil)
subject.tag_update(items)
end
@ -79,7 +80,8 @@ RSpec.shared_examples 'HasTags' do
.with(object: described_class.name,
o_id: subject.id,
items: items,
created_by_id: 1)
created_by_id: 1,
sourceable: nil)
subject.tag_update(items, 1)
end

View file

@ -52,5 +52,88 @@ RSpec.describe PerformChanges::Action::AttributeUpdates, type: :model do
end
end
end
context 'when tags are provided' do
# `tag_add` writes to ticket history via a polymorphic `sourceable` association,
# which requires a real ActiveRecord instance (an `instance_double` raises
# `NoMethodError: undefined method 'has_query_constraints?'`).
let(:performable) { create(:trigger) }
let(:context_data) { {} }
context 'when tags are provided as comma-separated string' do
let(:execution_data) do
{
'tags' => {
'operator' => 'add',
'value' => 'alpha, beta,alpha , foo, bar, bar',
},
}
end
it 'adds normalized unique tags' do
action.execute
expect(ticket.reload.tag_list).to contain_exactly('alpha', 'beta', 'foo', 'bar')
end
end
context 'when tags are provided as array' do
let(:execution_data) do
{
'tags' => {
'operator' => 'add',
'value' => ['alpha', ' beta ', '', nil, 'alpha', 'foo', 'blub'],
},
}
end
it 'adds normalized unique tags' do
action.execute
expect(ticket.reload.tag_list).to contain_exactly('alpha', 'beta', 'foo', 'blub')
end
end
context 'when operator is replace' do
before { ticket.tag_add('existing', 1) }
let(:execution_data) do
{
'tags' => {
'operator' => 'replace',
'value' => %w[new-tag other],
},
}
end
it 'replaces all existing tags with the new ones' do
action.execute
expect(ticket.reload.tag_list).to contain_exactly('new-tag', 'other')
end
end
context 'when operator is remove' do
before do
ticket.tag_add('alpha', 1)
ticket.tag_add('beta', 1)
end
let(:execution_data) do
{
'tags' => {
'operator' => 'remove',
'value' => ['alpha'],
},
}
end
it 'removes only the specified tag' do
action.execute
expect(ticket.reload.tag_list).to contain_exactly('beta')
end
end
end
end
end

View file

@ -237,6 +237,35 @@ RSpec.describe 'Ticket::PerformChanges', :aggregate_failures do
.to have_enqueued_job(SearchIndexJob).with('Ticket', object.id)
end
end
context 'with replace' do
let(:tag_operator) { 'replace' }
before do
Transaction.execute do
object
%w[tag0 tag1].each { |tag| object.tag_add(tag, 1) }
end
perform_enqueued_jobs
end
it 'replaces the tags' do
expect { object.perform_changes(performable, 'trigger', object, user.id) }
.to change { object.reload.tag_list }.to(%w[tag1 tag2])
end
it 'schedules a search index update job' do
allow(SearchIndexBackend).to receive(:enabled?).and_return(true)
expect do
Transaction.execute do
object.perform_changes(performable, 'trigger', object, user.id)
end
end
.to have_enqueued_job(SearchIndexJob).with('Ticket', object.id)
end
end
end
context 'with "pre_condition" in "perform" hash' do

View file

@ -2,15 +2,20 @@
require 'rails_helper'
RSpec.describe Service::AI::Agent::Run::Context::Entity, type: :service do
let(:ticket) { create(:ticket, title: 'Test Ticket', group: group) }
RSpec.describe Service::AI::Agent::Run::Context::Entity, current_user_id: 1, type: :service do
let(:group) { create(:group, name: 'Example Group') }
let(:entity) { described_class.new(entity_object: ticket, entity_context: entity_context) }
let(:ticket) do
create(:ticket, title: 'Test Ticket', group: group)
.tap { it.tag_add('example_tag') }
end
let(:entity_context) do
{
'object_attributes' => %w[title group_id type]
}
end
let(:entity) { described_class.new(entity_object: ticket, entity_context: entity_context) }
describe '#prepare' do
context 'when entity_object_attributes is blank' do
@ -218,7 +223,7 @@ RSpec.describe Service::AI::Agent::Run::Context::Entity, type: :service do
context 'when articles is set to "last" and entity_article is provided' do
let(:specific_article) { articles.second }
let(:entity) { described_class.new(entity_object: ticket, entity_context: entity_context, entity_article: specific_article) }
let(:entity) { described_class.new(entity_object: ticket, entity_context: entity_context, entity_article: specific_article) }
let(:entity_context) do
{
'object_attributes' => %w[title],
@ -280,6 +285,36 @@ RSpec.describe Service::AI::Agent::Run::Context::Entity, type: :service do
)
end
end
describe 'tags in entity context' do
let(:result) { entity.prepare }
context 'when set to true' do
let(:entity_context) { { 'tags' => true } }
it 'includes tags' do
expect(result).to include(
tags: ['example_tag']
)
end
end
context 'when tags is set to false' do
let(:entity_context) { { 'tags' => false } }
it 'does not include tags' do
expect(result).not_to include(:tags)
end
end
context 'when tags is not mentioned in the context' do
let(:entity_context) { {} }
it 'does not include tags' do
expect(result).not_to include(:tags)
end
end
end
end
end
end

View file

@ -93,5 +93,40 @@ RSpec.describe Service::AI::Agent::Run::Context::Instruction, type: :service do
)
end
end
describe 'tags' do
let(:result) { instruction.prepare }
before do
Tag::Item.destroy_all
create(:tag, tag: 'example_tag')
create(:tag, tag: 'another_tag')
end
context 'when tags are enabled' do
let(:instruction_context) { { 'tags' => true } }
it 'returns available tags' do
expect(result[:tags]).to contain_exactly('example_tag', 'another_tag')
end
end
context 'when tags are disabled' do
let(:instruction_context) { { 'tags' => false } }
it 'does not return tags' do
expect(result).not_to have_key(:tags)
end
end
context 'when tags state not provided' do
let(:instruction_context) { {} }
it 'does not return tags' do
expect(result).not_to have_key(:tags)
end
end
end
end
end

View file

@ -78,6 +78,20 @@ RSpec.describe Service::AI::Agent::Run::Context, type: :service do
expect(result[:object_attributes]).to eq(expected_instruction_result[:object_attributes])
end
end
context 'when instruction_context declares existing_tags' do
let(:instruction_context) do
{ 'existing_tags' => '' }
end
before do
ticket.tag_add('alpha', 1)
end
it 'injects the current tag list at runtime' do
expect(context.prepare_instructions).to include(existing_tags: 'alpha')
end
end
end
describe '#prepare_entity' do

View file

@ -332,6 +332,63 @@ RSpec.describe Service::AI::Agent::Run do
end
end
context 'when AI agent has TicketTagger agent_type' do
let(:ai_agent) { create(:ai_agent, agent_type: 'TicketTagger', type_enrichment_data: type_enrichment_data, definition: {}, action_definition: {}) }
let(:type_enrichment_data) { { 'tag_operator' => 'add', 'tagging_principles' => 'Use short tags.' } }
let(:ai_result_content) do
{
'tags' => %w[billing urgent],
}
end
let(:ai_result) do
AI::Service::Result.new(
content: ai_result_content,
stored_result: nil,
fresh: true
)
end
before do
allow_any_instance_of(AI::Service::AIAgent).to receive(:execute).and_return(ai_result)
end
it 'applies array tags from the AI result' do
service_result
expect(ticket.reload.tag_list).to contain_exactly('billing', 'urgent')
end
it 'embeds configured guidelines into the instruction sent to the model' do
ai_service_spy = instance_double(AI::Service::AIAgent)
allow(AI::Service::AIAgent).to receive(:new).and_return(ai_service_spy)
allow(ai_service_spy).to receive(:execute).and_return(ai_result)
service_result
expect(AI::Service::AIAgent).to have_received(:new).with(
hash_including(
context_data: hash_including(
instruction: include('Use short tags.'),
)
)
)
end
context 'when preconditions are not met (max_tags reached)' do
let(:type_enrichment_data) { { 'number_of_tags' => 1, 'tag_operator' => 'fill' } }
before do
ticket.tag_add('existing', 1)
allow(AI::Service::AIAgent).to receive(:new)
end
it 'skips the LLM call without raising an error', :aggregate_failures do
expect { service_result }.not_to raise_error
expect(AI::Service::AIAgent).not_to have_received(:new)
end
end
end
context 'when AI agent handles multiselect field', db_strategy: :reset do
let(:instruction_context) do
{