mathpix 0.1.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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE +21 -0
  4. data/README.md +171 -0
  5. data/SECURITY.md +137 -0
  6. data/lib/mathpix/balanced_ternary.rb +86 -0
  7. data/lib/mathpix/batch.rb +155 -0
  8. data/lib/mathpix/capture_builder.rb +142 -0
  9. data/lib/mathpix/chemistry.rb +69 -0
  10. data/lib/mathpix/client.rb +439 -0
  11. data/lib/mathpix/configuration.rb +187 -0
  12. data/lib/mathpix/configuration.rb.backup +125 -0
  13. data/lib/mathpix/conversion.rb +257 -0
  14. data/lib/mathpix/document.rb +320 -0
  15. data/lib/mathpix/errors.rb +78 -0
  16. data/lib/mathpix/mcp/auth/oauth_provider.rb +346 -0
  17. data/lib/mathpix/mcp/auth/token_manager.rb +31 -0
  18. data/lib/mathpix/mcp/auth.rb +18 -0
  19. data/lib/mathpix/mcp/base_tool.rb +117 -0
  20. data/lib/mathpix/mcp/elicitations/ambiguity_elicitation.rb +162 -0
  21. data/lib/mathpix/mcp/elicitations/base_elicitation.rb +141 -0
  22. data/lib/mathpix/mcp/elicitations/confidence_elicitation.rb +162 -0
  23. data/lib/mathpix/mcp/elicitations.rb +78 -0
  24. data/lib/mathpix/mcp/middleware/cors_middleware.rb +94 -0
  25. data/lib/mathpix/mcp/middleware/oauth_middleware.rb +72 -0
  26. data/lib/mathpix/mcp/middleware/rate_limiting_middleware.rb +140 -0
  27. data/lib/mathpix/mcp/middleware.rb +13 -0
  28. data/lib/mathpix/mcp/resources/formats_list_resource.rb +113 -0
  29. data/lib/mathpix/mcp/resources/hierarchical_router.rb +237 -0
  30. data/lib/mathpix/mcp/resources/latest_snip_resource.rb +60 -0
  31. data/lib/mathpix/mcp/resources/recent_snips_resource.rb +75 -0
  32. data/lib/mathpix/mcp/resources/snip_stats_resource.rb +78 -0
  33. data/lib/mathpix/mcp/resources.rb +15 -0
  34. data/lib/mathpix/mcp/server.rb +174 -0
  35. data/lib/mathpix/mcp/tools/batch_convert_tool.rb +106 -0
  36. data/lib/mathpix/mcp/tools/check_document_status_tool.rb +66 -0
  37. data/lib/mathpix/mcp/tools/convert_document_tool.rb +90 -0
  38. data/lib/mathpix/mcp/tools/convert_image_tool.rb +91 -0
  39. data/lib/mathpix/mcp/tools/convert_strokes_tool.rb +82 -0
  40. data/lib/mathpix/mcp/tools/get_account_info_tool.rb +57 -0
  41. data/lib/mathpix/mcp/tools/get_usage_tool.rb +62 -0
  42. data/lib/mathpix/mcp/tools/list_formats_tool.rb +81 -0
  43. data/lib/mathpix/mcp/tools/search_results_tool.rb +111 -0
  44. data/lib/mathpix/mcp/transports/http_streaming_transport.rb +622 -0
  45. data/lib/mathpix/mcp/transports/sse_stream_handler.rb +236 -0
  46. data/lib/mathpix/mcp/transports.rb +12 -0
  47. data/lib/mathpix/mcp.rb +52 -0
  48. data/lib/mathpix/result.rb +364 -0
  49. data/lib/mathpix/version.rb +22 -0
  50. data/lib/mathpix.rb +229 -0
  51. metadata +283 -0
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'json'
5
+ require 'base64'
6
+ require 'openssl'
7
+ require 'digest'
8
+
9
+ module Mathpix
10
+ module MCP
11
+ module Auth
12
+ # OAuth 2.0 Provider implementation
13
+ class OAuthProvider
14
+ AUTHORIZATION_CODE_EXPIRY = 600 # 10 minutes
15
+ ACCESS_TOKEN_EXPIRY = 3600 # 1 hour
16
+ REFRESH_TOKEN_EXPIRY = 2_592_000 # 30 days
17
+
18
+ def initialize
19
+ @clients = {}
20
+ @authorization_codes = {}
21
+ @access_tokens = {}
22
+ @refresh_tokens = {}
23
+ @revoked_tokens = {}
24
+ @jwt_secret = ENV['JWT_SECRET'] || SecureRandom.hex(32)
25
+ end
26
+
27
+ def register_client(client_id:, client_secret:, redirect_uri:, name: nil)
28
+ raise ArgumentError, 'client_id required' if client_id.nil?
29
+ raise ArgumentError, 'redirect_uri required' if redirect_uri.nil?
30
+
31
+ @clients[client_id] = {
32
+ client_secret: client_secret,
33
+ redirect_uri: redirect_uri,
34
+ name: name
35
+ }
36
+ end
37
+
38
+ def client_exists?(client_id)
39
+ @clients.key?(client_id)
40
+ end
41
+
42
+ def get_authorization(code)
43
+ @authorization_codes[code]
44
+ end
45
+
46
+ # Authorization code grant flow
47
+ def authorize(client_id:, redirect_uri:, scope:, user_id:, state: nil, code_challenge: nil, code_challenge_method: nil)
48
+ raise InvalidClientError, 'Unknown client' unless @clients.key?(client_id)
49
+
50
+ client = @clients[client_id]
51
+ raise InvalidRedirectUriError, 'Mismatched redirect_uri' unless client[:redirect_uri] == redirect_uri
52
+
53
+ code = SecureRandom.hex(32)
54
+
55
+ @authorization_codes[code] = {
56
+ client_id: client_id,
57
+ redirect_uri: redirect_uri,
58
+ scope: scope,
59
+ user_id: user_id,
60
+ state: state,
61
+ code_challenge: code_challenge,
62
+ code_challenge_method: code_challenge_method,
63
+ expires_at: Time.now + AUTHORIZATION_CODE_EXPIRY,
64
+ used: false
65
+ }
66
+
67
+ code
68
+ end
69
+
70
+ def exchange_code(code:, client_id:, client_secret:, redirect_uri:, code_verifier: nil)
71
+ auth = @authorization_codes[code]
72
+
73
+ raise InvalidGrantError, 'Invalid authorization code' if auth.nil?
74
+ raise InvalidGrantError, 'Authorization code already used' if auth[:used]
75
+ raise InvalidGrantError, 'Authorization code expired' if Time.now > auth[:expires_at]
76
+ raise InvalidClientError, 'Invalid client' unless validate_client(client_id, client_secret)
77
+ raise InvalidGrantError, 'Mismatched redirect_uri' unless auth[:redirect_uri] == redirect_uri
78
+
79
+ # PKCE verification
80
+ if auth[:code_challenge]
81
+ raise InvalidGrantError, 'Code verifier required' if code_verifier.nil?
82
+ unless verify_pkce(code_verifier, auth[:code_challenge], auth[:code_challenge_method])
83
+ raise InvalidGrantError, 'Invalid code verifier'
84
+ end
85
+ end
86
+
87
+ # Mark code as used
88
+ auth[:used] = true
89
+
90
+ # Generate tokens
91
+ access_token = generate_access_token(
92
+ user_id: auth[:user_id],
93
+ client_id: client_id,
94
+ scope: auth[:scope]
95
+ )
96
+
97
+ refresh_token = generate_refresh_token(
98
+ user_id: auth[:user_id],
99
+ client_id: client_id,
100
+ scope: auth[:scope]
101
+ )
102
+
103
+ {
104
+ access_token: access_token,
105
+ token_type: 'Bearer',
106
+ expires_in: ACCESS_TOKEN_EXPIRY,
107
+ refresh_token: refresh_token,
108
+ scope: auth[:scope]
109
+ }
110
+ end
111
+
112
+ def refresh_token(refresh_token:, client_id:, client_secret:)
113
+ raise InvalidClientError, 'Invalid client' unless validate_client(client_id, client_secret)
114
+
115
+ token_data = @refresh_tokens[refresh_token]
116
+ raise InvalidGrantError, 'Invalid refresh token' if token_data.nil?
117
+ raise InvalidGrantError, 'Refresh token expired' if Time.now > token_data[:expires_at]
118
+ raise InvalidGrantError, 'Refresh token revoked' if @revoked_tokens.key?(refresh_token)
119
+
120
+ # Invalidate old refresh token (rotation)
121
+ @refresh_tokens.delete(refresh_token)
122
+ @revoked_tokens[refresh_token] = Time.now
123
+
124
+ # Generate new tokens
125
+ access_token = generate_access_token(
126
+ user_id: token_data[:user_id],
127
+ client_id: client_id,
128
+ scope: token_data[:scope]
129
+ )
130
+
131
+ new_refresh_token = generate_refresh_token(
132
+ user_id: token_data[:user_id],
133
+ client_id: client_id,
134
+ scope: token_data[:scope]
135
+ )
136
+
137
+ {
138
+ access_token: access_token,
139
+ token_type: 'Bearer',
140
+ expires_in: ACCESS_TOKEN_EXPIRY,
141
+ refresh_token: new_refresh_token,
142
+ scope: token_data[:scope]
143
+ }
144
+ end
145
+
146
+ def validate_token(token)
147
+ raise InvalidTokenError, 'Token revoked' if @revoked_tokens.key?(token)
148
+
149
+ payload = decode_jwt(token)
150
+ raise InvalidTokenError, 'Token expired' if Time.now.to_i > payload['exp']
151
+
152
+ payload
153
+ rescue OpenSSL::OpenSSLError, StandardError => e
154
+ raise InvalidTokenError, "Invalid token signature: #{e.message}"
155
+ end
156
+
157
+ def validate_scope(token_scope, required_scope)
158
+ token_scopes = token_scope.split
159
+ required_scopes = required_scope.split
160
+
161
+ # Admin scope grants all
162
+ return true if token_scopes.include?('admin')
163
+
164
+ # Check if all required scopes are present
165
+ required_scopes.all? { |scope| token_scopes.include?(scope) }
166
+ end
167
+
168
+ def introspect_token(token, client_id: nil, client_secret: nil)
169
+ # Validate client if credentials provided
170
+ if client_id && client_secret
171
+ raise InvalidClientError, 'Invalid client' unless validate_client(client_id, client_secret)
172
+ end
173
+
174
+ begin
175
+ payload = validate_token(token)
176
+
177
+ {
178
+ active: true,
179
+ scope: payload['scope'],
180
+ client_id: payload['client_id'],
181
+ exp: payload['exp']
182
+ }
183
+ rescue InvalidTokenError
184
+ { active: false }
185
+ end
186
+ end
187
+
188
+ def revoke_token(token, client_id: nil, client_secret: nil)
189
+ # Validate client if credentials provided
190
+ if client_id && client_secret
191
+ raise InvalidClientError, 'Invalid client' unless validate_client(client_id, client_secret)
192
+ end
193
+
194
+ @revoked_tokens[token] = Time.now
195
+
196
+ # If it's a refresh token, also revoke associated access tokens
197
+ if @refresh_tokens[token]
198
+ token_data = @refresh_tokens[token]
199
+ @refresh_tokens.delete(token)
200
+
201
+ # Revoke all access tokens for this user/client combination
202
+ @access_tokens.select do |access_token, data|
203
+ data[:user_id] == token_data[:user_id] && data[:client_id] == token_data[:client_id]
204
+ end.each_key do |access_token|
205
+ @revoked_tokens[access_token] = Time.now
206
+ end
207
+ end
208
+
209
+ true
210
+ end
211
+
212
+ def client_credentials(client_id:, client_secret:, scope:)
213
+ raise InvalidClientError, 'Invalid client' unless validate_client(client_id, client_secret)
214
+
215
+ access_token = generate_access_token(
216
+ user_id: nil, # No user for client credentials
217
+ client_id: client_id,
218
+ scope: scope
219
+ )
220
+
221
+ {
222
+ access_token: access_token,
223
+ token_type: 'Bearer',
224
+ expires_in: ACCESS_TOKEN_EXPIRY
225
+ # No refresh token for client credentials flow
226
+ }
227
+ end
228
+ alias client_credentials_grant client_credentials
229
+
230
+ # Public test helpers (used by step definitions)
231
+ def store_authorization_code(code:, client_id:, redirect_uri:, scope:)
232
+ @authorization_codes[code] = {
233
+ client_id: client_id,
234
+ redirect_uri: redirect_uri,
235
+ scope: scope,
236
+ user_id: 'test_user',
237
+ expires_at: Time.now + AUTHORIZATION_CODE_EXPIRY,
238
+ used: false
239
+ }
240
+ end
241
+
242
+ def decode_token(token)
243
+ decode_jwt(token)
244
+ end
245
+
246
+ private
247
+
248
+ def validate_client(client_id, client_secret)
249
+ client = @clients[client_id]
250
+ return false if client.nil?
251
+
252
+ client[:client_secret] == client_secret
253
+ end
254
+
255
+ def generate_access_token(user_id:, client_id:, scope:)
256
+ now = Time.now.to_i
257
+
258
+ payload = {
259
+ user_id: user_id,
260
+ client_id: client_id,
261
+ scope: scope,
262
+ iat: now,
263
+ exp: now + ACCESS_TOKEN_EXPIRY,
264
+ aud: 'mathpix-mcp-server',
265
+ iss: 'mathpix-oauth'
266
+ }
267
+
268
+ token = encode_jwt(payload)
269
+
270
+ @access_tokens[token] = {
271
+ user_id: user_id,
272
+ client_id: client_id,
273
+ scope: scope,
274
+ expires_at: Time.at(now + ACCESS_TOKEN_EXPIRY)
275
+ }
276
+
277
+ token
278
+ end
279
+
280
+ def generate_refresh_token(user_id:, client_id:, scope:)
281
+ token = SecureRandom.hex(32)
282
+
283
+ @refresh_tokens[token] = {
284
+ user_id: user_id,
285
+ client_id: client_id,
286
+ scope: scope,
287
+ expires_at: Time.now + REFRESH_TOKEN_EXPIRY
288
+ }
289
+
290
+ token
291
+ end
292
+
293
+ def encode_jwt(payload)
294
+ header = {
295
+ alg: 'HS256',
296
+ typ: 'JWT'
297
+ }
298
+
299
+ header_encoded = base64url_encode(JSON.generate(header))
300
+ payload_encoded = base64url_encode(JSON.generate(payload))
301
+
302
+ signature_input = "#{header_encoded}.#{payload_encoded}"
303
+ signature = OpenSSL::HMAC.digest('SHA256', @jwt_secret, signature_input)
304
+ signature_encoded = base64url_encode(signature)
305
+
306
+ "#{header_encoded}.#{payload_encoded}.#{signature_encoded}"
307
+ end
308
+
309
+ def decode_jwt(token)
310
+ header_encoded, payload_encoded, signature_encoded = token.split('.')
311
+
312
+ # Verify signature
313
+ signature_input = "#{header_encoded}.#{payload_encoded}"
314
+ expected_signature = OpenSSL::HMAC.digest('SHA256', @jwt_secret, signature_input)
315
+ expected_signature_encoded = base64url_encode(expected_signature)
316
+
317
+ unless signature_encoded == expected_signature_encoded
318
+ raise InvalidTokenError, 'Invalid signature'
319
+ end
320
+
321
+ JSON.parse(base64url_decode(payload_encoded))
322
+ end
323
+
324
+ def verify_pkce(code_verifier, code_challenge, method)
325
+ case method
326
+ when 'S256'
327
+ computed = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
328
+ computed == code_challenge
329
+ when 'plain'
330
+ code_verifier == code_challenge
331
+ else
332
+ false
333
+ end
334
+ end
335
+
336
+ def base64url_encode(data)
337
+ Base64.urlsafe_encode64(data, padding: false)
338
+ end
339
+
340
+ def base64url_decode(data)
341
+ Base64.urlsafe_decode64(data)
342
+ end
343
+ end
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ module MCP
5
+ module Auth
6
+ # Token storage and management
7
+ class TokenManager
8
+ def initialize
9
+ @tokens = {}
10
+ end
11
+
12
+ def store_token(token, data)
13
+ @tokens[token] = data
14
+ end
15
+
16
+ def get_token(token)
17
+ @tokens[token]
18
+ end
19
+
20
+ def revoke_token(token)
21
+ @tokens.delete(token)
22
+ end
23
+
24
+ def cleanup_expired
25
+ now = Time.now
26
+ @tokens.reject! { |_, data| data[:expires_at] && data[:expires_at] < now }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ module MCP
5
+ module Auth
6
+ # Custom error classes
7
+ class AuthError < StandardError; end
8
+ class InvalidClientError < AuthError; end
9
+ class InvalidGrantError < AuthError; end
10
+ class InvalidTokenError < AuthError; end
11
+ class InvalidRedirectUriError < AuthError; end
12
+ class InsufficientScopeError < AuthError; end
13
+ end
14
+ end
15
+ end
16
+
17
+ require_relative 'auth/oauth_provider'
18
+ require_relative 'auth/token_manager'
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ module MCP
5
+ module Tools
6
+ # Base class for all Mathpix MCP tools
7
+ #
8
+ # Uses official Ruby MCP SDK (MCP::Tool)
9
+ # Provides common utilities for Mathpix-specific tools
10
+ #
11
+ # The geodesic path: official SDK base + Mathpix utilities
12
+ #
13
+ # @example Tool implementation
14
+ # class ExampleTool < BaseTool
15
+ # description "Example tool"
16
+ # input_schema(
17
+ # properties: { message: { type: "string" } },
18
+ # required: ["message"]
19
+ # )
20
+ #
21
+ # def self.call(message:, server_context:)
22
+ # client = server_context[:mathpix_client]
23
+ # # Use client to make API calls
24
+ # text_response("Result: #{message}")
25
+ # end
26
+ # end
27
+ class BaseTool < ::MCP::Tool
28
+ class << self
29
+ protected
30
+
31
+ # Get Mathpix client from server context
32
+ #
33
+ # @param server_context [Hash] MCP server context
34
+ # @return [Mathpix::Client] Mathpix API client
35
+ def mathpix_client(server_context)
36
+ server_context[:mathpix_client] || raise(ArgumentError, "mathpix_client not in server_context")
37
+ end
38
+
39
+ # Create text response (official MCP format)
40
+ #
41
+ # @param text [String] response text
42
+ # @return [::MCP::Tool::Response]
43
+ def text_response(text)
44
+ ::MCP::Tool::Response.new([{
45
+ type: "text",
46
+ text: text
47
+ }])
48
+ end
49
+
50
+ # Create JSON response with text wrapper
51
+ #
52
+ # @param data [Hash] JSON data
53
+ # @return [::MCP::Tool::Response]
54
+ def json_response(data)
55
+ text_response(JSON.pretty_generate(data))
56
+ end
57
+
58
+ # Create error response
59
+ #
60
+ # @param error [StandardError, String] error object or message
61
+ # @return [::MCP::Tool::Response]
62
+ def error_response(error)
63
+ message = error.is_a?(StandardError) ? error.message : error.to_s
64
+ details = error.is_a?(Mathpix::Error) ? error.details : {}
65
+
66
+ error_data = {
67
+ error: true,
68
+ message: message,
69
+ type: error.class.name
70
+ }
71
+ error_data[:details] = details unless details.empty?
72
+
73
+ json_response(error_data)
74
+ end
75
+
76
+ # Extract formats from arguments
77
+ #
78
+ # @param formats [Array, nil] format array
79
+ # @param client [Mathpix::Client] client for defaults
80
+ # @return [Array<Symbol>] format symbols
81
+ def extract_formats(formats, client)
82
+ return client.config.default_formats if formats.nil? || formats.empty?
83
+ Array(formats).map(&:to_sym)
84
+ end
85
+
86
+ # Normalize path (expand ~, resolve relative paths)
87
+ #
88
+ # @param path [String] file path
89
+ # @return [String] normalized path
90
+ def normalize_path(path)
91
+ File.expand_path(path) rescue path
92
+ end
93
+
94
+ # Check if path is a URL
95
+ #
96
+ # @param path [String] path or URL
97
+ # @return [Boolean]
98
+ def url?(path)
99
+ path.to_s.start_with?('http://', 'https://')
100
+ end
101
+
102
+ # Safe execute with error handling
103
+ #
104
+ # @yield Block to execute
105
+ # @return [::MCP::Tool::Response]
106
+ def safe_execute
107
+ yield
108
+ rescue Mathpix::Error => e
109
+ error_response(e)
110
+ rescue StandardError => e
111
+ error_response(e)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ module MCP
5
+ module Elicitations
6
+ # Ambiguity elicitation for mathematical notation disambiguation
7
+ #
8
+ # When OCR detects ambiguous symbols, prompts user to clarify
9
+ # E.g., "0" vs "O", "1" vs "l", partial fractions, etc.
10
+ #
11
+ # @example Ambiguous symbol
12
+ # elicitation = AmbiguityElicitation.new(
13
+ # ambiguous_text: "O",
14
+ # context: "x + O = 5",
15
+ # alternatives: ["0 (zero)", "O (letter O)"]
16
+ # )
17
+ # response = elicitation.request_clarification
18
+ #
19
+ class AmbiguityElicitation
20
+ # Common ambiguous patterns in mathematical notation
21
+ AMBIGUOUS_PATTERNS = {
22
+ 'O_or_0' => {
23
+ pattern: /[O0]/,
24
+ alternatives: ['0 (zero)', 'O (letter O)'],
25
+ description: 'Zero vs letter O ambiguity'
26
+ },
27
+ '1_or_l' => {
28
+ pattern: /[1lI]/,
29
+ alternatives: ['1 (one)', 'l (lowercase L)', 'I (uppercase i)'],
30
+ description: 'One vs lowercase L vs uppercase I'
31
+ },
32
+ 'comma_or_decimal' => {
33
+ pattern: /\d[.,]\d/,
34
+ alternatives: ['comma (thousands separator)', 'decimal point'],
35
+ description: 'Comma vs decimal point in numbers'
36
+ },
37
+ 'prime_or_apostrophe' => {
38
+ pattern: /[\'′]/,
39
+ alternatives: ["' (prime)", "' (apostrophe)"],
40
+ description: 'Prime notation vs apostrophe'
41
+ }
42
+ }.freeze
43
+
44
+ attr_reader :ambiguous_text, :context, :alternatives, :decision
45
+
46
+ # Initialize ambiguity elicitation
47
+ #
48
+ # @param ambiguous_text [String] the ambiguous symbol/text
49
+ # @param context [String] surrounding LaTeX for context
50
+ # @param alternatives [Array<String>] possible interpretations
51
+ # @param position [Hash] optional position data {x, y, width, height}
52
+ def initialize(ambiguous_text:, context:, alternatives: nil, position: nil)
53
+ @ambiguous_text = ambiguous_text
54
+ @context = context
55
+ @position = position
56
+ @alternatives = alternatives || detect_alternatives
57
+ @decision = nil
58
+ end
59
+
60
+ # Generate disambiguation prompt
61
+ #
62
+ # @return [String] user-facing prompt
63
+ def prompt
64
+ <<~PROMPT
65
+ 🔍 Ambiguous Notation Detected
66
+
67
+ Ambiguous symbol: "#{@ambiguous_text}"
68
+ Context: #{@context}
69
+
70
+ Please select the correct interpretation:
71
+ PROMPT
72
+ end
73
+
74
+ # Generate options for clarification
75
+ #
76
+ # @return [Array<Hash>] option choices
77
+ def options
78
+ @alternatives.map.with_index do |alt, idx|
79
+ {
80
+ value: "option_#{idx}",
81
+ label: alt,
82
+ description: "Interpret as: #{alt}"
83
+ }
84
+ end
85
+ end
86
+
87
+ # Set user's clarification decision
88
+ #
89
+ # @param decision [String] selected option (option_0, option_1, etc)
90
+ def set_decision(decision)
91
+ index = decision.match(/option_(\d+)/)[1].to_i
92
+ unless index >= 0 && index < @alternatives.length
93
+ raise ArgumentError, "Invalid decision: #{decision}"
94
+ end
95
+ @decision = @alternatives[index]
96
+ end
97
+
98
+ # Apply clarification to LaTeX
99
+ #
100
+ # @return [String] corrected LaTeX
101
+ def apply_clarification
102
+ raise "No decision set" unless @decision
103
+
104
+ # Simple replacement (in real implementation, would be more sophisticated)
105
+ corrected = @context.dup
106
+
107
+ case @decision
108
+ when /0 \(zero\)/
109
+ corrected.gsub!(/O/, '0')
110
+ when /O \(letter O\)/
111
+ corrected.gsub!(/0/, 'O')
112
+ when /1 \(one\)/
113
+ corrected.gsub!(/[lI]/, '1')
114
+ when /l \(lowercase L\)/
115
+ corrected.gsub!(/[1I]/, 'l')
116
+ # Add more transformations as needed
117
+ end
118
+
119
+ corrected
120
+ end
121
+
122
+ # Convert to MCP elicitation request
123
+ #
124
+ # @return [Hash] MCP-compatible request
125
+ def to_mcp_request
126
+ {
127
+ type: 'select',
128
+ prompt: prompt,
129
+ field_name: 'ambiguity_clarification',
130
+ options: options.map { |o| { value: o[:value], label: o[:label] } },
131
+ metadata: {
132
+ ambiguous_text: @ambiguous_text,
133
+ context: @context,
134
+ position: @position
135
+ }
136
+ }
137
+ end
138
+
139
+ private
140
+
141
+ # Auto-detect possible alternatives based on ambiguous text
142
+ #
143
+ # @return [Array<String>] detected alternatives
144
+ def detect_alternatives
145
+ # Check common patterns
146
+ AMBIGUOUS_PATTERNS.each do |key, pattern_data|
147
+ if pattern_data[:pattern].match?(@ambiguous_text)
148
+ return pattern_data[:alternatives]
149
+ end
150
+ end
151
+
152
+ # Default: generic ambiguity
153
+ [
154
+ "Interpretation A: #{@ambiguous_text}",
155
+ "Interpretation B: similar symbol",
156
+ "Keep as-is: #{@ambiguous_text}"
157
+ ]
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end