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.
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/server/main.py +6 -6
- package/server/vision_images.py +72 -18
package/extension/manifest.json
CHANGED
package/package.json
CHANGED
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.
|
|
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 ''),
|
package/server/vision_images.py
CHANGED
|
@@ -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
|
|
265
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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):
|