i18n-context-generator 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +163 -26
  3. data/lib/i18n_context_generator/android_resource.rb +209 -0
  4. data/lib/i18n_context_generator/apple_string_literal.rb +28 -0
  5. data/lib/i18n_context_generator/cache.rb +40 -8
  6. data/lib/i18n_context_generator/changed_location.rb +55 -0
  7. data/lib/i18n_context_generator/cli.rb +163 -76
  8. data/lib/i18n_context_generator/config/cli_values.rb +33 -0
  9. data/lib/i18n_context_generator/config/defaults.rb +35 -0
  10. data/lib/i18n_context_generator/config/schema.rb +225 -0
  11. data/lib/i18n_context_generator/config/serialization.rb +64 -0
  12. data/lib/i18n_context_generator/config/validation.rb +249 -0
  13. data/lib/i18n_context_generator/config.rb +293 -102
  14. data/lib/i18n_context_generator/context_extractor/cache_identity.rb +65 -0
  15. data/lib/i18n_context_generator/context_extractor/extraction_result.rb +46 -0
  16. data/lib/i18n_context_generator/context_extractor/run_logging.rb +45 -6
  17. data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
  18. data/lib/i18n_context_generator/context_extractor/source_filters.rb +51 -5
  19. data/lib/i18n_context_generator/context_extractor/translation_filters.rb +91 -0
  20. data/lib/i18n_context_generator/context_extractor/workflow.rb +118 -0
  21. data/lib/i18n_context_generator/context_extractor.rb +207 -216
  22. data/lib/i18n_context_generator/file_classifier.rb +72 -0
  23. data/lib/i18n_context_generator/generated_comment.rb +32 -0
  24. data/lib/i18n_context_generator/git_diff/xml_changes.rb +235 -0
  25. data/lib/i18n_context_generator/git_diff.rb +224 -95
  26. data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
  27. data/lib/i18n_context_generator/llm/client.rb +294 -92
  28. data/lib/i18n_context_generator/llm/openai.rb +92 -51
  29. data/lib/i18n_context_generator/llm/openai_compatible.rb +20 -0
  30. data/lib/i18n_context_generator/llm/prompt_evidence.rb +47 -0
  31. data/lib/i18n_context_generator/llm/request_policy.rb +147 -0
  32. data/lib/i18n_context_generator/localization_syntax.rb +246 -0
  33. data/lib/i18n_context_generator/parsers/android_xml_parser.rb +47 -8
  34. data/lib/i18n_context_generator/parsers/base.rb +7 -4
  35. data/lib/i18n_context_generator/parsers/json_parser.rb +4 -0
  36. data/lib/i18n_context_generator/parsers/xcstrings_parser.rb +79 -0
  37. data/lib/i18n_context_generator/parsers/yaml_parser.rb +20 -7
  38. data/lib/i18n_context_generator/path_policy.rb +107 -0
  39. data/lib/i18n_context_generator/platform_validator.rb +24 -50
  40. data/lib/i18n_context_generator/run_metrics.rb +47 -0
  41. data/lib/i18n_context_generator/searcher/comment_masking.rb +115 -0
  42. data/lib/i18n_context_generator/searcher/match_filtering.rb +52 -0
  43. data/lib/i18n_context_generator/searcher/source_discovery.rb +109 -64
  44. data/lib/i18n_context_generator/searcher.rb +61 -205
  45. data/lib/i18n_context_generator/supplemental_context.rb +77 -0
  46. data/lib/i18n_context_generator/translation_comment_index.rb +121 -0
  47. data/lib/i18n_context_generator/version.rb +1 -1
  48. data/lib/i18n_context_generator/writers/android_xml_writer.rb +48 -18
  49. data/lib/i18n_context_generator/writers/atomic_file.rb +37 -0
  50. data/lib/i18n_context_generator/writers/csv_writer.rb +43 -18
  51. data/lib/i18n_context_generator/writers/helpers.rb +7 -29
  52. data/lib/i18n_context_generator/writers/json_writer.rb +31 -6
  53. data/lib/i18n_context_generator/writers/preview.rb +80 -0
  54. data/lib/i18n_context_generator/writers/result_serialization.rb +30 -0
  55. data/lib/i18n_context_generator/writers/strings_writer.rb +23 -12
  56. data/lib/i18n_context_generator/writers/swift_writer.rb +16 -54
  57. data/lib/i18n_context_generator/writers/xcstrings_writer.rb +57 -0
  58. data/lib/i18n_context_generator/xcstrings_document.rb +218 -0
  59. data/lib/i18n_context_generator/xml_scanner.rb +38 -0
  60. data/lib/i18n_context_generator.rb +20 -4
  61. metadata +59 -12
