browser-assistant 2.8.5 → 2.8.7

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.7",
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.7",
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.7", 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,10 +1240,14 @@ 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)):
1238
- return False
1250
+ return cleaned, False
1239
1251
 
1240
1252
  async def independent_solve(prompt_messages):
1241
1253
  if source_model is None:
@@ -1249,14 +1261,14 @@ REPAIR MODE:
1249
1261
  return await try_groq(prompt_messages, model=source_model, temperature=0.0)
1250
1262
 
1251
1263
  try:
1252
- await cross_check_text_answers(messages, cleaned, independent_solve)
1264
+ checked = await cross_check_text_answers(messages, cleaned, independent_solve, question_text=trimmed_text)
1253
1265
  except Exception as exc:
1254
1266
  if isinstance(exc, VisionUnconfirmedError):
1255
1267
  message = str(exc)
1256
1268
  else:
1257
1269
  message = 'An independent solve was unavailable.'
1258
1270
  raise HTTPException(422, detail={'code': 'answer_unconfirmed', 'message': message}) from exc
1259
- return True
1271
+ return checked, True
1260
1272
 
1261
1273
  # Try each Groq text model in the fallback chain
1262
1274
  for model_id in TEXT_MODELS:
@@ -1282,7 +1294,7 @@ REPAIR MODE:
1282
1294
  )
1283
1295
  if cleaned != "[]":
1284
1296
  logger.info(f"Groq ({model_id}) returned a validated result ({len(cleaned)} characters)")
1285
- verified = await confirm_answers(cleaned, model_id)
1297
+ cleaned, verified = await confirm_answers(cleaned, model_id)
1286
1298
  return {"results": cleaned, "provider": f"groq ({model_id})" + (' [cross-checked]' if verified else ''),
1287
1299
  "language": language, "answer_verified": verified,
1288
1300
  "code_reviewed": strict_code_review and language != 'web'}
@@ -1318,7 +1330,7 @@ REPAIR MODE:
1318
1330
  starter_code=content.starter_code,
1319
1331
  )
1320
1332
  if cleaned != "[]":
1321
- verified = await confirm_answers(cleaned)
1333
+ cleaned, verified = await confirm_answers(cleaned)
1322
1334
  return {
1323
1335
  "results": cleaned,
1324
1336
  "provider": f"ollama ({OLLAMA_MODEL})" + (' [cross-checked]' if verified else ''),
@@ -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,104 @@ 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 parse_labeled_choices(option_text):
217
+ lines = ('A.' + option_text).splitlines()
218
+ if not 2 <= len(lines) <= 26:
219
+ return {}
220
+ choices = {}
221
+ for index, line in enumerate(lines):
222
+ letter = chr(ord('A') + index)
223
+ option = re.fullmatch(rf'{letter}\.\s*(\S.*)', line)
224
+ if not option:
225
+ return {}
226
+ choices[letter] = option[1]
227
+ return choices
228
+
229
+
230
+ def solve_matrix_choice(text):
231
+ """Calculate a self-contained matrix question with one matching displayed choice."""
232
+ question, separator, option_text = text.strip().rpartition('\nA.')
233
+ if not separator:
234
+ return None
235
+ question = re.sub(r'\n(?:Question format: )?Select one answer\.$', '', question, flags=re.I)
236
+ question = re.sub(r'^\d+\s*of\s*\d+\s*questions?\s*\d+\s*marks?\s*', '', question, flags=re.I)
237
+ # Math delimiters and layout whitespace do not carry meaning in these prompts.
238
+ prompt = re.sub(r'\s+', ' ', question.replace('$', '')).strip()
239
+ inverse = re.fullmatch(
240
+ r'For ([A-Z])\s*=\s*(.+?),\s*what is \1\^(?:\{-1\}|-1)'
241
+ r'(?: computed using the adjugate method)?\?', prompt,
242
+ )
243
+ product = re.fullmatch(
244
+ r'For matrices ([A-Z]) with shape \(\s*([a-z])\s*,\s*([a-z])\s*\) and '
245
+ r'([A-Z]) with shape \(\s*([a-z])\s*,\s*([a-z])\s*\),\s*'
246
+ r'which condition must hold for the product (\1\4|\4\1) to be defined\?', prompt,
247
+ )
248
+ if inverse:
249
+ entries = matrix_2x2(inverse[2])
250
+ if entries is None:
251
+ return None
252
+ a, b, c, d = entries
253
+ determinant = a * d - b * c
254
+ if not determinant:
255
+ return None
256
+ expected = (d / determinant, -b / determinant, -c / determinant, a / determinant)
257
+ parse_option = matrix_2x2
258
+ reasoning = f'The determinant is ({a})({d}) − ({b})({c}) = {determinant}. Divide the adjugate by the determinant.'
259
+ elif product:
260
+ first, first_rows, first_cols, second, second_rows, second_cols, order = product.groups()
261
+ if first == second or len({first_rows, first_cols, second_rows, second_cols}) != 4:
262
+ return None
263
+ left_cols, right_rows = (first_cols, second_rows) if order == first + second else (second_cols, first_rows)
264
+ expected = frozenset((left_cols, right_rows))
265
+
266
+ def parse_option(value):
267
+ equation = re.fullmatch(r'([a-z])\s*=\s*([a-z])', unwrap_math(value))
268
+ return frozenset(equation.groups()) if equation else None
269
+
270
+ reasoning = f'The left matrix has {left_cols} columns and the right matrix has {right_rows} rows. These must be equal.'
271
+ else:
272
+ return None
273
+
274
+ matches = []
275
+ for letter, option in parse_labeled_choices(option_text).items():
276
+ value = parse_option(option)
277
+ if value is None:
278
+ return None
279
+ if value == expected:
280
+ matches.append(f'Option {letter}: {option}')
281
+ if len(matches) != 1:
282
+ return None
283
+ return {'type': 'mcq', 'question': question, 'answer': matches[0], 'reasoning': reasoning}
284
+
285
+
191
286
  def normalize_checked_answer(value, *, matrix_math=False):
192
287
  if answer_permutation(value):
193
288
  return json.dumps(value)
@@ -238,7 +333,34 @@ def parse_answer_check(raw, count):
238
333
  return answers
239
334
 
240
335
 
241
- async def cross_check_text_answers(messages, results, complete):
336
+ def displayed_single_choices(text):
337
+ question, separator, option_text = text.strip().rpartition('\nA.')
338
+ if not separator or not re.search(r'\n(?:Question format: )?Select one answer\.$', question, re.I):
339
+ return {}
340
+ return parse_labeled_choices(option_text)
341
+
342
+
343
+ def bind_displayed_choice(value, choices, *, matrix_math):
344
+ if type(value) not in (str, int, float):
345
+ return None
346
+ answer = str(value).strip()
347
+
348
+ def key(text):
349
+ return normalize_checked_answer(text, matrix_math=matrix_math)
350
+
351
+ labeled = re.fullmatch(r'(?:Option\s+)?([A-Z])\s*[:.)]\s*(\S.*)', answer, re.I)
352
+ if labeled:
353
+ letter, body = labeled[1].upper(), labeled[2]
354
+ return letter if letter in choices and key(choices[letter]) == key(body) else None
355
+ matches = [letter for letter, body in choices.items() if key(body) == key(answer)]
356
+ letter_only = re.fullmatch(r'(?:Option\s+)?([A-Z])\s*[:.)]?', answer, re.I)
357
+ if letter_only and letter_only[1].upper() in choices:
358
+ matches.append(letter_only[1].upper())
359
+ unique = set(matches)
360
+ return unique.pop() if len(unique) == 1 else None
361
+
362
+
363
+ async def cross_check_text_answers(messages, results, complete, *, question_text=''):
242
364
  items = json.loads(results)
243
365
  if not items or any(item.get('type') != 'mcq' for item in items):
244
366
  raise VisionUnconfirmedError('Verification requires a complete set of question answers.')
@@ -252,22 +374,42 @@ async def cross_check_text_answers(messages, results, complete):
252
374
  'If the information is incomplete or any answer is uncertain, return {"readable":false,"answers":[]}.'
253
375
  )
