browser-assistant 2.8.6 → 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.6",
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.6",
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
@@ -191,7 +191,7 @@ async def lifespan(_app: FastAPI):
191
191
  client.close()
192
192
 
193
193
 
194
- app = FastAPI(title="Browser Assistant API", version="2.8.6", lifespan=lifespan)
194
+ app = FastAPI(title="Browser Assistant API", version="2.8.7", lifespan=lifespan)
195
195
  install_local_guards(app)
196
196
 
197
197
  @app.exception_handler(HTTPException)
@@ -1247,7 +1247,7 @@ REPAIR MODE:
1247
1247
 
1248
1248
  async def confirm_answers(cleaned, source_model=None):
1249
1249
  if not verify_answers or not any(item['type'] == 'mcq' for item in json.loads(cleaned)):
1250
- return False
1250
+ return cleaned, False
1251
1251
 
1252
1252
  async def independent_solve(prompt_messages):
1253
1253
  if source_model is None:
@@ -1261,14 +1261,14 @@ REPAIR MODE:
1261
1261
  return await try_groq(prompt_messages, model=source_model, temperature=0.0)
1262
1262
 
1263
1263
  try:
1264
- await cross_check_text_answers(messages, cleaned, independent_solve)
1264
+ checked = await cross_check_text_answers(messages, cleaned, independent_solve, question_text=trimmed_text)
1265
1265
  except Exception as exc:
1266
1266
  if isinstance(exc, VisionUnconfirmedError):
1267
1267
  message = str(exc)
1268
1268
  else:
1269
1269
  message = 'An independent solve was unavailable.'
1270
1270
  raise HTTPException(422, detail={'code': 'answer_unconfirmed', 'message': message}) from exc
1271
- return True
1271
+ return checked, True
1272
1272
 
1273
1273
  # Try each Groq text model in the fallback chain
1274
1274
  for model_id in TEXT_MODELS:
@@ -1294,7 +1294,7 @@ REPAIR MODE:
1294
1294
  )
1295
1295
  if cleaned != "[]":
1296
1296
  logger.info(f"Groq ({model_id}) returned a validated result ({len(cleaned)} characters)")
1297
- verified = await confirm_answers(cleaned, model_id)
1297
+ cleaned, verified = await confirm_answers(cleaned, model_id)
1298
1298
  return {"results": cleaned, "provider": f"groq ({model_id})" + (' [cross-checked]' if verified else ''),
1299
1299
  "language": language, "answer_verified": verified,
1300
1300
  "code_reviewed": strict_code_review and language != 'web'}
@@ -1330,7 +1330,7 @@ REPAIR MODE:
1330
1330
  starter_code=content.starter_code,
1331
1331
  )
1332
1332
  if cleaned != "[]":
1333
- verified = await confirm_answers(cleaned)
1333
+ cleaned, verified = await confirm_answers(cleaned)
1334
1334
  return {
1335
1335
  "results": cleaned,
1336
1336
  "provider": f"ollama ({OLLAMA_MODEL})" + (' [cross-checked]' if verified else ''),
@@ -213,6 +213,20 @@ def matrix_2x2(value):
213
213
  return tuple(entries)
214
214
 
215
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
+
216
230
  def solve_matrix_choice(text):
217
231
  """Calculate a self-contained matrix question with one matching displayed choice."""
218
232
  question, separator, option_text = text.strip().rpartition('\nA.')
@@ -257,20 +271,13 @@ def solve_matrix_choice(text):
257
271
  else:
258
272
  return None
259
273
 
260
- lines = ('A.' + option_text).splitlines()
261
- if not 2 <= len(lines) <= 26:
262
- return None
263
274
  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])
275
+ for letter, option in parse_labeled_choices(option_text).items():
276
+ value = parse_option(option)
270
277
  if value is None:
271
278
  return None
272
279
  if value == expected:
273
- matches.append(f'Option {letter}: {option[1]}')
280
+ matches.append(f'Option {letter}: {option}')
274
281
  if len(matches) != 1:
275
282
  return None
276
283
  return {'type': 'mcq', 'question': question, 'answer': matches[0], 'reasoning': reasoning}
@@ -326,7 +333,34 @@ def parse_answer_check(raw, count):
326
333
  return answers
327
334
 
328
335
 
329
- 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=''):
330
364
  items = json.loads(results)
331
365
  if not items or any(item.get('type') != 'mcq' for item in items):
332
366
  raise VisionUnconfirmedError('Verification requires a complete set of question answers.')
@@ -340,22 +374,42 @@ async def cross_check_text_answers(messages, results, complete):
340
374
  'If the information is incomplete or any answer is uncertain, return {"readable":false,"answers":[]}.'
341
375
  )
342
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
343
388
  for attempt in range(2):
344
389
  raw = await complete(check_messages)
345
390
  try:
346
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:
347
403
  break
348
- except ValueError as exc:
349
- if attempt:
350
- raise VisionUnconfirmedError('The independent solve could not verify every answer after retrying.') from exc
404
+ if not attempt:
405
+ previous = current
351
406
  check_messages = [*check_messages, {
352
407
  'role': 'user',
353
408
  'content': 'Solve again using the original question and options. Return the full verification JSON with the required ids.',
354
409
  }]
355
- matrix_math = matrix_question(messages)
356
- first = [normalize_checked_answer(item['answer'], matrix_math=matrix_math) for item in items]
357
- if not all(first) or first != [normalize_checked_answer(item['answer'], matrix_math=matrix_math) for item in verified]:
358
- 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.')
359
413
 
360
414
 
361
415
  async def cross_check_visual_answers(messages, results, model, complete, comparison=None, *, require_agreement=False):