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.
- package/extension/automation-policy.js +19 -0
- package/extension/content.js +101 -7
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/server/main.py +19 -3
- package/server/vision_images.py +91 -3
|
@@ -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()));
|
package/extension/content.js
CHANGED
|
@@ -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
|
-
|
|
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') {
|
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.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
|
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,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
|
-
|
|
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)
|