@@ -2,30 +2,74 @@
2
2
 
3
3
  require 'open3'
4
4
  require 'pathname'
5
+ require_relative 'apple_string_literal'
6
+ require_relative 'changed_location'
7
+ require_relative 'translation_comment_index'
8
+ require_relative 'xml_scanner'
9
+ require_relative 'android_resource'
10
+ require_relative 'xcstrings_document'
11
+ require_relative 'git_diff/xml_changes'
5
12
 
6
13
  module I18nContextGenerator
7
14
  # Parses git diff to extract changed translation keys
8
15
  class GitDiff
9
- def initialize(base_ref: 'main')
16
+ include GitDiffXmlChanges
17
+
18
+ def initialize(base_ref: 'main', head_ref: 'HEAD')
10
19
  @base_ref = base_ref
20
+ @head_ref = head_ref
11
21
  end
12
22
 
13
23
  # Get keys that were added or modified since the base ref
14
24
  # @param translation_paths [Array<String>] paths to translation files
15
25
  # @return [Set<String>] set of changed keys
16
26
  def changed_keys(translation_paths)
17
- keys = Set.new
27
+ changed_key_locations(translation_paths).each_key.to_set(&:last)
28
+ end
18
29
 
19
- translation_paths.each do |path|
30
+ # Get changed translation keys together with the exact changed lines that
31
+ # produced them. Keys are scoped by translation file so duplicate keys in
32
+ # different files remain distinct.
33
+ # @return [Hash{Array(String, String) => Array<ChangedLocation>}]
34
+ def changed_key_locations(translation_paths)
35
+ translation_paths.each_with_object({}) do |path, changes|
20
36
  next unless File.exist?(path)
21
37
 
22
38
  diff_output = git_diff_for_path(path)
23
39
  next if diff_output.empty?
24
40
 
25
- keys.merge(extract_keys_from_diff(diff_output, path))
41
+ normalized_path = Pathname.new(path).cleanpath.to_s
42
+ base_content, head_content = revision_contents(path) if revision_contents_needed?(path, diff_output)
43
+ head_content ||= File.binread(path)
44
+ key_locations = case File.extname(path).downcase
45
+ when '.strings'
46
+ extract_strings_key_locations(
47
+ diff_output,
48
+ normalized_path,
49
+ base_content: base_content,
50
+ head_content: head_content
51
+ )
52
+ when '.xcstrings'
53
+ extract_xcstrings_key_locations(
54
+ diff_output,
55
+ normalized_path,
56
+ base_content: base_content,
57
+ head_content: head_content
58
+ )
59
+ when '.xml'
60
+ extract_xml_key_locations(
61
+ diff_output,
62
+ normalized_path,
63
+ base_content: base_content,
64
+ head_content: head_content
65
+ )
66
+ else
67
+ {}
68
+ end
69
+ key_locations.each do |key, locations|
70
+ changes[[normalized_path, key]] = locations
71
+ end
26
72
  end
27
-
28
- keys
29
73
  end
30
74
 
31
75
  # Get changed line numbers in source files since the base ref.
@@ -52,35 +96,56 @@ module I18nContextGenerator
52
96
  system('git', 'rev-parse', '--verify', @base_ref, out: File::NULL, err: File::NULL)
53
97
  end
54
98
 
