browser-assistant 2.8.4 → 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.
@@ -379,6 +379,25 @@
379
379
  const unique = [...new Set(candidates)];
380
380
  return unique.length === 1 ? unique[0] : null;
381
381
  },
382
+ getPracticeSkip(doc, question) {
383
+ const anchor = question.form || question.container;
384
+ if (!anchor)
385
+ return null;
386
+ const skips = [...doc.querySelectorAll('button,[role="button"]')].filter(button => button.isConnected && button.getClientRects().length && !button.disabled
387
+ && button.getAttribute('aria-disabled') !== 'true' && !button.closest('#assistant-root')
388
+ && /^skip(?: question)?$/i.test((button.innerText || button.textContent || button.getAttribute('aria-label') || '').trim()));
389
+ if (skips.length !== 1)
390
+ return null;
391
+ for (let scope = anchor; scope && scope !== doc.body; scope = scope.parentElement) {
392
+ if (!scope.contains(skips[0]))
393
+ continue;
394
+ const submits = [...scope.querySelectorAll('button,[role="button"],input[type="submit"]')].filter(button => button.getClientRects().length && !button.closest('#assistant-root')
395
+ && /^submit(?: answer)?$/i.test((button.innerText || button.textContent || button.value || button.getAttribute('aria-label') || '').trim()));
396
+ if (submits.length === 1)
397
+ return skips[0];
398
+ }
399
+ return null;
400
+ },
382
401
  getBlockingReason(doc) {
383
402
  const expired = [...doc.querySelectorAll('p,span,div,h1,h2,h3,h4')].some(node => node.getClientRects().length && !node.closest('#assistant-root') && !node.children.length
384
403
  && /^time['’]s up!\s*you cannot attempt this assessment now\.?$/i.test((node.innerText || node.textContent || '').trim()));
@@ -415,6 +415,7 @@
415
415
  const currentQuestion = automation.batch?.title || (automation.running && automation.codingQuestionText)
416
416
  || (authorized ? getAuthorizedQuestion(context)?.text : '');
417
417
  const pageName = authorized ? context.target.id : window.location.hostname;
418
+ const deferred = automation.deferredQuestions?.size ?? 0;
418
419
  if (status) {
419
420
  status.textContent = authorized
420
421
  ? `Build ${extensionVersion} · ${context.target.id}: ${automation.state} · Completed: ${automation.completed}${automation.error ? ` · ${automation.error}` : ''}`
@@ -431,14 +432,15 @@
431
432
  if (question)
432
433
  question.textContent = currentQuestion || '—';
433
434
  if (progress)
434
- progress.textContent = `${automation.completed} / —`;
435
+ progress.textContent = deferred ? `${automation.completed} completed · ${deferred} deferred` : `${automation.completed} / —`;
435
436
  if (latest) {
437
+ const reviewing = ['WAITING_FOR_REVIEW', 'DEFERRING_QUESTION', 'REVIEW_NEEDED'].includes(automation.state);
436
438
  latest.textContent = automation.error
437
- ? `⚠ ${automation.error}`
439
+ ? reviewing ? automation.error : `⚠ ${automation.error}`
438
440
  : authorized
439
441
  ? `Current status: ${automation.state.replace(/_/g, ' ').toLowerCase()}.`
440
442
  : 'Automation is available only on an authorized QA page.';
441
- latest.classList.toggle('has-error', Boolean(automation.error));
443
+ latest.classList.toggle('has-error', Boolean(automation.error) && !reviewing);
442
444
  }
443
445
  if (start)
444
446
  start.disabled = !authorized || automation.running;
@@ -477,6 +479,8 @@
477
479
  automation.paused = false;
478
480
  automation.pendingNavigation = false;
479
481
  automation.state = reason;
482
+ if (reason === 'REVIEW_NEEDED')
483
+ automation.error = 'Review deferred questions on the portal.';
480
484
  updateAutomationUI();
481
485
  }
482
486
  async function waitWhilePaused(runId) {
@@ -1178,6 +1182,73 @@
1178
1182
  stopAutomation('STOPPED');
1179
1183
  }
1180
1184
  }
1185
+ async function continueAfterUnverifiedQuestion(question, runId, reason, allowSkip = true) {
1186
+ if (!(await waitWhilePaused(runId)))
1187
+ return false;
1188
+ const initial = getAutomationContext();
1189
+ const adapter = getAutomationAdapter(initial);
1190
+ if (!adapter || initial.target.id !== automation.targetId) {
1191
+ stopAutomation('UNAUTHORIZED');
1192
+ return false;
1193
+ }
1194
+ const fresh = getAuthorizedQuestion(initial);
1195
+ const sameQuestion = fresh?.signature === question.signature && hasSameQuestionOptions(question, fresh);
1196
+ const practice = initial.target.id === 'ccbp-learning-practice'
1197
+ && /^\/(?:default-question|primitive-coding(?:-review)?)\/practice\//.test(location.pathname);
1198
+ const hasDraft = fresh?.input?.value?.trim() || fresh?.options.some(option => option.isSelected?.() || option.input?.checked);
1199
+ if (allowSkip && practice && sameQuestion && !hasDraft && question.type !== 'ordering'
1200
+ && !adapter.isSaving?.(document) && !adapter.getSubmittedAdvance?.(document)
1201
+ && automationPolicy.canExecute('click', initial) && automationPolicy.canExecute('validate', initial)) {
1202
+ const skip = adapter.getPracticeSkip?.(document, fresh);
1203
+ if (skip) {
1204
+ automation.state = 'DEFERRING_QUESTION';
1205
+ automation.error = reason;
1206
+ automation.pendingNavigation = true;
1207
+ updateAutomationUI();
1208
+ skip.click();
1209
+ const transition = await waitForPracticeTransition(question.signature, runId);
1210
+ if (!automation.running || automation.runId !== runId)
1211
+ return false;
1212
+ automation.pendingNavigation = false;
1213
+ if (transition === 'next' || transition === 'complete') {
1214
+ automation.deferredQuestions.add(question.signature);
1215
+ automation.error = '';
1216
+ return true;
1217
+ }
1218
+ }
1219
+ }
1220
+ automation.state = 'WAITING_FOR_REVIEW';
1221
+ automation.error = `${reason} Use the page controls to review and move to the next question. Automation will continue here.`;
1222
+ updateAutomationUI();
1223
+ while (await waitWhilePaused(runId)) {
1224
+ if (automation.state !== 'WAITING_FOR_REVIEW') {
1225
+ automation.state = 'WAITING_FOR_REVIEW';
1226
+ updateAutomationUI();
1227
+ }
1228
+ const context = getAutomationContext();
1229
+ const currentAdapter = getAutomationAdapter(context);
1230
+ if (context.mode !== 'FULL' || context.target.id !== initial.target.id || currentAdapter?.id !== adapter.id) {
1231
+ stopAutomation('UNAUTHORIZED');
1232
+ return false;
1233
+ }
1234
+ const blocked = currentAdapter.getBlockingReason?.(document);
1235
+ if (blocked) {
1236
+ automation.error = blocked;
1237
+ stopAutomation('PAUSED');
1238
+ return false;
1239
+ }
1240
+ if (!currentAdapter.isSaving?.(document)) {
1241
+ const current = getAuthorizedQuestion(context);
1242
+ if (currentAdapter.getSubmittedAdvance?.(document) || currentAdapter.isComplete(document)
1243
+ || (current && current.signature !== question.signature && !hasSameQuestionOptions(question, current))) {
1244
+ automation.error = '';
1245
+ return true;
1246
+ }
1247
+ }
1248
+ await new Promise(resolve => setTimeout(resolve, 250));
1249
+ }
1250
+ return false;
1251
+ }
1181
1252
  async function startPracticeAutomation() {
1182
1253
  if (window.BrowserAssistantJournal?.matches()) {
1183
1254
  await window.BrowserAssistantJournal.start();
@@ -1230,6 +1301,7 @@
1230
1301
  automation.paused = false;
1231
1302
  automation.error = '';
1232
1303
  automation.completed = 0;
1304
+ automation.deferredQuestions = new Set();
1233
1305
  automation.targetId = initialContext.target.id;
1234
1306
  automation.pendingNavigation = false;
1235
1307
  automation.currentSignature = null;
@@ -1286,10 +1358,12 @@
1286
1358
  const transition = await waitForPracticeTransition(previousSignature, runId);
1287
1359
  if (!automation.running || automation.runId !== runId)
1288
1360
  break;
1289
- if (transition === 'next' || transition === 'complete')
1361
+ if (transition === 'next' || transition === 'complete') {
1290
1362
  automation.completed += 1;
1363
+ automation.deferredQuestions.delete(previousSignature);
1364
+ }
1291
1365
  if (transition === 'complete') {
1292
- stopAutomation('COMPLETED');
1366
+ stopAutomation(automation.deferredQuestions.size ? 'REVIEW_NEEDED' : 'COMPLETED');
1293
1367
  break;
1294
1368
  }
1295
1369
  if (transition !== 'next') {
@@ -1300,6 +1374,10 @@
1300
1374
  continue;
1301
1375
  }
1302
1376
  if (currentAdapter.isComplete(document)) {
1377
+ if (automation.deferredQuestions.size) {
1378
+ stopAutomation('REVIEW_NEEDED');
1379
+ break;
1380
+ }
1303
1381
  const nextPractice = currentAdapter.getNextPractice?.(document);
1304
1382
  if (!nextPractice) {
1305
1383
  stopAutomation('COMPLETED');
@@ -1341,6 +1419,12 @@
1341
1419
  await runCodingAutomation(question, runId);
1342
1420
  break;
1343
1421
  }
1422
+ if (automation.deferredQuestions.has(question.signature)) {
1423
+ automation.currentSignature = question.signature;
1424
+ if (await continueAfterUnverifiedQuestion(question, runId, 'This question was deferred earlier.', false))
1425
+ continue;
1426
+ break;
1427
+ }
1344
1428
  if (automation.processed.has(question.signature)) {
1345
1429
  automation.error = 'Repeated question detected.';
1346
1430
  stopAutomation('PAUSED');
@@ -1403,7 +1487,12 @@
1403
1487
  if (!automation.running || automation.runId !== runId)
1404
1488
  break;
1405
1489
  automation.error = error.message || 'Local AI server unavailable.';
1406
- stopAutomation(['answer_unconfirmed', 'vision_unconfirmed'].includes(error.code) ? 'PAUSED' : 'ERROR');
1490
+ if (['answer_unconfirmed', 'vision_unconfirmed'].includes(error.code)) {
1491
+ if (await continueAfterUnverifiedQuestion(question, runId, automation.error))
1492
+ continue;
1493
+ }
1494
+ else
1495
+ stopAutomation('ERROR');
1407
1496
  break;
1408
1497
  }
1409
1498
  if (!automation.running || automation.runId !== runId || !response)
@@ -1474,6 +1563,11 @@
1474
1563
  // transient code-block markup.
1475
1564
  if (!answerIndexes.length || !current || !hasSameQuestionOptions(question, current)) {
1476
1565
  automation.error = !answerIndexes.length ? 'The verified answer did not match the displayed options exactly.' : !current ? 'Question controls disappeared while solving.' : 'Question text or options changed while solving. Please retry.';
1566
+ if (!answerIndexes.length && current?.signature === question.signature && hasSameQuestionOptions(question, current)) {
1567
+ if (await continueAfterUnverifiedQuestion(question, runId, automation.error))
1568
+ continue;
1569
+ break;
1570
+ }
1477
1571
  stopAutomation('PAUSED');
1478
1572
  break;
1479
1573
  }
@@ -1590,7 +1684,7 @@
1590
1684
  }
1591
1685
  if (transition === 'complete') {
1592
1686
  automation.completed += 1;
1593
- stopAutomation('COMPLETED');
1687
+ stopAutomation(automation.deferredQuestions.size ? 'REVIEW_NEEDED' : 'COMPLETED');
1594
1688
  break;
1595
1689
  }
1596
1690
  if (transition !== 'next') {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser Assistant",
4
- "version": "2.8.4",
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.4",
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.4", 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)