i18n-context-generator 0.4.0 → 0.5.1
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.
- checksums.yaml +4 -4
- data/README.md +163 -26
- data/lib/i18n_context_generator/android_resource.rb +209 -0
- data/lib/i18n_context_generator/apple_string_literal.rb +28 -0
- data/lib/i18n_context_generator/cache.rb +40 -8
- data/lib/i18n_context_generator/changed_location.rb +55 -0
- data/lib/i18n_context_generator/cli.rb +163 -76
- data/lib/i18n_context_generator/config/cli_values.rb +33 -0
- data/lib/i18n_context_generator/config/defaults.rb +35 -0
- data/lib/i18n_context_generator/config/schema.rb +225 -0
- data/lib/i18n_context_generator/config/serialization.rb +64 -0
- data/lib/i18n_context_generator/config/validation.rb +249 -0
- data/lib/i18n_context_generator/config.rb +293 -102
- data/lib/i18n_context_generator/context_extractor/cache_identity.rb +65 -0
- data/lib/i18n_context_generator/context_extractor/extraction_result.rb +46 -0
- data/lib/i18n_context_generator/context_extractor/run_logging.rb +45 -6
- data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
- data/lib/i18n_context_generator/context_extractor/source_filters.rb +51 -5
- data/lib/i18n_context_generator/context_extractor/translation_filters.rb +91 -0
- data/lib/i18n_context_generator/context_extractor/workflow.rb +118 -0
- data/lib/i18n_context_generator/context_extractor.rb +207 -216
- data/lib/i18n_context_generator/file_classifier.rb +72 -0
- data/lib/i18n_context_generator/generated_comment.rb +32 -0
- data/lib/i18n_context_generator/git_diff/xml_changes.rb +235 -0
- data/lib/i18n_context_generator/git_diff.rb +224 -95
- data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
- data/lib/i18n_context_generator/llm/client.rb +304 -92
- data/lib/i18n_context_generator/llm/openai.rb +92 -51
- data/lib/i18n_context_generator/llm/openai_compatible.rb +20 -0
- data/lib/i18n_context_generator/llm/prompt_evidence.rb +47 -0
- data/lib/i18n_context_generator/llm/request_policy.rb +147 -0
- data/lib/i18n_context_generator/localization_syntax.rb +246 -0
- data/lib/i18n_context_generator/parsers/android_xml_parser.rb +47 -8
- data/lib/i18n_context_generator/parsers/base.rb +7 -4
- data/lib/i18n_context_generator/parsers/json_parser.rb +4 -0
- data/lib/i18n_context_generator/parsers/xcstrings_parser.rb +79 -0
- data/lib/i18n_context_generator/parsers/yaml_parser.rb +20 -7
- data/lib/i18n_context_generator/path_policy.rb +107 -0
- data/lib/i18n_context_generator/platform_validator.rb +24 -50
- data/lib/i18n_context_generator/run_metrics.rb +47 -0
- data/lib/i18n_context_generator/searcher/comment_masking.rb +115 -0
- data/lib/i18n_context_generator/searcher/match_filtering.rb +52 -0
- data/lib/i18n_context_generator/searcher/source_discovery.rb +109 -64
- data/lib/i18n_context_generator/searcher.rb +61 -205
- data/lib/i18n_context_generator/supplemental_context.rb +77 -0
- data/lib/i18n_context_generator/translation_comment_index.rb +121 -0
- data/lib/i18n_context_generator/version.rb +1 -1
- data/lib/i18n_context_generator/writers/android_xml_writer.rb +48 -18
- data/lib/i18n_context_generator/writers/atomic_file.rb +37 -0
- data/lib/i18n_context_generator/writers/csv_writer.rb +43 -18
- data/lib/i18n_context_generator/writers/helpers.rb +7 -29
- data/lib/i18n_context_generator/writers/json_writer.rb +31 -6
- data/lib/i18n_context_generator/writers/preview.rb +80 -0
- data/lib/i18n_context_generator/writers/result_serialization.rb +30 -0
- data/lib/i18n_context_generator/writers/strings_writer.rb +23 -12
- data/lib/i18n_context_generator/writers/swift_writer.rb +16 -54
- data/lib/i18n_context_generator/writers/xcstrings_writer.rb +57 -0
- data/lib/i18n_context_generator/xcstrings_document.rb +248 -0
- data/lib/i18n_context_generator/xml_scanner.rb +38 -0
- data/lib/i18n_context_generator.rb +20 -4
- 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
|
-
|
|
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
|
-
|
|
27
|
+
changed_key_locations(translation_paths).each_key.to_set(&:last)
|
|
28
|
+
end
|
|
18
29
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
stdout,
|
|
63
|
-
|
|
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
|
|
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?('
|
|
83
|
-
next if
|
|
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
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
253
|
+
prefer_right_locations(locations)
|
|
141
254
|
end
|
|
142
255
|
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|