99
+ def head_ref_exists?
100
+ system('git', 'rev-parse', '--verify', @head_ref, out: File::NULL, err: File::NULL)
101
+ end
102
+
55
103
  private
56
104
 
57
105
  def git_diff_for_path(path)
58
106
  # Run git from the directory containing the file so the correct repo is used
59
107
  dir = File.directory?(path) ? path : File.dirname(path)
60
108
  pathspec = File.directory?(path) ? '.' : File.basename(path)
61
- # Use triple-dot to get changes on current branch since it diverged from base
62
- stdout, _stderr, status = Open3.capture3('git', 'diff', "#{@base_ref}...HEAD", '--', pathspec, chdir: dir)
63
- status.success? ? stdout : ''
109
+ # Use triple-dot to get changes on the configured head since it diverged from base.
110
+ stdout, stderr, status = Open3.capture3(
111
+ 'git', 'diff', "#{@base_ref}...#{@head_ref}", '--', pathspec, chdir: dir
112
+ )
113
+ return stdout if status.success?
114
+
115
+ detail = stderr.strip
116
+ detail = 'git exited unsuccessfully without an error message' if detail.empty?
117
+ raise Error, "Git diff failed for #{@base_ref}...#{@head_ref} (#{path}): #{detail}"
118
+ rescue SystemCallError => e
119
+ raise Error, "Git diff failed for #{@base_ref}...#{@head_ref} (#{path}): #{e.message}"
64
120
  end
65
121
 
66
122
  def extract_changed_lines(diff_output, path)
67
123
  changed_lines = Hash.new { |h, k| h[k] = Set.new }
68
124
  current_file = File.file?(path) ? path : nil
69
125
  file_line = nil
126
+ in_hunk = false
70
127
 
71
128
  diff_output.each_line do |line|
72
- if (match = line.match(%r{^\+\+\+ b/(.+)$}))
129
+ if line.start_with?('diff ')
130
+ file_line = nil
131
+ in_hunk = false
132
+ next
133
+ end
134
+
135
+ if !in_hunk && (match = line.match(%r{^\+\+\+ b/(.+)$}))
73
136
  current_file = resolve_diff_file_path(path, match[1])
74
137
  next
75
138
  end
76
139
 
77
140
  if (hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/))
78
141
  file_line = hunk[1].to_i
142
+ in_hunk = true
79
143
  next
80
144
  end
81
145
 
82
- next if line.start_with?('diff ', 'index ', '--- ', '+++ ', '\\')
83
- next if file_line.nil? || current_file.nil?
146
+ next if !in_hunk && line.start_with?('index ', '--- ', '+++ ')
147
+ next if line.start_with?('\\')
148
+ next if !in_hunk || file_line.nil? || current_file.nil?
84
149
 
85
150
  if line.start_with?('+')
86
151
  changed_lines[current_file] << file_line
@@ -98,124 +163,188 @@ module I18nContextGenerator
98
163
  def resolve_diff_file_path(path, diff_file_path)
99
164
  return Pathname.new(path).cleanpath.to_s if File.file?(path)
100
165
 
101
- normalized_path = path.to_s.sub(%r{/\z}, '')
166
+ normalized_path = Pathname.new(path).cleanpath.to_s
167
+ if Pathname.new(normalized_path).absolute?
168
+ prefix = repository_prefix_for(path)
169
+ relative_path = diff_file_path.delete_prefix(prefix)
170
+ return Pathname.new(File.join(normalized_path, relative_path)).cleanpath.to_s
171
+ end
102
172
  return Pathname.new(diff_file_path).cleanpath.to_s if normalized_path.empty? || normalized_path == '.'
103
173
  return Pathname.new(diff_file_path).cleanpath.to_s if diff_file_path == normalized_path || diff_file_path.start_with?("#{normalized_path}/")
104
174
 
105
175
  Pathname.new(File.join(normalized_path, diff_file_path)).cleanpath.to_s
106
176
  end
107
177
 
