mirror of
https://github.com/zammad/zammad
synced 2026-05-24 09:48:36 +00:00
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:
parent
1868dfbdcd
commit
1a33abad0d
29 changed files with 1675 additions and 128 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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/%"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
232
app/models/ai/agent/type/ticket_tagger.rb
Normal file
232
app/models/ai/agent/type/ticket_tagger.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
134
i18n/zammad.pot
134
i18n/zammad.pot
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 -%>
|
||||
|
|
|
|||
|
|
@ -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
90
lib/erb_sanitizer.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
315
spec/lib/erb_sanitizer_spec.rb
Normal file
315
spec/lib/erb_sanitizer_spec.rb
Normal 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
|
||||
160
spec/models/ai/agent/type/ticket_tagger_spec.rb
Normal file
160
spec/models/ai/agent/type/ticket_tagger_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue