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.
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/server/main.py +24 -8
- package/server/vision_images.py +153 -11
package/extension/manifest.json
CHANGED
package/package.json
CHANGED
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,
|
|
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.
|
|
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
|
package/server/vision_images.py
CHANGED
|
@@ -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
|
|
173
|
+
def unwrap_math(value):
|
|
174
174
|
text = re.sub(r'^\${1,2}|\${1,2}$', '', value.strip())
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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):
|