178
+ def repository_prefix_for(path)
179
+ @repository_prefixes ||= {}
180
+ directory = File.directory?(path) ? path : File.dirname(path)
181
+ @repository_prefixes[directory] ||= begin
182
+ stdout, stderr, status = Open3.capture3('git', 'rev-parse', '--show-prefix', chdir: directory)
183
+ if status.success?
184
+ stdout.strip.sub(%r{/\z}, '')
185
+ else
186
+ detail = stderr.strip
187
+ detail = 'unable to resolve repository path prefix' if detail.empty?
188
+ raise Error, "Git diff failed for #{@base_ref}...#{@head_ref} (#{path}): #{detail}"
189
+ end
190
+ end
191
+ end
192
+
108
193
  def merge_line_maps!(target, source)
109
194
  source.each do |file, lines|
110
195
  target[file].merge(lines)
111
196
  end
112
197
  end
113
198
 
114
- def extract_keys_from_diff(diff_output, path)
115
- ext = File.extname(path).downcase
116
-
117
- case ext
118
- when '.strings'
119
- extract_strings_keys(diff_output)
120
- when '.xml'
121
- extract_xml_keys(diff_output, path)
122
- else
123
- Set.new
199
+ def extract_strings_key_locations(diff_output, file_path, base_content: nil, head_content: nil)
200
+ locations = Hash.new { |hash, key| hash[key] = [] }
201
+ head_content ||= File.binread(file_path)
202
+ head_index = TranslationCommentIndex.new(format: :strings, content: head_content)
203
+ base_index = TranslationCommentIndex.new(format: :strings, content: base_content) if base_content
204
+
205
+ each_changed_diff_line(diff_output) do |content, old_line, new_line, side|
206
+ if side == :right
207
+ key = AppleStringLiteral.assignment_key(content) || head_index.key_at(new_line)
208
+ locations[key] << changed_location(file_path, new_line, side: :right) if key
209
+ elsif base_index
210
+ key = AppleStringLiteral.assignment_key(content) || base_index.key_at(old_line)
211
+ fallback_line = head_index.line_for_key(key)
212
+ if key
213
+ locations[key] << changed_location(
214
+ file_path,
215
+ old_line,
216
+ side: :left,
217
+ fallback_line: fallback_line
218
+ )
219
+ end
220
+ end
124
221
  end
222
+
223
+ prefer_right_locations(locations)
125
224
  end
126
225
 
127
- # Extract keys from iOS .strings diff
128
- # Looks for added lines like: +"key" = "value";
129
- def extract_strings_keys(diff_output)
130
- keys = Set.new
226
+ def extract_xcstrings_key_locations(diff_output, file_path, base_content: nil, head_content: nil)
227
+ current_content = head_content || File.binread(file_path)
228
+ head_index = xcstrings_line_index(head_content || current_content, file_path)
229
+ base_index = xcstrings_line_index(base_content || head_content || current_content, file_path)
230
+ locations = Hash.new { |hash, key| hash[key] = [] }
231
+ head_lines = first_lines_by_key(head_index)
131
232
 
132
- diff_output.each_line do |line|
133
- # Match added or modified lines (start with +, not ++)
134
- next unless line.start_with?('+') && !line.start_with?('++')
233
+ each_changed_diff_line(diff_output) do |content, old_line, new_line, side|
234
+ next if content.strip.empty?
135
235
 
136
- # Extract key from: "key" = "value";
137
- keys << Regexp.last_match(1) if line =~ /^\+\s*"([^"]+)"\s*=/
236
+ if side == :right
237
+ key = head_index[new_line]
238
+ locations[key] << changed_location(file_path, new_line, side: :right) if key
239
+ else
240
+ key = base_index[old_line]
241
+ fallback_line = head_lines[key]
242
+ if key
243
+ locations[key] << changed_location(
244
+ file_path,
245
+ old_line,
246
+ side: :left,
247
+ fallback_line: fallback_line
248
+ )
249
+ end
250
+ end
138
251
  end
139
252
 
140
- keys
253
+ prefer_right_locations(locations)
141
254
  end
142
255
 