254
376
  check_messages = [{'role': 'system', 'content': instruction}, *messages[1:]]
377
+ matrix_math = matrix_question(messages)
378
+ choices = displayed_single_choices(question_text) if len(items) == 1 else {}
379
+
380
+ def answer_key(entries):
381
+ if choices:
382
+ return bind_displayed_choice(entries[0].get('answer'), choices, matrix_math=matrix_math)
383
+ keys = tuple(normalize_checked_answer(item.get('answer'), matrix_math=matrix_math) for item in entries)
384
+ return keys if all(keys) else None
385
+
386
+ first = answer_key(items)
387
+ previous = None
255
388
  for attempt in range(2):
256
389
  raw = await complete(check_messages)
257
390
  try:
258
391
  verified = parse_answer_check(raw, len(items))
392
+ current = answer_key(verified)
393
+ except ValueError:
394
+ current = None
395
+ if current is not None and (current == first or current == previous):
396
+ if choices:
397
+ if current != first:
398
+ items[0].pop('reasoning', None)
399
+ items[0]['answer'] = f'Option {current}: {choices[current]}'
400
+ return json.dumps(items, ensure_ascii=False)
401
+ return results
402
+ if not choices and current is not None:
259
403
  break
260
- except ValueError as exc:
261
- if attempt:
262
- raise VisionUnconfirmedError('The independent solve could not verify every answer after retrying.') from exc
404
+ if not attempt:
405
+ previous = current
263
406
  check_messages = [*check_messages, {
264
407
  'role': 'user',
265
408
  'content': 'Solve again using the original question and options. Return the full verification JSON with the required ids.',
266
409
  }]
267
- matrix_math = matrix_question(messages)
268
- first = [normalize_checked_answer(item['answer'], matrix_math=matrix_math) for item in items]
269
- if not all(first) or first != [normalize_checked_answer(item['answer'], matrix_math=matrix_math) for item in verified]:
270
- raise VisionUnconfirmedError('The two solves disagreed on at least one answer.')
410
+ if current is None and previous is None:
411
+ raise VisionUnconfirmedError('The independent solve could not verify every answer after retrying.')
412
+ raise VisionUnconfirmedError('The independent solves could not agree on the displayed answer.')
271
413
 
272
414
 
273
415
  async def cross_check_visual_answers(messages, results, model, complete, comparison=None, *, require_agreement=False):