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.
- checksums.yaml +7 -0
- data/LICENSE.txt +25 -0
- data/README.md +285 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/bin/teri +5 -0
- data/lib/teri/accounting.rb +310 -0
- data/lib/teri/ai_integration.rb +109 -0
- data/lib/teri/category_manager.rb +92 -0
- data/lib/teri/cli.rb +71 -0
- data/lib/teri/ledger.rb +332 -0
- data/lib/teri/openai_client.rb +229 -0
- data/lib/teri/report_generator.rb +258 -0
- data/lib/teri/transaction.rb +399 -0
- data/lib/teri/transaction_coder.rb +338 -0
- data/lib/teri/version.rb +3 -0
- data/lib/teri.rb +10 -0
- metadata +105 -0
@@ -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
|
data/lib/teri/version.rb
ADDED
data/lib/teri.rb
ADDED
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: []
|