143
- # Extract keys from Android strings.xml diff.
144
- # Tracks parent element context from diff lines and uses hunk headers to
145
- # map added lines to file positions. When an added <item> can't be attributed
146
- # to a parent from diff context alone (e.g. large plural/array blocks where the
147
- # opener isn't in the hunk), falls back to reading the actual file.
148
- def extract_xml_keys(diff_output, file_path)
149
- keys = Set.new
150
- current_parent = nil
151
- file_line = nil
152
- orphaned_item_file_lines = []
256
+ def xcstrings_line_index(content, file_path)
257
+ return {} unless content
153
258
 
154
- diff_output.each_line do |line|
155
- # Parse hunk header to track position in new file
156
- if (hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/))
157
- file_line = hunk[1].to_i
158
- next
159
- end
259
+ XcstringsDocument.new(content, path: file_path).line_index
260
+ end
160
261
 
161
- # Skip diff metadata lines
162
- next if line.start_with?('diff ', 'index ', '--- ', '+++ ')
262
+ def revision_contents_needed?(path, diff_output)
263
+ File.extname(path).downcase == '.xcstrings' ||
264
+ diff_output.each_line.any? { |line| line.start_with?('-') && !line.start_with?('---') }
265
+ end
163
266
 
164
- is_removed = line.start_with?('-')
165
- is_added = line.start_with?('+')
166
- content = line.sub(/^[ +-]/, '')
267
+ def revision_contents(path)
268
+ directory = File.dirname(File.expand_path(path))
269
+ root, stderr, status = Open3.capture3('git', 'rev-parse', '--show-toplevel', chdir: directory)
270
+ unless status.success?
271
+ detail = stderr.strip
272
+ detail = 'unable to resolve repository root' if detail.empty?
273
+ raise Error, "Git diff failed for #{@base_ref}...#{@head_ref} (#{path}): #{detail}"
274
+ end
167
275
 
168
- # Track parent element from any visible line (context, added, or removed)
169
- if content =~ /<(?:plurals|string-array)\s+name=["']([^"']+)["']/
170
- current_parent = Regexp.last_match(1)
171
- elsif content =~ %r{</(?:plurals|string-array)>}
172
- current_parent = nil
173
- end
276
+ repository_root = root.strip
277
+ relative_path = Pathname.new(File.expand_path(path))
278
+ .relative_path_from(Pathname.new(repository_root)).to_s
279
+ merge_base, _stderr, status = Open3.capture3(
280
+ 'git', 'merge-base', @base_ref, @head_ref, chdir: repository_root
281
+ )
282
+ base_revision = status.success? && !merge_base.strip.empty? ? merge_base.strip : @base_ref
283
+ [
284
+ file_at_revision(repository_root, relative_path, base_revision),
285
+ file_at_revision(repository_root, relative_path, @head_ref)
286
+ ]
287
+ end
174
288
 
175
- # Process added lines for key extraction
176
- if is_added
177
- keys << Regexp.last_match(1) if content =~ /<string\s+name=["']([^"']+)["']/
178
- keys << Regexp.last_match(1) if content =~ /<(?:plurals|string-array)\s+name=["']([^"']+)["']/
179
-
180
- if content =~ /^\s*<item[\s>]/
181
- if current_parent
182
- keys << current_parent
183
- elsif file_line
184
- orphaned_item_file_lines << file_line
185
- end
186
- end
187
- end
289
+ def file_at_revision(repository_root, relative_path, revision)
290
+ stdout, _stderr, status = Open3.capture3(
291
+ 'git', 'show', "#{revision}:#{relative_path}", chdir: repository_root
292
+ )
293
+ status.success? ? stdout : nil
294
+ end
188
295
 
189
- # Context and added lines exist in new file; removed lines do not
190
- file_line += 1 if file_line && !is_removed
191
- end
296
+ def changed_location(file, line, side:, fallback_line: nil)
297
+ ChangedLocation.new(file: file, line: line, side: side, fallback_line: fallback_line)
298
+ end
192
299
 
