teri 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.
@@ -0,0 +1,338 @@
1
+ module Teri
2
+ # Handles transaction coding logic
3
+ class TransactionCoder
4
+ def initialize(category_manager, ai_integration, options, logger)
5
+ @category_manager = category_manager
6
+ @ai_integration = ai_integration
7
+ @openai_client = ai_integration.openai_client
8
+ @options = options
9
+ @logger = logger
10
+ end
11
+
12
+ # Core transaction coding logic without I/O operations
13
+ def code_transaction(transaction, selected_option, split_input = nil, new_category = nil)
14
+ # Check if the transaction has an unknown category
15
+ unknown_categories = ['Income:Unknown', 'Expenses:Unknown']
16
+
17
+ # Find entries with unknown categories
18
+ unknown_entry = transaction.entries.find do |entry|
19
+ unknown_categories.include?(entry.account)
20
+ end
21
+
22
+ raise "Transaction has no unknown category: #{transaction}" unless unknown_entry
23
+
24
+ # Initialize new_categories
25
+ new_categories = {}
26
+
27
+ # Process the selected option
28
+ case selected_option.to_i
29
+ when 1
30
+ # Split transaction between multiple categories
31
+ if split_input && !split_input.empty?
32
+ # Parse the split input
33
+ split_input.split(',').each do |part|
34
+ # Handle the format "Category:Amount" or just "Category"
35
+ if part.include?(':')
36
+ parts = part.split(':')
37
+ if parts.length >= 3
38
+ # Format is Category:Subcategory:Amount
39
+ category = "#{parts[0]}:#{parts[1]}"
40
+ amount = parts[2].to_f
41
+ else
42
+ # Format is Category:Amount
43
+ category = parts[0]
44
+ amount = parts[1].to_f
45
+ end
46
+ new_categories[category] = amount
47
+ else
48
+ # Just a category with no amount specified
49
+ new_categories[part] = unknown_entry.amount
50
+ end
51
+ end
52
+ else
53
+ puts 'Please enter the split categories and amounts (e.g. Expenses:Rent:500,Expenses:Utilities:250):'
54
+ split_input = gets.chomp
55
+ return code_transaction(transaction, selected_option, split_input)
56
+ end
57
+ when 2
58
+ # Enter a custom category
59
+ if new_category && !new_category.empty?
60
+ new_categories[new_category] = unknown_entry.amount
61
+ else
62
+ puts 'Please enter the new category:'
63
+ new_category = gets.chomp
64
+ return code_transaction(transaction, selected_option, nil, new_category)
65
+ end
66
+ when 3..999
67
+ # Use a predefined category
68
+ category_index = selected_option.to_i - 3
69
+ all_cats = @category_manager.all_categories
70
+ if category_index < all_cats.size
71
+ category = all_cats[category_index]
72
+ new_categories[category] = unknown_entry.amount
73
+ else
74
+ puts 'Invalid category index. Please try again.'
75
+ return nil
76
+ end
77
+ else
78
+ puts 'Invalid option. Please try again.'
79
+ return nil
80
+ end
81
+
82
+ # Create a reverse transaction with the new categories
83
+ transaction.create_reverse_transaction(new_categories)
84
+ end
85
+
86
+ # Get AI suggestion for a transaction
87
+ def get_ai_suggestion(transaction, io = nil)
88
+ return nil unless @openai_client && @options[:use_ai_suggestions]
89
+
90
+ begin
91
+ io&.puts 'Getting AI suggestion...'
92
+ @logger&.info('Requesting AI suggestion')
93
+
94
+ ai_suggestion = @ai_integration.suggest_category(
95
+ transaction,
96
+ @category_manager.all_categories
97
+ )
98
+
99
+ if ai_suggestion[:category]
100
+ # Find the option number for the AI suggested category
101
+ all_cats = @category_manager.all_categories
102
+ ai_option_index = all_cats.find_index(ai_suggestion[:category])
103
+ ai_option_number = ai_option_index ? ai_option_index + 3 : nil
104
+
105
+ @logger&.info("AI suggestion: #{ai_suggestion[:category]} (Confidence: #{(ai_suggestion[:confidence] * 100).round(1)}%)")
106
+ @logger&.info("AI explanation: #{ai_suggestion[:explanation]}")
107
+
108
+ io&.puts "AI Suggestion: #{ai_suggestion[:category]} (Confidence: #{(ai_suggestion[:confidence] * 100).round(1)}%)"
109
+ io&.puts "Explanation: #{ai_suggestion[:explanation]}"
110
+ io&.puts ''
111
+
112
+ return {
113
+ suggestion: ai_suggestion,
114
+ option_number: ai_option_number
115
+ }
116
+ else
117
+ @logger&.warn('AI did not provide a category suggestion')
118
+ return nil
119
+ end
120
+ rescue StandardError => e
121
+ @logger&.error("Error getting AI suggestion: #{e.message}")
122
+ io&.puts "Error getting AI suggestion: #{e.message}"
123
+ return nil
124
+ end
125
+ end
126
+
127
+ # Save a transaction to coding.ledger
128
+ def save_transaction(reverse_transaction, transaction, selected_option, feedback = nil)
129
+ return false unless reverse_transaction
130
+
131
+ # Add the feedback as a comment to the reverse transaction
132
+ if feedback && !feedback.empty? && reverse_transaction.respond_to?(:add_comment)
133
+ reverse_transaction.add_comment("Hint: #{feedback}")
134
+ end
135
+
136
+ # Append to coding.ledger
137
+ File.open('coding.ledger', 'a') do |file|
138
+ file.puts reverse_transaction.to_ledger
139
+ end
140
+
141
+ @logger&.info('Transaction coded and saved to coding.ledger')
142
+
143
+ # Update previous codings cache with this transaction
144
+ if @openai_client && selected_option.to_i >= 3
145
+ category_index = selected_option.to_i - 3
146
+ all_cats = @category_manager.all_categories
147
+ if category_index < all_cats.size
148
+ selected_category = all_cats[category_index]
149
+ # Store more information about the transaction
150
+ @ai_integration.update_previous_codings(
151
+ transaction.description,
152
+ selected_category,
153
+ transaction.counterparty,
154
+ feedback ? [feedback] : transaction.hints
155
+ )
156
+ @logger&.info("Updated previous codings cache with: #{transaction.description} => #{selected_category} (Counterparty: #{transaction.counterparty})")
157
+ end
158
+ end
159
+
160
+ true
161
+ end
162
+
163
+ def code_transaction_interactively(transaction, responses = nil, saved_responses = nil, auto_apply_ai = false, io)
164
+ # Display the transaction information to the user
165
+ io.puts transaction.to_s
166
+ io.puts ''
167
+
168
+ @logger&.info("Transaction details: #{transaction.to_s.gsub("\n", ' | ')}")
169
+
170
+ # Get AI suggestion if available
171
+ ai_result = get_ai_suggestion(transaction, io)
172
+ ai_suggestion = ai_result&.dig(:suggestion)
173
+ ai_option_number = ai_result&.dig(:option_number)
174
+
175
+ # Display the available options to the user
176
+ all_cats = @category_manager.all_categories
177
+
178
+ io.puts 'Available options:'
179
+ io.puts '1. Split transaction between multiple categories'
180
+ io.puts '2. Create new category'
181
+
182
+ all_cats.each_with_index do |category, index|
183
+ option_number = index + 3
184
+ ai_indicator = option_number == ai_option_number ? ' (AI Suggested)' : ''
185
+ io.puts "#{option_number}. #{category}#{ai_indicator}"
186
+ end
187
+
188
+ if @options[:use_ai_suggestions] && !auto_apply_ai
189
+ io.puts 'A. Auto-apply AI suggestions for all remaining transactions'
190
+ end
191
+
192
+ # Get the user's selection
193
+ selected_option = nil
194
+
195
+ if auto_apply_ai && ai_option_number
196
+ selected_option = ai_option_number
197
+ io.puts "Auto-applying AI suggestion: #{ai_suggestion[:category]}"
198
+ elsif responses && !responses.empty?
199
+ # Use the next saved response
200
+ selected_option = responses.shift
201
+ io.puts "Using saved response: #{selected_option}"
202
+ else
203
+ # Prompt the user for input
204
+ io.print "Select option (1-#{all_cats.size + 2})"
205
+ io.print "[#{ai_option_number}]" if @options[:use_ai_suggestions] && !auto_apply_ai && ai_option_number
206
+ io.print ': '
207
+
208
+ user_input = io.gets.chomp
209
+
210
+ # Save the response if requested
211
+ saved_responses << user_input if saved_responses
212
+
213
+ # Check if the user wants to auto-apply AI suggestions
214
+ if user_input.downcase == 'a' && @options[:use_ai_suggestions] && !auto_apply_ai
215
+ return 'A' # Return 'A' for backward compatibility with tests
216
+ end
217
+
218
+ selected_option = user_input.to_i
219
+ end
220
+
221
+ # Process the user's selection
222
+ if selected_option == 1
223
+ # Split transaction between multiple categories
224
+ io.print 'Enter categories and amounts (category1:amount1,category2:amount2,...): '
225
+ split_input = nil
226
+
227
+ if responses && !responses.empty?
228
+ split_input = responses.shift
229
+ io.puts "Using saved response: #{split_input}"
230
+ else
231
+ split_input = io.gets.chomp
232
+ saved_responses << split_input if saved_responses
233
+ end
234
+
235
+ reverse_transaction = code_transaction(transaction, selected_option, split_input)
236
+ elsif selected_option == 2
237
+ # Create new category
238
+ io.print 'Enter new category: '
239
+ new_category = nil
240
+
241
+ if responses && !responses.empty?
242
+ new_category = responses.shift
243
+ io.puts "Using saved response: #{new_category}"
244
+ else
245
+ new_category = io.gets.chomp
246
+ saved_responses << new_category if saved_responses
247
+ end
248
+
249
+ reverse_transaction = code_transaction(transaction, selected_option, nil, new_category)
250
+ elsif selected_option >= 3 && selected_option <= all_cats.size + 2
251
+ # Select an existing category
252
+ category = all_cats[selected_option - 3]
253
+
254
+ # If this was an AI suggestion and the user selected it, ask for feedback if it was wrong
255
+ feedback = nil
256
+ if ai_suggestion && selected_option != ai_option_number
257
+ io.print 'Provide a reason why the AI was wrong: '
258
+
259
+ if responses && !responses.empty?
260
+ feedback = responses.shift
261
+ io.puts "Using saved response: #{feedback}"
262
+ else
263
+ feedback = io.gets.chomp
264
+ saved_responses << feedback if saved_responses
265
+ end
266
+
267
+ # Add the feedback as a hint to the transaction
268
+ transaction.add_hint(feedback) if feedback && !feedback.empty?
269
+ end
270
+
271
+ reverse_transaction = code_transaction(transaction, selected_option, nil, nil)
272
+
273
+ # Save the transaction
274
+ save_transaction(reverse_transaction, transaction, selected_option, feedback)
275
+ else
276
+ io.puts 'Invalid option. Please try again.'
277
+ return code_transaction_interactively(transaction, responses, saved_responses, auto_apply_ai, io)
278
+ end
279
+
280
+ # Append to coding.ledger
281
+ if reverse_transaction
282
+ save_transaction(reverse_transaction, transaction, selected_option)
283
+ io.puts 'Transaction coded and saved to coding.ledger.'
284
+ else
285
+ @logger&.error('Failed to code transaction')
286
+ io.puts 'Failed to code transaction.'
287
+ end
288
+
289
+ io.puts ''
290
+
291
+ # Return the selected option for backward compatibility with tests
292
+ selected_option.is_a?(Integer) ? selected_option.to_s : selected_option
293
+ end
294
+
295
+ def process_reconcile_file(uncoded_transactions, reconcile_file)
296
+ # Read the reconciliation file
297
+ reconcile_data = File.readlines(reconcile_file).map(&:strip)
298
+
299
+ # Process each line in the reconciliation file
300
+ reconcile_data.each do |line|
301
+ next if line.empty? || line.start_with?('#')
302
+
303
+ # Parse the line
304
+ parts = line.split(',')
305
+ transaction_id = parts[0].strip
306
+ category = parts[1].strip
307
+
308
+ # Find the transaction
309
+ transaction = uncoded_transactions.find { |t| t.transaction_id == transaction_id }
310
+ next unless transaction
311
+
312
+ # Check if the transaction has an unknown category
313
+ unknown_categories = ['Income:Unknown', 'Expenses:Unknown']
314
+ unknown_category = unknown_categories.find do |cat|
315
+ transaction.from_account == cat || transaction.to_account == cat
316
+ end
317
+ next unless unknown_category
318
+
319
+ # Create the categories hash with a single category
320
+ new_categories = { category => transaction.amount }
321
+
322
+ # Create the reverse transaction
323
+ begin
324
+ reverse_transaction = transaction.create_reverse_transaction(new_categories)
325
+
326
+ # Append to coding.ledger
327
+ File.open('coding.ledger', 'a') do |file|
328
+ file.puts reverse_transaction.to_ledger
329
+ end
330
+
331
+ puts "Coded transaction #{transaction_id} with category #{category}"
332
+ rescue StandardError => e
333
+ puts "Error coding transaction #{transaction_id}: #{e.message}"
334
+ end
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,3 @@
1
+ module Teri
2
+ VERSION = '0.5.1'.freeze
3
+ end
data/lib/teri.rb ADDED
@@ -0,0 +1,10 @@
1
+ require 'teri/version'
2
+ require 'teri/transaction'
3
+ require 'teri/ledger'
4
+ require 'teri/cli'
5
+ require 'teri/accounting'
6
+
7
+ module Teri
8
+ class Error < StandardError; end
9
+ # Your code goes here...
10
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: teri
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.1
5
+ platform: ruby
6
+ authors:
7
+ - Jonathan Siegel
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-03-11 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby-openai
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '6.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '6.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: thor
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: tty-prompt
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.23'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.23'
54
+ description: A terminal interface for ledger-cli that makes it easier to code transactions
55
+ and reconcile accounts
56
+ email:
57
+ - usiegj00@github.com
58
+ executables:
59
+ - console
60
+ - setup
61
+ - teri
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - LICENSE.txt
66
+ - README.md
67
+ - bin/console
68
+ - bin/setup
69
+ - bin/teri
70
+ - lib/teri.rb
71
+ - lib/teri/accounting.rb
72
+ - lib/teri/ai_integration.rb
73
+ - lib/teri/category_manager.rb
74
+ - lib/teri/cli.rb
75
+ - lib/teri/ledger.rb
76
+ - lib/teri/openai_client.rb
77
+ - lib/teri/report_generator.rb
78
+ - lib/teri/transaction.rb
79
+ - lib/teri/transaction_coder.rb
80
+ - lib/teri/version.rb
81
+ homepage: https://github.com/usiegj00/teri
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ homepage_uri: https://github.com/usiegj00/teri
86
+ rubygems_mfa_required: 'true'
87
+ source_code_uri: https://github.com/usiegj00/teri
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 3.3.0
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.6.5
103
+ specification_version: 4
104
+ summary: Terminal interface for ledger-cli
105
+ test_files: []