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,229 @@
1
+ require 'openai'
2
+ require 'logger'
3
+ require 'fileutils'
4
+
5
+ module Teri
6
+ class OpenAIClient
7
+ attr_reader :client, :logger, :model
8
+
9
+ def initialize(options = {})
10
+ @api_key = options[:api_key] || ENV.fetch('OPENAI_API_KEY', nil)
11
+ unless @api_key
12
+ raise 'OpenAI API key not found. Please set OPENAI_API_KEY environment variable or provide it as an argument.'
13
+ end
14
+
15
+ @model = options[:model] || 'gpt-3.5-turbo'
16
+ @client = OpenAI::Client.new(access_token: @api_key)
17
+
18
+ # Set up logging
19
+ setup_logger(options[:log_file])
20
+ end
21
+
22
+ # Get a suggested category for a transaction based on its details and previous codings
23
+ # @param transaction [Teri::Transaction] The transaction to get a suggestion for
24
+ # @param accounting [Teri::Accounting] The accounting instance with previous codings
25
+ # @return [Hash] A hash containing the suggested category and confidence score
26
+ def suggest_category(transaction, accounting)
27
+ previous_codings = accounting.respond_to?(:previous_codings) ? accounting.previous_codings : {}
28
+ counterparty_hints = accounting.respond_to?(:counterparty_hints) ? accounting.counterparty_hints : {}
29
+ available_categories = []
30
+
31
+ # Prepare the prompt with transaction details
32
+ prompt = build_prompt(transaction, accounting)
33
+
34
+ # Log the previous_codings for debugging
35
+ if @logger
36
+ @logger.info("PREVIOUS_CODINGS: #{previous_codings.inspect}")
37
+ @logger.info("COUNTERPARTY_HINTS: #{counterparty_hints.inspect}")
38
+ end
39
+
40
+ # Log the prompt
41
+ @logger&.info("PROMPT: #{prompt}")
42
+
43
+ # Call the OpenAI API
44
+ response = @client.chat(
45
+ parameters: {
46
+ model: @model,
47
+ messages: [
48
+ { role: 'user', content: prompt },
49
+ ],
50
+ }
51
+ )
52
+
53
+ # Log the response
54
+ @logger&.info("RESPONSE: #{response.dig('choices', 0, 'message', 'content')}")
55
+
56
+ # Parse the response
57
+ parse_suggestion(response, available_categories)
58
+ end
59
+
60
+ # Build a prompt for the OpenAI API
61
+ # @param transaction [Teri::Transaction] The transaction to get a suggestion for
62
+ # @param accounting [Teri::Accounting] The accounting instance with previous codings
63
+ # @return [String] The prompt for the OpenAI API
64
+ def build_prompt(transaction, accounting)
65
+ prompt = "Transaction details:\n"
66
+ prompt += "Date: #{transaction.date}\n"
67
+ prompt += "Description: #{transaction.description}\n"
68
+
69
+ prompt += "Memo: #{transaction.memo}\n" if transaction.respond_to?(:memo) && transaction.memo
70
+
71
+ prompt += "Amount: #{transaction.amount}\n" if transaction.respond_to?(:amount)
72
+
73
+ if transaction.respond_to?(:counterparty) && transaction.counterparty
74
+ prompt += "Counterparty: #{transaction.counterparty}\n"
75
+ end
76
+
77
+ if transaction.respond_to?(:hints) && transaction.hints && !transaction.hints.empty?
78
+ prompt += "\nHints from previous categorizations:\n"
79
+ transaction.hints.each do |hint|
80
+ prompt += "- #{hint}\n"
81
+ end
82
+ end
83
+
84
+ # Add previous codings section if available
85
+ if accounting.respond_to?(:previous_codings) && accounting.previous_codings && !accounting.previous_codings.empty?
86
+ prompt += "\nPrevious codings:\n"
87
+ accounting.previous_codings.each do |desc, info|
88
+ next unless info.is_a?(Hash)
89
+
90
+ prompt += if info[:hints] && !info[:hints].empty?
91
+ "- \"#{desc}\" => #{info[:category]} (hints: #{info[:hints].join(', ')})\n"
92
+ else
93
+ "- \"#{desc}\" => #{info[:category]}\n"
94
+ end
95
+ end
96
+ end
97
+
98
+ # Add counterparty hints if available
99
+ if accounting.respond_to?(:counterparty_hints) &&
100
+ transaction.respond_to?(:counterparty) &&
101
+ transaction.counterparty &&
102
+ accounting.counterparty_hints[transaction.counterparty]
103
+ prompt += "\nCounterparty information:\n"
104
+ accounting.counterparty_hints[transaction.counterparty].each do |hint|
105
+ prompt += "- #{hint}\n"
106
+ end
107
+ end
108
+
109
+ prompt += "\nPlease respond with a JSON object containing:\n"
110
+ prompt += "- category: The suggested category (e.g., 'Expenses:Office')\n"
111
+ prompt += "- confidence: A number between 0-100 indicating your confidence\n"
112
+ prompt += "- explanation: A brief explanation of your suggestion\n"
113
+
114
+ prompt
115
+ end
116
+
117
+ private
118
+
119
+ # Set up the logger
120
+ # @param log_file [String] The path to the log file
121
+ def setup_logger(log_file = nil)
122
+ # Skip logger setup if we're in a test environment
123
+ if defined?(RSpec)
124
+ @logger = nil
125
+ return
126
+ end
127
+
128
+ begin
129
+ # Create logs directory if it doesn't exist
130
+ FileUtils.mkdir_p('logs')
131
+
132
+ # Use the provided log file or create a default one with timestamp
133
+ log_file ||= "logs/openai_#{Time.now.strftime('%Y%m%d_%H%M%S')}.log"
134
+
135
+ @logger = Logger.new(log_file)
136
+ @logger.level = Logger::INFO
137
+ @logger.formatter = proc do |severity, datetime, _progname, msg|
138
+ "#{datetime.strftime('%Y-%m-%d %H:%M:%S')} [#{severity}] #{msg}\n"
139
+ end
140
+
141
+ @logger.info('OpenAI client initialized')
142
+ rescue StandardError => e
143
+ puts "Warning: Failed to set up OpenAI logging: #{e.message}"
144
+ @logger = nil
145
+ end
146
+ end
147
+
148
+ # Parse the suggestion from the OpenAI API response
149
+ # @param response [Hash] The response from the OpenAI API
150
+ # @param available_categories [Array<String>] A list of available categories
151
+ # @return [Hash] A hash containing the suggested category and confidence score
152
+ def parse_suggestion(response, available_categories = [])
153
+ content = response.dig('choices', 0, 'message', 'content')
154
+ return { category: 'Expenses:Unknown', confidence: 0, explanation: 'Failed to parse AI response' } unless content
155
+
156
+ begin
157
+ # Parse the JSON response
158
+ suggestion = JSON.parse(content, symbolize_names: true)
159
+
160
+ # Set default values if missing
161
+ suggestion[:category] ||= 'Expenses:Unknown'
162
+ suggestion[:confidence] ||= 0
163
+ suggestion[:explanation] ||= 'No explanation provided'
164
+
165
+ # Convert confidence to a float between 0 and 1 if it's a percentage
166
+ if suggestion[:confidence].is_a?(Numeric) && suggestion[:confidence] > 1
167
+ suggestion[:confidence] = suggestion[:confidence].to_f / 100.0
168
+ end
169
+
170
+ # Find the closest match if the category is not in the available categories
171
+ if !available_categories.empty? && !available_categories.include?(suggestion[:category])
172
+ original_category = suggestion[:category]
173
+ suggestion[:category] = find_closest_match(original_category, available_categories)
174
+ suggestion[:explanation] += " (Adjusted from '#{original_category}' to closest match '#{suggestion[:category]}')"
175
+ end
176
+
177
+ suggestion
178
+ rescue JSON::ParserError
179
+ { category: 'Expenses:Unknown', confidence: 0, explanation: 'Failed to parse AI response' }
180
+ end
181
+ end
182
+
183
+ # Find the closest match for a category in the available categories
184
+ # @param category [String] The category to find a match for
185
+ # @param available_categories [Array<String>] A list of available categories
186
+ # @return [String] The closest matching category
187
+ def find_closest_match(category, available_categories)
188
+ return category if available_categories.empty?
189
+
190
+ # Find the category with the smallest Levenshtein distance
191
+ closest_match = available_categories.min_by do |available_category|
192
+ levenshtein_distance(category, available_category)
193
+ end
194
+
195
+ closest_match || category
196
+ end
197
+
198
+ # Calculate the Levenshtein distance between two strings
199
+ # @param str1 [String] The first string
200
+ # @param str2 [String] The second string
201
+ # @return [Integer] The Levenshtein distance
202
+ def levenshtein_distance(str1, str2)
203
+ m = str1.length
204
+ n = str2.length
205
+
206
+ # Create a matrix of size (m+1) x (n+1)
207
+ d = Array.new(m + 1) { Array.new(n + 1, 0) }
208
+
209
+ # Initialize the first row and column
210
+ (0..m).each { |i| d[i][0] = i }
211
+ (0..n).each { |j| d[0][j] = j }
212
+
213
+ # Fill in the rest of the matrix
214
+ (1..m).each do |i|
215
+ (1..n).each do |j|
216
+ cost = str1[i - 1] == str2[j - 1] ? 0 : 1
217
+ d[i][j] = [
218
+ d[i - 1][j] + 1, # deletion
219
+ d[i][j - 1] + 1, # insertion
220
+ d[i - 1][j - 1] + cost, # substitution
221
+ ].min
222
+ end
223
+ end
224
+
225
+ # Return the distance
226
+ d[m][n]
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,258 @@
1
+ require 'English'
2
+ require 'date'
3
+
4
+ module Teri
5
+ # Handles report generation for balance sheets and income statements
6
+ class ReportGenerator
7
+ def initialize(options, logger)
8
+ @options = options
9
+ @logger = logger
10
+ end
11
+
12
+ def generate_balance_sheet(options = {})
13
+ # Merge provided options with default options
14
+ opts = @options.merge(options)
15
+
16
+ # Get the specified year from options, defaulting to current year
17
+ specified_year = opts[:year] || Date.today.year
18
+
19
+ # Get the number of periods from options, defaulting to 2 if not specified
20
+ periods = opts[:periods] || 2
21
+
22
+ puts "Balance Sheet for #{specified_year}"
23
+ if opts[:month]
24
+ puts "Month: #{opts[:month]}"
25
+ else
26
+ puts "Including previous #{periods} years"
27
+ end
28
+ puts '=' * 50
29
+ puts ''
30
+
31
+ # Check if coding.ledger exists
32
+ unless File.exist?('coding.ledger')
33
+ puts "Warning: coding.ledger file does not exist. Please run 'teri code' first to process and code your transactions."
34
+ puts 'Without coding, the balance sheet cannot be generated correctly.'
35
+ return
36
+ end
37
+
38
+ # Common ledger options for consistent formatting
39
+ ledger_options = '--exchange USD --no-total --collapse'
40
+
41
+ # If a specific month is provided, show just that month
42
+ if opts[:month]
43
+ month = opts[:month].to_i
44
+ end_date = Date.new(specified_year, month, -1) # Last day of the month
45
+
46
+ puts "Balance Sheet as of #{end_date.strftime('%Y-%m-%d')}"
47
+ puts '-' * 50
48
+
49
+ cmd = 'ledger -f coding.ledger'
50
+ Dir.glob('transactions/*.ledger').each do |file|
51
+ cmd += " -f #{file}"
52
+ end
53
+ cmd += " balance #{ledger_options} --end #{end_date.strftime('%Y/%m/%d')} ^Assets ^Liabilities ^Equity"
54
+
55
+ output = `#{cmd}`
56
+ if $?.success?
57
+ puts output
58
+
59
+ # Check if the balance sheet is balanced
60
+ if balance_sheet_unbalanced?(output)
61
+ puts "Note: The balance sheet is not balanced. You may want to run 'teri fix-balance' to create adjustment entries."
62
+ end
63
+ else
64
+ puts "Error generating balance sheet (exit code: #{$?.exitstatus})"
65
+ puts "Command was: #{cmd}"
66
+ end
67
+
68
+ return
69
+ end
70
+
71
+ # Generate balance sheet for the specified year and previous periods
72
+ start_year = specified_year - periods
73
+ end_year = specified_year - 1
74
+
75
+ # Show previous years
76
+ (start_year..end_year).each do |year|
77
+ puts "Balance Sheet as of #{year}-12-31"
78
+ puts '-' * 50
79
+
80
+ # Use the --file option to specify files instead of a glob pattern
81
+ cmd = 'ledger -f coding.ledger'
82
+ Dir.glob('transactions/*.ledger').each do |file|
83
+ cmd += " -f #{file}"
84
+ end
85
+ cmd += " balance #{ledger_options} --end #{year}/12/31 ^Assets ^Liabilities ^Equity"
86
+
87
+ output = `#{cmd}`
88
+ if $?.success?
89
+ puts output
90
+
91
+ # Check if the balance sheet is balanced
92
+ if balance_sheet_unbalanced?(output)
93
+ puts "Note: The balance sheet is not balanced. You may want to run 'teri fix-balance' to create adjustment entries."
94
+ end
95
+ else
96
+ puts "Error generating balance sheet (exit code: #{$?.exitstatus})"
97
+ puts "Command was: #{cmd}"
98
+ end
99
+ end
100
+
101
+ # Show current balance sheet for specified year
102
+ current_date = Date.today
103
+ if specified_year == current_date.year
104
+ # If specified year is current year, show as of today
105
+ puts "Balance Sheet as of #{current_date.strftime('%Y-%m-%d')}"
106
+ end_date = current_date
107
+ else
108
+ # If specified year is not current year, show as of year end
109
+ puts "Balance Sheet as of #{specified_year}-12-31"
110
+ end_date = Date.new(specified_year, 12, 31)
111
+ end
112
+ puts '-' * 50
113
+
114
+ cmd = 'ledger -f coding.ledger'
115
+ Dir.glob('transactions/*.ledger').each do |file|
116
+ cmd += " -f #{file}"
117
+ end
118
+ cmd += " balance #{ledger_options} --end #{end_date.strftime('%Y/%m/%d')} ^Assets ^Liabilities ^Equity"
119
+
120
+ output = `#{cmd}`
121
+ if $?.success?
122
+ puts output
123
+
124
+ # Check if the balance sheet is balanced
125
+ if balance_sheet_unbalanced?(output)
126
+ puts "Note: The balance sheet is not balanced. You may want to run 'teri fix-balance' to create adjustment entries."
127
+ end
128
+ else
129
+ puts "Error generating balance sheet (exit code: #{$?.exitstatus})"
130
+ puts "Command was: #{cmd}"
131
+ end
132
+ end
133
+
134
+ # Helper method to check if a balance sheet is unbalanced
135
+ def balance_sheet_unbalanced?(output)
136
+ # With --no-total option, we need to calculate the total ourselves
137
+ assets = 0.0
138
+ liabilities = 0.0
139
+ equity = 0.0
140
+
141
+ # Extract amounts for each section
142
+ output.each_line do |line|
143
+ case line
144
+ when /^\s*([\-\$\d,\.]+)\s+USD\s+Assets/
145
+ assets_str = ::Regexp.last_match(1).gsub(/[\$,]/, '')
146
+ assets = assets_str.to_f
147
+ when /^\s*([\-\$\d,\.]+)\s+USD\s+Liabilities/
148
+ liabilities_str = ::Regexp.last_match(1).gsub(/[\$,]/, '')
149
+ liabilities = liabilities_str.to_f
150
+ when /^\s*([\-\$\d,\.]+)\s+USD\s+Equity/
151
+ equity_str = ::Regexp.last_match(1).gsub(/[\$,]/, '')
152
+ equity = equity_str.to_f
153
+ end
154
+ end
155
+
156
+ # Calculate the balance (Assets = Liabilities + Equity)
157
+ balance = assets - (liabilities + equity)
158
+
159
+ # Consider it balanced if the difference is less than 1 cent
160
+ balance.abs > 0.01
161
+ end
162
+
163
+ def generate_income_statement(options = {})
164
+ # Merge provided options with default options
165
+ opts = @options.merge(options)
166
+
167
+ # Get the specified year from options, defaulting to current year
168
+ specified_year = opts[:year] || Date.today.year
169
+
170
+ # Get the number of periods from options, defaulting to 2 if not specified
171
+ periods = opts[:periods] || 2
172
+
173
+ puts "Income Statement for #{specified_year}"
174
+ if opts[:month]
175
+ puts "Month: #{opts[:month]}"
176
+ else
177
+ puts "Including previous #{periods} years"
178
+ end
179
+ puts '=' * 50
180
+ puts ''
181
+
182
+ # Check if coding.ledger exists
183
+ unless File.exist?('coding.ledger')
184
+ puts "Warning: coding.ledger file does not exist. Please run 'teri code' first to process and code your transactions."
185
+ puts 'Without coding, the income statement cannot be generated correctly.'
186
+ return
187
+ end
188
+
189
+ # Common ledger options for consistent formatting
190
+ ledger_options = '--exchange USD --no-total --collapse'
191
+
192
+ # If a specific month is provided, show just that month
193
+ if opts[:month]
194
+ month = opts[:month].to_i
195
+ start_date = Date.new(specified_year, month, 1)
196
+ end_date = Date.new(specified_year, month, -1) # Last day of the month
197
+
198
+ puts "Income Statement for #{start_date.strftime('%B %Y')}"
199
+ puts '-' * 50
200
+
201
+ cmd = 'ledger -f coding.ledger'
202
+ Dir.glob('transactions/*.ledger').each do |file|
203
+ cmd += " -f #{file}"
204
+ end
205
+ cmd += " balance #{ledger_options} --begin #{start_date.strftime('%Y/%m/%d')} --end #{end_date.strftime('%Y/%m/%d')} ^Income ^Expenses"
206
+
207
+ else
208
+ # Generate income statement for the specified year and previous periods
209
+ start_year = specified_year - periods
210
+ end_year = specified_year - 1
211
+
212
+ # Show previous years
213
+ (start_year..end_year).each do |year|
214
+ puts "Income Statement for #{year}"
215
+ puts '-' * 50
216
+
217
+ cmd = 'ledger -f coding.ledger'
218
+ Dir.glob('transactions/*.ledger').each do |file|
219
+ cmd += " -f #{file}"
220
+ end
221
+ cmd += " balance #{ledger_options} --begin #{year}/01/01 --end #{year}/12/31 ^Income ^Expenses"
222
+
223
+ output = `#{cmd}`
224
+ if $?.success?
225
+ puts output
226
+ else
227
+ puts "Error generating income statement (exit code: #{$?.exitstatus})"
228
+ puts "Command was: #{cmd}"
229
+ end
230
+ end
231
+
232
+ # Show current year income statement
233
+ current_date = Date.today
234
+ if specified_year == current_date.year
235
+ # If specified year is current year, show as of today
236
+ puts "Income Statement as of #{current_date.strftime('%Y-%m-%d')}"
237
+ else
238
+ # If specified year is not current year, show as of year end
239
+ puts "Income Statement as of #{specified_year}-12-31"
240
+ end
241
+
242
+ cmd = 'ledger -f coding.ledger'
243
+ Dir.glob('transactions/*.ledger').each do |file|
244
+ cmd += " -f #{file}"
245
+ end
246
+ cmd += " balance #{ledger_options} --begin #{specified_year}/01/01 --end #{specified_year}/12/31 ^Income ^Expenses"
247
+ end
248
+
249
+ output = `#{cmd}`
250
+ if $?.success?
251
+ puts output
252
+ else
253
+ puts "Error generating income statement (exit code: #{$?.exitstatus})"
254
+ puts "Command was: #{cmd}"
255
+ end
256
+ end
257
+ end
258
+ end