193
- # Resolve orphaned items by reading the actual file
194
- resolve_orphaned_items(keys, orphaned_item_file_lines, file_path)
300
+ def prefer_right_locations(locations)
301
+ preferred = locations.transform_values do |values|
302
+ unique = values.uniq
303
+ right = unique.select(&:right?)
304
+ right.empty? ? unique : right
305
+ end
306
+ preferred.reject { |_key, values| values.empty? }
307
+ end
195
308
 
196
- keys
309
+ def first_lines_by_key(line_index)
310
+ line_index.each_with_object({}) do |(line, key), lines|
311
+ lines[key] ||= line
312
+ end
197
313
  end
198
314
 
199
- # Build a map of file line numbers to enclosing plural/array resource names,
200
- # then use it to attribute orphaned <item> additions to their parent.
201
- def resolve_orphaned_items(keys, orphaned_lines, file_path)
202
- return if orphaned_lines.empty? || !File.exist?(file_path)
315
+ def each_changed_diff_line(diff_output)
316
+ old_line_number = nil
317
+ new_line_number = nil
318
+ in_hunk = false
203
319
 
204
- current_parent = nil
205
- parent_at_line = {}
320
+ diff_output.each_line do |line|
321
+ if line.start_with?('diff ')
322
+ old_line_number = nil
323
+ new_line_number = nil
324
+ in_hunk = false
325
+ next
326
+ end
206
327
 
207
- File.readlines(file_path).each_with_index do |line, index|
208
- if line =~ /<(?:plurals|string-array)\s+name=["']([^"']+)["']/
209
- current_parent = Regexp.last_match(1)
210
- elsif line =~ %r{</(?:plurals|string-array)>}
211
- current_parent = nil
328
+ if (match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/))
329
+ old_line_number = match[1].to_i
330
+ new_line_number = match[2].to_i
331
+ in_hunk = true
332
+ next
212
333
  end
213
- parent_at_line[index + 1] = current_parent
214
- end
334
+ next if !in_hunk && line.start_with?('index ', '--- ', '+++ ')
335
+ next if line.start_with?('\\')
336
+ next if !in_hunk || old_line_number.nil? || new_line_number.nil?
215
337
 
216
- orphaned_lines.each do |line_num|
217
- parent = parent_at_line[line_num]
218
- keys << parent if parent
338
+ if line.start_with?('+')
339
+ yield(line[1..], nil, new_line_number, :right)
340
+ new_line_number += 1
341
+ elsif line.start_with?('-')
342
+ yield(line[1..], old_line_number, nil, :left)
343
+ old_line_number += 1
344
+ else
345
+ old_line_number += 1
346
+ new_line_number += 1
347
+ end
219
348
  end
220
349
  end
221
350
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module I18nContextGenerator
4
4
  module LLM
5
- # Claude API implementation of the LLM client with retry logic for rate limits.
5
+ # Claude API implementation of the LLM client.
6
6
  class Anthropic < Client
7
7
  API_URL = 'https://api.anthropic.com/v1/messages'
8
8
  ANTHROPIC_VERSION = '2023-06-01'
@@ -16,36 +16,31 @@ module I18nContextGenerator
16
16
  @uri = URI(API_URL)
17
17
  end
18
18
 
19
- MAX_RETRIES = 2
20
-
21
19
  def generate_context(key:, text:, matches:, model: nil, comment: nil,
22
- include_file_paths: false, redact_prompts: true)
23
- model ||= DEFAULT_MODEL
20
+ include_file_paths: false, redact_prompts: true,
21
+ max_prompt_chars: nil, supplemental_context: [])
22
+ outcome = nil
23
+ model = resolved_model(model)
24
24
  prompt = build_prompt(
25
25
  key: key,
26
26
  text: text,
27
27
  matches: matches,
28
28
  comment: comment,
29
29
  include_file_paths: include_file_paths,
30
- redact_prompts: redact_prompts
30
+ redact_prompts: redact_prompts,
31
+ max_prompt_chars: max_prompt_chars,
32
+ supplemental_context: supplemental_context
31
33
  )
