browser-assistant 2.8.5 → 2.8.6

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser Assistant",
4
- "version": "2.8.5",
4
+ "version": "2.8.6",
5
5
  "description": "Scans webpages for MCQs and coding problems. Powered by Groq (cloud) and Ollama (local fallback).",
6
6
  "permissions": [
7
7
  "activeTab",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-assistant",
3
- "version": "2.8.5",
3
+ "version": "2.8.6",
4
4
  "description": "Chrome extension and local Python backend for exploring webpage questions and coding exercises",
5
5
  "license": "MIT",
6
6
  "repository": {
package/server/main.py CHANGED
@@ -24,7 +24,8 @@ from fastapi.exception_handlers import http_exception_handler
24
24
  from local_security import install_local_guards, redact, sanitize_data
25
25
  from vision_images import (compress_image_base64, unique_image_blocks, collect_image_evidence,
26
26
  cross_check_visual_answers, relative_percentage_question, RELATIVE_PERCENTAGE_INSTRUCTION,
27
- answer_permutation, cross_check_text_answers, VisionRateLimitError, VisionUnconfirmedError)
27
+ answer_permutation, cross_check_text_answers, solve_matrix_choice,
28
+ VisionRateLimitError, VisionUnconfirmedError)
28
29
 
29
30
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
30
31
  logger = logging.getLogger("browser-assistant")
@@ -190,7 +191,7 @@ async def lifespan(_app: FastAPI):
190
191
  client.close()
191
192
 
192
193
 
193
- app = FastAPI(title="Browser Assistant API", version="2.8.5", lifespan=lifespan)
194
+ app = FastAPI(title="Browser Assistant API", version="2.8.6", lifespan=lifespan)
194
195
  install_local_guards(app)
195
196
 
196
197
  @app.exception_handler(HTTPException)
@@ -1190,6 +1191,13 @@ async def review_and_correct_code(
1190
1191
 
1191
1192
  # ── Routes ────────────────────────────────────────────────────────────────────
1192
1193
 
1194
+ def calculated_matrix_result(text, language):
1195
+ item = solve_matrix_choice(text)
1196
+ if item is not None:
1197
+ return {"results": json.dumps([item], ensure_ascii=False), "provider": "local matrix calculation [verified]",
1198
+ "language": language, "answer_verified": True, "code_reviewed": False}
1199
+
1200
+
1193
1201
  @app.post("/solve")
1194
1202
  async def solve_questions(content: PageContent):
1195
1203
  # Trim oversized page text to stay within token limits
@@ -1232,6 +1240,10 @@ REPAIR MODE:
1232
1240
  )
1233
1241
  strict_code_review = require_code and (content.review_before_submit or not FAST_CODE_MODE or STRICT_REPAIR_REVIEW)
1234
1242
  verify_answers = not require_code and (content.verify_answer or not FAST_SCAN_MODE)
1243
+ if not require_code and content.practice_type in {'auto', 'mcq', 'math', 'aptitude'}:
1244
+ matrix_result = calculated_matrix_result(trimmed_text, language)
1245
+ if matrix_result is not None:
1246
+ return matrix_result
1235
1247
 
1236
1248
  async def confirm_answers(cleaned, source_model=None):
1237
1249
  if not verify_answers or not any(item['type'] == 'mcq' for item in json.loads(cleaned)):
@@ -1395,6 +1407,11 @@ async def _solve_vision(content: VisionContent):
1395
1407
  compressed.append(await asyncio.to_thread(compress_image_base64, image, max_dim=2400, quality=95))
1396
1408
  except ValueError as exc:
1397
1409
  raise HTTPException(422, f'Image {index}: {exc}') from exc
1410
+ is_code_scan = content.practice_type == "code" or (content.practice_type == "auto" and bool(content.language))
1411
+ if not is_code_scan and content.practice_type in {'auto', 'mcq', 'math', 'aptitude'}:
1412
+ matrix_result = calculated_matrix_result(content.text, content.language or CODE_DEFAULT_LANGUAGE)
1413
+ if matrix_result is not None:
1414
+ return matrix_result
1398
1415
  image_blocks, image_positions = unique_image_blocks(compressed)
1399
1416
 
1400
1417
  clients = tuple(_groq_clients)
@@ -1406,7 +1423,6 @@ async def _solve_vision(content: VisionContent):
1406
1423
  loop = asyncio.get_running_loop()
1407
1424
  errors = []
1408
1425
  rate_limit_resets = []
1409
- is_code_scan = content.practice_type == "code" or (content.practice_type == "auto" and bool(content.language))
1410
1426
  comparison = None if is_code_scan else relative_percentage_question(content.text)
1411
1427
  unconfirmed = False
1412
1428
  vision_token_limit = 4096 if is_code_scan else VISION_MAX_TOKENS
@@ -170,9 +170,13 @@ def answer_permutation(value):
170
170
  MATRIX_SYNTAX = re.compile(r'\\begin\{(?:[bpBvV]?matrix|smallmatrix)\}')
171
171
 
172
172
 
173
- def numeric_matrix_key(value):
173
+ def unwrap_math(value):
174
174
  text = re.sub(r'^\${1,2}|\${1,2}$', '', value.strip())
175
- text = re.sub(r'^\\[\[(]|\\[\])]$', '', text).strip().replace('−', '-')
175
+ return re.sub(r'^\\[\[(]|\\[\])]$', '', text).strip().replace('−', '-')
176
+
177
+
178
+ def matrix_cells(value):
179
+ text = unwrap_math(value)
176
180
  latex = re.fullmatch(r'\\begin\{([bpBvV]?matrix|smallmatrix)\}(.*?)\\end\{\1\}', text, re.S)
177
181
  array = re.fullmatch(r'\[\s*(\[[^\[\]]+\](?:\s*,\s*\[[^\[\]]+\])*)\s*\]', text)
178
182
  if latex:
@@ -181,13 +185,97 @@ def numeric_matrix_key(value):
181
185
  rows = [row.split(',') for row in re.findall(r'\[([^\[\]]+)\]', array[1])]
182
186
  else:
183
187
  return None
184
- cells = [[cell.strip() for cell in row] for row in rows]
188
+ return [[cell.strip() for cell in row] for row in rows]
189
+
190
+
191
+ def numeric_matrix_key(value):
192
+ cells = matrix_cells(value)
185
193
  if cells and all(len(row) == len(cells[0]) and all(
186
194
  re.fullmatch(r'[+-]?(?:\d+(?:\.\d+)?|\.\d+)', cell, re.ASCII) for cell in row) for row in cells):
187
195
  return json.dumps(cells, separators=(',', ':'))
188
196
  return None
189
197
 
190
198
 
199
+ def matrix_2x2(value):
200
+ rows = matrix_cells(value)
201
+ if not rows or len(rows) != 2 or any(len(row) != 2 for row in rows):
202
+ return None
203
+ entries = []
204
+ for cell in (cell for row in rows for cell in row):
205
+ cell = re.sub(r'^([+-]?)\\(?:[dt]?frac)\{([+-]?\d{1,15})\}\{(\d{1,15})\}$',
206
+ r'\1\2/\3', cell)
207
+ if not re.fullmatch(rf'(?:{DECIMAL_VALUE}|[+-]?\d{{1,15}}/\d{{1,15}})', cell):
208
+ return None
209
+ try:
210
+ entries.append(Fraction(cell))
211
+ except ZeroDivisionError:
212
+ return None
213
+ return tuple(entries)
214
+
215
+
216
+ def solve_matrix_choice(text):
217
+ """Calculate a self-contained matrix question with one matching displayed choice."""
218
+ question, separator, option_text = text.strip().rpartition('\nA.')
219
+ if not separator:
220
+ return None
221
+ question = re.sub(r'\n(?:Question format: )?Select one answer\.$', '', question, flags=re.I)
222
+ question = re.sub(r'^\d+\s*of\s*\d+\s*questions?\s*\d+\s*marks?\s*', '', question, flags=re.I)
223
+ # Math delimiters and layout whitespace do not carry meaning in these prompts.
224
+ prompt = re.sub(r'\s+', ' ', question.replace('$', '')).strip()
225
+ inverse = re.fullmatch(
226
+ r'For ([A-Z])\s*=\s*(.+?),\s*what is \1\^(?:\{-1\}|-1)'
227
+ r'(?: computed using the adjugate method)?\?', prompt,
228
+ )
229
+ product = re.fullmatch(
230
+ r'For matrices ([A-Z]) with shape \(\s*([a-z])\s*,\s*([a-z])\s*\) and '
231
+ r'([A-Z]) with shape \(\s*([a-z])\s*,\s*([a-z])\s*\),\s*'
232
+ r'which condition must hold for the product (\1\4|\4\1) to be defined\?', prompt,
233
+ )
234
+ if inverse:
235
+ entries = matrix_2x2(inverse[2])
236
+ if entries is None:
237
+ return None
238
+ a, b, c, d = entries
239
+ determinant = a * d - b * c
240
+ if not determinant:
241
+ return None
242
+ expected = (d / determinant, -b / determinant, -c / determinant, a / determinant)
243
+ parse_option = matrix_2x2
244
+ reasoning = f'The determinant is ({a})({d}) − ({b})({c}) = {determinant}. Divide the adjugate by the determinant.'
245
+ elif product:
246
+ first, first_rows, first_cols, second, second_rows, second_cols, order = product.groups()
247
+ if first == second or len({first_rows, first_cols, second_rows, second_cols}) != 4:
248
+ return None
249
+ left_cols, right_rows = (first_cols, second_rows) if order == first + second else (second_cols, first_rows)
250
+ expected = frozenset((left_cols, right_rows))
251
+
252
+ def parse_option(value):
253
+ equation = re.fullmatch(r'([a-z])\s*=\s*([a-z])', unwrap_math(value))
254
+ return frozenset(equation.groups()) if equation else None
255
+
256
+ reasoning = f'The left matrix has {left_cols} columns and the right matrix has {right_rows} rows. These must be equal.'
257
+ else:
258
+ return None
259
+
260
+ lines = ('A.' + option_text).splitlines()
261
+ if not 2 <= len(lines) <= 26:
262
+ return None
263
+ matches = []
264
+ for index, line in enumerate(lines):
265
+ letter = chr(ord('A') + index)
266
+ option = re.fullmatch(rf'{letter}\.\s*(\S.*)', line)
267
+ if not option:
268
+ return None
269
+ value = parse_option(option[1])
270
+ if value is None:
271
+ return None
272
+ if value == expected:
273
+ matches.append(f'Option {letter}: {option[1]}')
274
+ if len(matches) != 1:
275
+ return None
276
+ return {'type': 'mcq', 'question': question, 'answer': matches[0], 'reasoning': reasoning}
277
+
278
+
191
279
  def normalize_checked_answer(value, *, matrix_math=False):
192
280
  if answer_permutation(value):
193
281
  return json.dumps(value)