32
- retries = 0
33
-
34
- loop do
35
- response = post_request(model: model, prompt: prompt)
36
-
37
- # Retry on rate limit with backoff
38
- if response.code.to_i == 429 && retries < MAX_RETRIES
39
- retries += 1
40
- delay = (response['retry-after']&.to_i || 2) * retries
41
- sleep(delay)
42
- next
43
- end
44
-
45
- return handle_response(response)
46
- end
34
+ outcome = request_with_retries(uri: @uri) { post_request(model: model, prompt: prompt) }
35
+ handle_response(outcome.response, retries: outcome.retries)
36
+ rescue PromptPreparationError => e
37
+ ContextResult.new(description: 'Prompt preparation failed', error: e.message)
47
38
  rescue StandardError => e
48
- ContextResult.new(description: 'API request failed', error: e.message)
39
+ ContextResult.new(
40
+ description: 'API request failed',
41
+ error: e.message,
42
+ **failure_request_telemetry(e, outcome: outcome)
43
+ )
49
44
  end
50
45
 
51
46
  private
@@ -59,33 +54,65 @@ module I18nContextGenerator
59
54
  },
60
55
  body: {
61
56
  model: model,
62
- max_tokens: 500,
57
+ max_tokens: MAX_OUTPUT_TOKENS,
63
58
  system: SYSTEM_PROMPT,
64
- messages: [{ role: 'user', content: prompt }]
59
+ messages: [{ role: 'user', content: prompt }],
60
+ output_config: {
61
+ format: {
62
+ type: 'json_schema',
63
+ schema: RESPONSE_SCHEMA
64
+ }
65
+ }
65
66
  }
66
67
  )
67
68
  end
68
69
 
69
- def handle_response(response)
70
+ def handle_response(response, retries:)
70
71
  case response.code.to_i
71
72
  when 200
72
73
  body = JSON.parse(response.body)
73
- content = body.dig('content', 0, 'text')
74
- parse_response(content)
75
- when 429
76
- ContextResult.new(description: 'Rate limited', error: 'Rate limit exceeded - try reducing concurrency')
77
- when 401
78
- ContextResult.new(description: 'Authentication failed', error: 'Invalid API key')
74
+ handle_successful_response(body, retries: retries)
79
75
  else
80
- error_body = begin
81
- JSON.parse(response.body)
82
- rescue StandardError
83
- {}
84
- end
85
- error_msg = error_body.dig('error', 'message') || "HTTP #{response.code}"
86
- ContextResult.new(description: 'API error', error: error_msg)
76
+ http_error_result(response, retries: retries)
87
77
  end
88
78
  end
79
+
80
+ def handle_successful_response(body, retries:)
81
+ telemetry = usage_telemetry(body, retries: retries)
82
+ case body['stop_reason']
83
+ when 'end_turn'
84
+ content = Array(body['content']).find { |item| item['type'] == 'text' }
85
+ parse_response(content&.[]('text'), telemetry: telemetry)
86
+ when 'refusal'
87
+ ContextResult.new(
88
+ description: 'Provider refused request',
89
+ error: 'Anthropic refused to generate context',
90
+ **telemetry
91
+ )
92
+ when 'max_tokens'
93
+ ContextResult.new(
94
+ description: 'Incomplete response',
95
+ error: 'Anthropic response reached max_tokens',
96
+ **telemetry
97
+ )
98
+ else
99
+ reason = body['stop_reason'] || 'missing'
100
+ ContextResult.new(
101
+ description: 'Incomplete response',
102
+ error: "Unexpected Anthropic stop reason: #{reason}",
103
+ **telemetry
104
+ )
105
+ end
106
+ end
107
+
108
+ def usage_telemetry(body, retries:)
109
+ {
110
+ input_tokens: body.dig('usage', 'input_tokens').to_i,
111
+ output_tokens: body.dig('usage', 'output_tokens').to_i,
112
+ retries: retries,
113
+ request_count: retries + 1
114
+ }
115
+ end
89
116
  end
90
117
  end
91
118
  end