browser-assistant 2.8.6 → 2.8.21
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 +29 -2
- package/extension/background.js +110 -2
- package/extension/content.js +70 -1
- package/extension/journal-automation.js +141 -16
- package/extension/manifest.json +2 -1
- package/extension/popup.html +16 -0
- package/extension/popup.js +76 -0
- package/extension/styles.css +17 -0
- package/package.json +1 -1
- package/server/main.py +19 -15
- package/server/vision_images.py +145 -29
|
@@ -94,10 +94,36 @@
|
|
|
94
94
|
id: 'semantic-mcq',
|
|
95
95
|
getQuestion(doc) {
|
|
96
96
|
const visible = (element) => element.getClientRects().length > 0;
|
|
97
|
+
const preserveMatrices = (clone) => {
|
|
98
|
+
for (const math of clone.querySelectorAll('math')) {
|
|
99
|
+
const tables = math.querySelectorAll('mtable');
|
|
100
|
+
if (tables.length !== 1)
|
|
101
|
+
continue;
|
|
102
|
+
const matrix = tables[0];
|
|
103
|
+
const surrounding = math.cloneNode(true);
|
|
104
|
+
surrounding.querySelectorAll('mtable,annotation,annotation-xml').forEach(node => node.remove());
|
|
105
|
+
if (!/^[\s()[\]{}|‖]*$/.test(surrounding.textContent || ''))
|
|
106
|
+
continue;
|
|
107
|
+
const rows = [...matrix.children];
|
|
108
|
+
if (!rows.length || rows.some(row => !row.matches('mtr')))
|
|
109
|
+
continue;
|
|
110
|
+
const cells = rows.map(row => [...row.children].map(cell => {
|
|
111
|
+
if (!cell.matches('mtd'))
|
|
112
|
+
return '';
|
|
113
|
+
const value = (cell.textContent || '').trim().replace(/−/g, '-').replace(/^([+-])\s+/, '$1');
|
|
114
|
+
return /^[+-]?\d+(?:\.\d+)?$/.test(value) ? value : '';
|
|
115
|
+
}));
|
|
116
|
+
if (!cells[0].length || cells.some(row => row.length !== cells[0].length || row.some(value => !value)))
|
|
117
|
+
continue;
|
|
118
|
+
const renderer = math.closest('.katex,mjx-container') || math;
|
|
119
|
+
renderer.replaceWith(doc.createTextNode(` ${JSON.stringify(cells).replace(/"/g, '')} `));
|
|
120
|
+
}
|
|
121
|
+
};
|
|
97
122
|
const textOf = (element) => {
|
|
98
123
|
if (!element)
|
|
99
124
|
return '';
|
|
100
125
|
const clone = element.cloneNode(true);
|
|
126
|
+
preserveMatrices(clone);
|
|
101
127
|
const editors = element.querySelectorAll('.ace_editor,.CodeMirror,.monaco-editor');
|
|
102
128
|
[...clone.querySelectorAll('.ace_editor,.CodeMirror,.monaco-editor')].forEach((block, index) => {
|
|
103
129
|
const source = typeof browserAssistantCachedMcqSnippet === 'function'
|
|
@@ -222,8 +248,8 @@
|
|
|
222
248
|
// Coding MCQs often render the code separately from the prose
|
|
223
249
|
// question. Preserve that visible context before asking the AI.
|
|
224
250
|
const questionRegion = optionScope.closest('[data-question],article,section,main') || optionScope.parentElement;
|
|
225
|
-
const promptRegion = questionRegion?.querySelector('#generic-question-wrapper-id,[data-question-text]')
|
|
226
|
-
|| doc.querySelector('#generic-question-wrapper-id');
|
|
251
|
+
const promptRegion = questionRegion?.querySelector('#generic-question-wrapper-id,#exam-question-wrapper-id,[data-question-text]')
|
|
252
|
+
|| doc.querySelector('#generic-question-wrapper-id,#exam-question-wrapper-id');
|
|
227
253
|
const sourceRegion = promptRegion || questionRegion;
|
|
228
254
|
const snippets = [...(sourceRegion?.querySelectorAll('pre,code,.ace_editor,.CodeMirror,.monaco-editor') || [])]
|
|
229
255
|
.filter(node => visible(node) && !node.parentElement?.closest('pre,code,.ace_editor,.CodeMirror,.monaco-editor'));
|
|
@@ -240,6 +266,7 @@
|
|
|
240
266
|
// Preserve filenames, prose and ALL snippets together; the
|
|
241
267
|
// last code block alone is not the question's full context.
|
|
242
268
|
const clone = promptRegion.cloneNode(true);
|
|
269
|
+
preserveMatrices(clone);
|
|
243
270
|
for (const block of clone.querySelectorAll('pre,.ace_editor,.CodeMirror,.monaco-editor')) {
|
|
244
271
|
const plain = doc.createElement('pre');
|
|
245
272
|
plain.textContent = '\n' + codeText(block) + '\n';
|
package/extension/background.js
CHANGED
|
@@ -61,14 +61,122 @@ async function attachUpdatedPanels() {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
chrome.alarms.create('assistant-workspace-update', { periodInMinutes: 1 });
|
|
64
|
-
chrome.alarms.onAlarm.addListener(alarm => {
|
|
65
|
-
|
|
64
|
+
chrome.alarms.onAlarm.addListener(alarm => {
|
|
65
|
+
if (alarm.name !== 'assistant-workspace-update')
|
|
66
|
+
return;
|
|
67
|
+
void checkWorkspaceUpdate();
|
|
68
|
+
void checkJournalReminder();
|
|
69
|
+
void updateJournalBadge();
|
|
70
|
+
});
|
|
66
71
|
chrome.runtime.onInstalled.addListener(() => { void attachUpdatedPanels(); });
|
|
72
|
+
let journalBadgeRevision = 0;
|
|
73
|
+
function activeJournalTimers(data, now = Date.now()) {
|
|
74
|
+
const prefix = 'assistantJournalTimer:';
|
|
75
|
+
const timers = new Map();
|
|
76
|
+
const last = data.assistantJournalTimer;
|
|
77
|
+
if (typeof last?.path === 'string')
|
|
78
|
+
timers.set(last.path.replace(/\/$/, ''), last.deadline);
|
|
79
|
+
for (const [key, deadline] of Object.entries(data)) {
|
|
80
|
+
if (key.startsWith(prefix) && typeof deadline === 'number')
|
|
81
|
+
timers.set(key.slice(prefix.length), deadline);
|
|
82
|
+
}
|
|
83
|
+
return [...timers].filter(([path, deadline]) => /^\/modules\/reflective-journal\/[^/]+$/.test(path) &&
|
|
84
|
+
Number.isFinite(deadline) && deadline > now && !data[`assistantJournalConfirmed:${path}`])
|
|
85
|
+
.map(([path, deadline]) => ({ path, deadline })).sort((a, b) => a.deadline - b.deadline);
|
|
86
|
+
}
|
|
87
|
+
async function updateJournalBadge() {
|
|
88
|
+
const storage = chrome.storage?.local;
|
|
89
|
+
if (!storage || !chrome.action)
|
|
90
|
+
return;
|
|
91
|
+
const revision = ++journalBadgeRevision;
|
|
92
|
+
try {
|
|
93
|
+
const timers = activeJournalTimers(await storage.get());
|
|
94
|
+
const remaining = timers[0]?.deadline - Date.now();
|
|
95
|
+
if (revision !== journalBadgeRevision)
|
|
96
|
+
return;
|
|
97
|
+
const minutes = Math.ceil(remaining / 60000);
|
|
98
|
+
const active = Number.isFinite(minutes) && minutes > 0;
|
|
99
|
+
const text = active ? (remaining < 60000 ? '<1m' : minutes > 999 ? '999+' : `${minutes}m`) : '';
|
|
100
|
+
await Promise.all([
|
|
101
|
+
chrome.action.setBadgeText({ text }),
|
|
102
|
+
chrome.action.setBadgeBackgroundColor({ color: '#5b39db' }),
|
|
103
|
+
chrome.action.setTitle({ title: active ? `Browser Assistant · ${timers.length > 1 ? 'Next journal' : 'Journal'}: about ${minutes} min left` : 'Browser Assistant' }),
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
catch { /* Retry when the timer changes or on the next alarm. */ }
|
|
107
|
+
}
|
|
108
|
+
chrome.storage?.local.onChanged?.addListener(changes => {
|
|
109
|
+
if (Object.keys(changes).some(key => key.startsWith('assistantJournalTimer') || key.startsWith('assistantJournalConfirmed:'))) {
|
|
110
|
+
void updateJournalBadge();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
void updateJournalBadge();
|
|
114
|
+
let checkingJournalReminder = false;
|
|
115
|
+
async function checkJournalReminder() {
|
|
116
|
+
if (checkingJournalReminder)
|
|
117
|
+
return;
|
|
118
|
+
checkingJournalReminder = true;
|
|
119
|
+
try {
|
|
120
|
+
const storage = chrome.storage.local;
|
|
121
|
+
const data = await storage.get();
|
|
122
|
+
const config = data.assistantJournalReminderConfig;
|
|
123
|
+
if (!config?.url || !config?.token)
|
|
124
|
+
return;
|
|
125
|
+
await Promise.all(activeJournalTimers(data).map(timer => sendJournalReminder(timer, config)));
|
|
126
|
+
}
|
|
127
|
+
catch { /* The minute alarm keeps timer checks independent of workspace updates. */ }
|
|
128
|
+
finally {
|
|
129
|
+
checkingJournalReminder = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function sendJournalReminder(timer, config) {
|
|
133
|
+
try {
|
|
134
|
+
const storage = chrome.storage.local;
|
|
135
|
+
const remaining = timer.deadline - Date.now();
|
|
136
|
+
// Give the portal time to confirm an existing submission at 30:00.
|
|
137
|
+
if (remaining > (30 * 60 - 45) * 1000 || remaining <= 0)
|
|
138
|
+
return;
|
|
139
|
+
const stateKey = `assistantJournalReminder:${timer.path}`;
|
|
140
|
+
const confirmationKey = `assistantJournalConfirmed:${timer.path}`;
|
|
141
|
+
const previous = await storage.get([stateKey, confirmationKey]);
|
|
142
|
+
if (previous[stateKey] || previous[confirmationKey])
|
|
143
|
+
return;
|
|
144
|
+
// Reserve the one attempt before the network call. A timed-out webhook
|
|
145
|
+
// can still have sent a message, so replay would risk a duplicate.
|
|
146
|
+
await storage.set({ [stateKey]: 'sending' });
|
|
147
|
+
const latest = await storage.get();
|
|
148
|
+
const current = activeJournalTimers(latest).find(candidate => candidate.path === timer.path);
|
|
149
|
+
if (!current || Math.abs(current.deadline - timer.deadline) > 2000 ||
|
|
150
|
+
latest.assistantJournalReminderConfig?.url !== config.url || latest.assistantJournalReminderConfig.token !== config.token) {
|
|
151
|
+
await storage.remove(stateKey);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
let result = 'failed';
|
|
155
|
+
try {
|
|
156
|
+
const response = await fetch(config.url, {
|
|
157
|
+
method: 'POST', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15000),
|
|
158
|
+
headers: { 'Content-Type': 'application/json', 'X-Journal-Reminder-Token': config.token },
|
|
159
|
+
body: JSON.stringify({ event: 'journal_submission_unconfirmed' }),
|
|
160
|
+
});
|
|
161
|
+
if (response.ok)
|
|
162
|
+
result = 'sent';
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await storage.set({ [stateKey]: result });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch { /* One failed request does not interrupt the other journals. */ }
|
|
169
|
+
}
|
|
67
170
|
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|
68
171
|
if (!request || typeof request.action !== 'string') {
|
|
69
172
|
sendResponse({ error: 'Invalid extension message' });
|
|
70
173
|
return false;
|
|
71
174
|
}
|
|
175
|
+
if (request.action === 'journalCountdowns' && sender.id === chrome.runtime.id) {
|
|
176
|
+
chrome.storage.local.get().then(data => sendResponse(activeJournalTimers(data)))
|
|
177
|
+
.catch(() => sendResponse([]));
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
72
180
|
if (request.action === 'questionImage') {
|
|
73
181
|
let url;
|
|
74
182
|
try {
|
package/extension/content.js
CHANGED
|
@@ -1,4 +1,63 @@
|
|
|
1
1
|
// Browser Assistant Content Script v3.0 — High Accuracy Edition
|
|
2
|
+
function mountJournalCountdown(signal) {
|
|
3
|
+
const storage = chrome.storage.local;
|
|
4
|
+
const badge = document.createElement('div');
|
|
5
|
+
badge.id = 'assistant-journal-countdown-badge';
|
|
6
|
+
const clock = document.createElement('span');
|
|
7
|
+
clock.setAttribute('role', 'timer');
|
|
8
|
+
badge.attachShadow({ mode: 'closed' }).append(clock);
|
|
9
|
+
let timers = [];
|
|
10
|
+
let tick;
|
|
11
|
+
let revision = 0;
|
|
12
|
+
function renderCountdown() {
|
|
13
|
+
window.clearTimeout(tick);
|
|
14
|
+
if (signal.aborted)
|
|
15
|
+
return;
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
const active = timers.filter(timer => timer.deadline > now);
|
|
18
|
+
const path = location.pathname.replace(/\/$/, '');
|
|
19
|
+
const aurora = location.origin === 'https://admissions.aurora.edu.in';
|
|
20
|
+
const visible = active.filter(timer => !aurora || (path.startsWith('/modules/reflective-journal/') && timer.path !== path));
|
|
21
|
+
if (!visible.length)
|
|
22
|
+
badge.remove();
|
|
23
|
+
else {
|
|
24
|
+
const times = visible.map(timer => {
|
|
25
|
+
const seconds = Math.ceil((timer.deadline - now) / 1000);
|
|
26
|
+
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
|
27
|
+
});
|
|
28
|
+
clock.textContent = `${times.length === 1 ? 'Journal time left' : 'Journal times left'} ≈ ${times.join(' · ')}`;
|
|
29
|
+
if (!badge.isConnected)
|
|
30
|
+
document.body.appendChild(badge);
|
|
31
|
+
}
|
|
32
|
+
if (active.length && !document.hidden)
|
|
33
|
+
tick = window.setTimeout(renderCountdown, 1000);
|
|
34
|
+
}
|
|
35
|
+
async function refreshCountdown() {
|
|
36
|
+
const request = ++revision;
|
|
37
|
+
try {
|
|
38
|
+
const next = await chrome.runtime.sendMessage({ action: 'journalCountdowns' });
|
|
39
|
+
if (request !== revision || signal.aborted)
|
|
40
|
+
return;
|
|
41
|
+
timers = Array.isArray(next) ? next : [];
|
|
42
|
+
renderCountdown();
|
|
43
|
+
}
|
|
44
|
+
catch { /* Reloading the extension can invalidate the old tab context. */ }
|
|
45
|
+
}
|
|
46
|
+
const onStorageChange = (changes) => {
|
|
47
|
+
if (Object.keys(changes).some(key => key.startsWith('assistantJournalTimer') || key.startsWith('assistantJournalConfirmed:'))) {
|
|
48
|
+
void refreshCountdown();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
storage.onChanged.addListener(onStorageChange);
|
|
52
|
+
document.addEventListener('visibilitychange', renderCountdown, { signal });
|
|
53
|
+
signal.addEventListener('abort', () => {
|
|
54
|
+
window.clearTimeout(tick);
|
|
55
|
+
storage.onChanged.removeListener(onStorageChange);
|
|
56
|
+
badge.remove();
|
|
57
|
+
}, { once: true });
|
|
58
|
+
void refreshCountdown();
|
|
59
|
+
return badge;
|
|
60
|
+
}
|
|
2
61
|
(function () {
|
|
3
62
|
// Abort on pages without a body (about:blank, PDFs, XML docs, etc.)
|
|
4
63
|
if (!document.body)
|
|
@@ -22,6 +81,8 @@
|
|
|
22
81
|
const root = document.createElement('div');
|
|
23
82
|
root.id = 'assistant-root';
|
|
24
83
|
document.body.appendChild(root);
|
|
84
|
+
const journalCountdownBadge = ['http:', 'https:'].includes(location.protocol)
|
|
85
|
+
? mountJournalCountdown(lifecycle.signal) : null;
|
|
25
86
|
// ── Move panel ───────────────────────────────────────────────────────────
|
|
26
87
|
// Drag the header to put the panel anywhere on the current page. The saved
|
|
27
88
|
// location is restored after a refresh on that site.
|
|
@@ -2077,10 +2138,13 @@
|
|
|
2077
2138
|
setLoading("Capturing screen…");
|
|
2078
2139
|
let timeoutId;
|
|
2079
2140
|
let panelVisibility = '';
|
|
2141
|
+
const countdownVisibility = journalCountdownBadge?.style.visibility;
|
|
2080
2142
|
try {
|
|
2081
|
-
//
|
|
2143
|
+
// Keep extension UI out of the captured screenshot.
|
|
2082
2144
|
panelVisibility = root.style.visibility;
|
|
2083
2145
|
root.style.visibility = 'hidden';
|
|
2146
|
+
if (journalCountdownBadge)
|
|
2147
|
+
journalCountdownBadge.style.visibility = 'hidden';
|
|
2084
2148
|
const context = await prepareVisionQuestionContext();
|
|
2085
2149
|
const visuals = window.BrowserAssistantQuestionVisuals;
|
|
2086
2150
|
let images;
|
|
@@ -2098,6 +2162,8 @@
|
|
|
2098
2162
|
images = [response.dataUrl];
|
|
2099
2163
|
}
|
|
2100
2164
|
root.style.visibility = panelVisibility;
|
|
2165
|
+
if (journalCountdownBadge)
|
|
2166
|
+
journalCountdownBadge.style.visibility = countdownVisibility;
|
|
2101
2167
|
await assertVisionQuestionCurrent(context);
|
|
2102
2168
|
setLoading("Analyzing and cross-checking…");
|
|
2103
2169
|
const res = await apiRequest('/solve-vision', 'POST', {
|
|
@@ -2140,6 +2206,8 @@
|
|
|
2140
2206
|
}
|
|
2141
2207
|
finally {
|
|
2142
2208
|
root.style.visibility = panelVisibility;
|
|
2209
|
+
if (journalCountdownBadge)
|
|
2210
|
+
journalCountdownBadge.style.visibility = countdownVisibility;
|
|
2143
2211
|
// Text Scan can hand off to Vision without a visible error. In that
|
|
2144
2212
|
// case, leave its in-progress state for the caller to clear.
|
|
2145
2213
|
if (!scanWasAlreadyRunning) {
|
|
@@ -2509,6 +2577,7 @@
|
|
|
2509
2577
|
<div><dt>Progress</dt><dd id="assistant-automation-progress">0 / —</dd></div>
|
|
2510
2578
|
<div class="assistant-qa-wide"><dt>Current question</dt><dd id="assistant-automation-question">—</dd></div>
|
|
2511
2579
|
</dl>
|
|
2580
|
+
<div class="assistant-qa-latest" id="assistant-journal-list-clock" hidden></div>
|
|
2512
2581
|
<div class="assistant-qa-latest" id="assistant-automation-latest" aria-live="polite"></div>
|
|
2513
2582
|
<div id="assistant-journal-slot"></div>
|
|
2514
2583
|
<div class="assistant-automation-actions assistant-qa-actions">
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
function formatJournalTime(seconds) {
|
|
2
|
+
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
|
3
|
+
}
|
|
1
4
|
function browserAssistantJournal(global) {
|
|
2
5
|
'use strict';
|
|
3
6
|
const keys = ['experience', 'feelings', 'learning', 'application', 'conclusion'];
|
|
@@ -84,10 +87,10 @@ function browserAssistantJournal(global) {
|
|
|
84
87
|
}
|
|
85
88
|
if (explicit.size)
|
|
86
89
|
return explicit.size === 1 ? [...explicit][0] : null;
|
|
87
|
-
for (const node of doc.querySelectorAll('span,div,p,output,strong,b')) {
|
|
90
|
+
for (const node of doc.querySelectorAll('span,div,p,output,strong,b,time,label')) {
|
|
88
91
|
if (excluded(node) || !node.getClientRects().length)
|
|
89
92
|
continue;
|
|
90
|
-
const value = labeled(node.textContent);
|
|
93
|
+
const value = labeled(node.innerText ?? node.textContent);
|
|
91
94
|
if (value !== null)
|
|
92
95
|
values.add(value);
|
|
93
96
|
}
|
|
@@ -130,7 +133,7 @@ function browserAssistantJournal(global) {
|
|
|
130
133
|
panel.id = 'assistant-journal';
|
|
131
134
|
panel.dataset.build = journalBuild;
|
|
132
135
|
panel.style.cssText = 'position:fixed;right:16px;top:160px;width:290px;z-index:2147483646;background:white;color:#17233d;padding:16px;border:2px solid #7638ff;border-radius:12px;box-shadow:0 4px 20px #0003;font:14px Arial';
|
|
133
|
-
panel.innerHTML = '<strong>Reflective Journal</strong><p data-info></p><textarea aria-label="Your class experience and feelings" placeholder="Your actual class experience / feelings (optional)" rows="3" style="width:100%;box-sizing:border-box"></textarea><button type="button" style="margin-top:8px;padding:10px">Fill & Submit Journal</button><p role="status" aria-live="polite"></p>';
|
|
136
|
+
panel.innerHTML = '<strong>Reflective Journal</strong><p data-info></p><p data-countdown style="font-weight:600;margin:8px 0"></p><textarea aria-label="Your class experience and feelings" placeholder="Your actual class experience / feelings (optional)" rows="3" style="width:100%;box-sizing:border-box"></textarea><button type="button" style="margin-top:8px;padding:10px">Fill & Submit Journal</button><p role="status" aria-live="polite"></p>';
|
|
134
137
|
document.body.appendChild(panel);
|
|
135
138
|
const controls = document.createElement('div');
|
|
136
139
|
controls.innerHTML = '<label>Subject <select data-subject aria-label="Journal subject"><option value="">Detect from page</option><option>Backend Development Fundamentals</option><option>Computer Organization and Architecture</option><option>Advanced Data Structures</option><option>Linear Algebra Essentials</option><option>Fundamentals of Artificial Intelligence</option></select></label><label>Module / Journal <select data-number aria-label="Journal number"><option value="">Detect from page</option>' + Array.from({ length: 10 }, (_, i) => `<option value="${i + 1}">${i + 1}</option>`).join('') + '</select></label>';
|
|
@@ -140,6 +143,93 @@ function browserAssistantJournal(global) {
|
|
|
140
143
|
select.style.cssText = 'display:block;width:100%;padding:5px;color:#17233d;background:white;border:1px solid #abb3c3';
|
|
141
144
|
panel.insertBefore(controls, panel.querySelector('textarea'));
|
|
142
145
|
const status = panel.querySelector('[role=status]'), button = panel.querySelector('button');
|
|
146
|
+
const countdown = panel.querySelector('[data-countdown]');
|
|
147
|
+
const countdownKey = 'assistantJournalTimer';
|
|
148
|
+
const journalPath = location.pathname.replace(/\/$/, '');
|
|
149
|
+
const journalDeadlineKey = `${countdownKey}:${journalPath}`;
|
|
150
|
+
let deadline;
|
|
151
|
+
let lastPortalTime = null;
|
|
152
|
+
let observedInForeground = false;
|
|
153
|
+
let publishedDeadline;
|
|
154
|
+
let journalConfirmed = false;
|
|
155
|
+
async function loadCountdown() {
|
|
156
|
+
try {
|
|
157
|
+
const cached = sessionStorage.getItem(journalDeadlineKey);
|
|
158
|
+
if (cached && Number.isFinite(Number(cached))) {
|
|
159
|
+
deadline = Number(cached);
|
|
160
|
+
renderCountdown(null);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch { /* Fall back to extension storage. */ }
|
|
165
|
+
try {
|
|
166
|
+
const data = await global.chrome?.storage?.local.get([journalDeadlineKey, countdownKey]);
|
|
167
|
+
const latest = data?.[countdownKey];
|
|
168
|
+
const saved = data?.[journalDeadlineKey] ?? (latest?.path?.replace(/\/$/, '') === journalPath ? latest.deadline : undefined);
|
|
169
|
+
if (deadline === undefined && Number.isFinite(saved)) {
|
|
170
|
+
deadline = saved;
|
|
171
|
+
renderCountdown(null);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch { /* The portal clock remains available if extension storage is unavailable. */ }
|
|
175
|
+
}
|
|
176
|
+
function renderCountdown(portalTime) {
|
|
177
|
+
const seconds = portalTime ?? (deadline === undefined ? null : Math.max(0, Math.ceil((deadline - Date.now()) / 1000)));
|
|
178
|
+
const label = portalTime === null ? 'Estimated time left' : 'Portal time left';
|
|
179
|
+
const restoringDraft = portalTime === null && /Unsaved draft detected\./i.test(document.body.innerText || pageText(document));
|
|
180
|
+
const clockText = seconds === null ? 'Waiting for the portal timer to appear' : `${label} ${formatJournalTime(seconds)}`;
|
|
181
|
+
const text = restoringDraft
|
|
182
|
+
? `${clockText}. To reopen your draft, review the declaration and use the portal’s Accept Terms to Start button.`
|
|
183
|
+
: seconds === null ? `${clockText}.` : clockText;
|
|
184
|
+
if (countdown.textContent !== text)
|
|
185
|
+
countdown.textContent = text;
|
|
186
|
+
}
|
|
187
|
+
async function recordJournalConfirmation() {
|
|
188
|
+
if (!journalConfirmed && (controller.getState().completed === 1 || [...document.querySelectorAll('p,h1,h2,h3,[role=status],[role=alert]')].some(node => !node.closest('#assistant-root,#assistant-journal,[contenteditable]') && node.getClientRects().length &&
|
|
189
|
+
/^(?:journal (?:has been )?submitted successfully|journal submission successful)[.!]?$/i.test(node.textContent.trim())))) {
|
|
190
|
+
try {
|
|
191
|
+
await global.chrome?.storage?.local.set({ [`assistantJournalConfirmed:${journalPath}`]: true });
|
|
192
|
+
journalConfirmed = true;
|
|
193
|
+
}
|
|
194
|
+
catch { /* Retry on the next tick while the confirmation is available. */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function updateCountdown() {
|
|
198
|
+
if (!document.contains(panel) || storageKey !== `assistantJournal:${location.pathname}`)
|
|
199
|
+
return;
|
|
200
|
+
const observed = remainingSeconds(document);
|
|
201
|
+
// The editor can briefly render zero before the active clock loads.
|
|
202
|
+
const portalTime = observed === 0 && lastPortalTime === null ? null : observed;
|
|
203
|
+
const visible = !document.hidden;
|
|
204
|
+
const returnedToJournal = visible && !observedInForeground;
|
|
205
|
+
observedInForeground = visible;
|
|
206
|
+
if (portalTime !== null && portalTime !== lastPortalTime) {
|
|
207
|
+
deadline = Date.now() + portalTime * 1000;
|
|
208
|
+
lastPortalTime = portalTime;
|
|
209
|
+
// Keep the observed deadline through an immediate same-tab reload,
|
|
210
|
+
// even if the extension storage write is still in flight.
|
|
211
|
+
try {
|
|
212
|
+
sessionStorage.setItem(journalDeadlineKey, String(deadline));
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
}
|
|
216
|
+
renderCountdown(portalTime);
|
|
217
|
+
if (!visible || portalTime === null || deadline === undefined ||
|
|
218
|
+
(!returnedToJournal && publishedDeadline !== undefined && Math.abs(deadline - publishedDeadline) <= 2000))
|
|
219
|
+
return;
|
|
220
|
+
const pendingDeadline = deadline;
|
|
221
|
+
publishedDeadline = pendingDeadline;
|
|
222
|
+
try {
|
|
223
|
+
await global.chrome?.storage?.local.set({
|
|
224
|
+
[journalDeadlineKey]: pendingDeadline,
|
|
225
|
+
[countdownKey]: { path: journalPath, deadline: pendingDeadline },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
if (publishedDeadline === pendingDeadline)
|
|
230
|
+
publishedDeadline = undefined;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
143
233
|
button.title = 'Fill & Submit Journal (Alt + Shift + J)';
|
|
144
234
|
button.setAttribute('aria-keyshortcuts', 'Alt+Shift+J');
|
|
145
235
|
const shortcutHint = document.createElement('p');
|
|
@@ -319,7 +409,7 @@ function browserAssistantJournal(global) {
|
|
|
319
409
|
if (remaining === null || remaining > 30 * 60) {
|
|
320
410
|
waitingForTimer = true;
|
|
321
411
|
const time = remaining === null ? 'Waiting for the portal timer to become readable.'
|
|
322
|
-
: `Current timer: ${
|
|
412
|
+
: `Current timer: ${formatJournalTime(remaining)}.`;
|
|
323
413
|
status.textContent = `${count} words ready. Waiting to submit at 30:00 remaining. ${time}`;
|
|
324
414
|
return;
|
|
325
415
|
}
|
|
@@ -447,6 +537,14 @@ function browserAssistantJournal(global) {
|
|
|
447
537
|
}
|
|
448
538
|
});
|
|
449
539
|
refreshInfo();
|
|
540
|
+
void loadCountdown();
|
|
541
|
+
void updateCountdown();
|
|
542
|
+
const clockObserver = global.MutationObserver ? new global.MutationObserver(records => {
|
|
543
|
+
if (records.some(({ target }) => !(target.nodeType === 1 ? target : target.parentElement)?.closest('#assistant-root,#assistant-journal'))) {
|
|
544
|
+
void updateCountdown();
|
|
545
|
+
}
|
|
546
|
+
}) : null;
|
|
547
|
+
clockObserver?.observe(document.body, { subtree: true, childList: true, characterData: true });
|
|
450
548
|
controller.mount(document.getElementById('assistant-journal-slot'));
|
|
451
549
|
document.dispatchEvent(new global.Event('assistant-journal-ready'));
|
|
452
550
|
try {
|
|
@@ -460,27 +558,20 @@ function browserAssistantJournal(global) {
|
|
|
460
558
|
// Poll only the journal page; field discovery never changes platform restrictions.
|
|
461
559
|
const timer = setInterval(() => {
|
|
462
560
|
if (global.BrowserAssistantJournal?.current !== controller && global.BrowserAssistantJournal) {
|
|
463
|
-
|
|
464
|
-
document.removeEventListener('keydown', shortcutListener, true);
|
|
561
|
+
stopWatching();
|
|
465
562
|
return;
|
|
466
563
|
}
|
|
467
|
-
if (!document.contains(panel)) {
|
|
564
|
+
if (!document.contains(panel) || storageKey !== `assistantJournal:${location.pathname}`) {
|
|
468
565
|
draft = null;
|
|
469
566
|
global.BrowserAssistantJournalBusy = false;
|
|
470
|
-
|
|
471
|
-
clearInterval(timer);
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
if (!/^\/modules\/reflective-journal\//.test(location.pathname) || storageKey !== `assistantJournal:${location.pathname}`) {
|
|
475
|
-
draft = null;
|
|
476
|
-
global.BrowserAssistantJournalBusy = false;
|
|
477
|
-
document.removeEventListener('keydown', shortcutListener, true);
|
|
478
|
-
clearInterval(timer);
|
|
567
|
+
stopWatching();
|
|
479
568
|
panel.remove();
|
|
480
569
|
return;
|
|
481
570
|
}
|
|
482
571
|
if (!info)
|
|
483
572
|
refreshInfo();
|
|
573
|
+
void updateCountdown();
|
|
574
|
+
void recordJournalConfirmation();
|
|
484
575
|
notify();
|
|
485
576
|
if (draft && !submitting && !starting) {
|
|
486
577
|
const run = generation;
|
|
@@ -499,6 +590,11 @@ function browserAssistantJournal(global) {
|
|
|
499
590
|
});
|
|
500
591
|
}
|
|
501
592
|
}, 1000);
|
|
593
|
+
function stopWatching() {
|
|
594
|
+
clearInterval(timer);
|
|
595
|
+
clockObserver?.disconnect();
|
|
596
|
+
document.removeEventListener('keydown', shortcutListener, true);
|
|
597
|
+
}
|
|
502
598
|
}
|
|
503
599
|
if (typeof window !== 'undefined' && location.origin === 'https://admissions.aurora.edu.in') {
|
|
504
600
|
window.BrowserAssistantJournal = window.BrowserAssistantJournal || {
|
|
@@ -552,7 +648,36 @@ if (typeof window !== 'undefined' && location.origin === 'https://admissions.aur
|
|
|
552
648
|
browserAssistantJournal(typeof window === 'undefined' ? globalThis : window);
|
|
553
649
|
if (typeof window !== 'undefined' && location.origin === 'https://admissions.aurora.edu.in' && !window.BrowserAssistantJournalBoot) {
|
|
554
650
|
window.BrowserAssistantJournalBoot = true;
|
|
651
|
+
let lastDeadline;
|
|
652
|
+
const storage = window.chrome?.storage?.local;
|
|
653
|
+
let clockChanged = false;
|
|
654
|
+
function updateJournalListClock() {
|
|
655
|
+
const clock = document.getElementById('assistant-journal-list-clock');
|
|
656
|
+
if (!clock)
|
|
657
|
+
return;
|
|
658
|
+
clock.hidden = /^\/modules\/reflective-journal\//.test(location.pathname) || !Number.isFinite(lastDeadline);
|
|
659
|
+
if (!clock.hidden) {
|
|
660
|
+
const seconds = Math.max(0, Math.ceil((lastDeadline - Date.now()) / 1000));
|
|
661
|
+
clock.textContent = `Last journal · Estimated time left ${formatJournalTime(seconds)}`;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function loadJournalListClock(saved) {
|
|
665
|
+
lastDeadline = typeof saved === 'object' && saved !== null && 'deadline' in saved && typeof saved.deadline === 'number'
|
|
666
|
+
? saved.deadline : undefined;
|
|
667
|
+
updateJournalListClock();
|
|
668
|
+
}
|
|
669
|
+
storage?.onChanged?.addListener(changes => {
|
|
670
|
+
if (!Object.hasOwn(changes, 'assistantJournalTimer'))
|
|
671
|
+
return;
|
|
672
|
+
clockChanged = true;
|
|
673
|
+
loadJournalListClock(changes.assistantJournalTimer.newValue);
|
|
674
|
+
});
|
|
675
|
+
void storage?.get('assistantJournalTimer').then(data => {
|
|
676
|
+
if (!clockChanged)
|
|
677
|
+
loadJournalListClock(data.assistantJournalTimer);
|
|
678
|
+
}).catch(() => { });
|
|
555
679
|
setInterval(() => {
|
|
680
|
+
updateJournalListClock();
|
|
556
681
|
if (/^\/modules\/reflective-journal\//.test(location.pathname) && !document.getElementById('assistant-journal'))
|
|
557
682
|
browserAssistantJournal(window);
|
|
558
683
|
}, 1000);
|
package/extension/manifest.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Browser Assistant",
|
|
4
|
-
|
|
4
|
+
"version": "2.8.21",
|
|
5
5
|
"description": "Scans webpages for MCQs and coding problems. Powered by Groq (cloud) and Ollama (local fallback).",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"activeTab",
|
|
8
8
|
"scripting",
|
|
9
9
|
"tabs",
|
|
10
10
|
"clipboardWrite",
|
|
11
|
+
"storage",
|
|
11
12
|
"alarms"
|
|
12
13
|
],
|
|
13
14
|
"background": {
|
package/extension/popup.html
CHANGED
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
#server-status { color: #f59e0b; }
|
|
40
40
|
#server-status.online { color: #10b981; }
|
|
41
41
|
#server-status.offline { color: #ef4444; }
|
|
42
|
+
#journal-reminder { margin: 10px 0; font-size: 11px; }
|
|
43
|
+
#journal-reminder summary { cursor: pointer; }
|
|
44
|
+
#journal-reminder label { display: block; margin: 7px 0; }
|
|
45
|
+
#journal-reminder input { box-sizing: border-box; width: 100%; }
|
|
46
|
+
#journal-reminder-status { margin-top: 6px; }
|
|
42
47
|
</style>
|
|
43
48
|
</head>
|
|
44
49
|
<body>
|
|
@@ -48,6 +53,17 @@
|
|
|
48
53
|
</div>
|
|
49
54
|
<p>Extension active. The floating panel appears on every page.</p>
|
|
50
55
|
<div class="status">Server: <span id="server-status">checking...</span></div>
|
|
56
|
+
<p id="journal-countdown" hidden></p>
|
|
57
|
+
<details id="journal-reminder">
|
|
58
|
+
<summary>WhatsApp journal reminder</summary>
|
|
59
|
+
<form id="journal-reminder-form">
|
|
60
|
+
<label>n8n production webhook URL<input id="journal-webhook-url" type="url" required></label>
|
|
61
|
+
<label>Webhook token<input id="journal-webhook-token" type="password" required autocomplete="off"></label>
|
|
62
|
+
<button type="submit">Enable</button>
|
|
63
|
+
<button type="button" id="journal-reminder-disable">Disable</button>
|
|
64
|
+
</form>
|
|
65
|
+
<p id="journal-reminder-status" role="status">Checking settings…</p>
|
|
66
|
+
</details>
|
|
51
67
|
<div class="shortcuts">
|
|
52
68
|
<div class="shortcut-row"><span>Open panel</span> <kbd>`</kbd></div>
|
|
53
69
|
<div class="shortcut-row"><span>Close panel</span> <kbd>]</kbd></div>
|
package/extension/popup.js
CHANGED
|
@@ -17,3 +17,79 @@ new Promise((resolve, reject) => {
|
|
|
17
17
|
el.textContent = 'offline';
|
|
18
18
|
el.className = 'offline';
|
|
19
19
|
});
|
|
20
|
+
const journalCountdown = document.getElementById('journal-countdown');
|
|
21
|
+
let journalTimers = [];
|
|
22
|
+
function showJournalCountdown() {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const times = journalTimers.filter(timer => timer.deadline > now).map(timer => {
|
|
25
|
+
const seconds = Math.ceil((timer.deadline - now) / 1000);
|
|
26
|
+
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
|
27
|
+
});
|
|
28
|
+
journalCountdown.hidden = !times.length;
|
|
29
|
+
journalCountdown.textContent = times.length ? `Journal time left ≈ ${times.join(' · ')}` : '';
|
|
30
|
+
}
|
|
31
|
+
const journalTimerStorage = chrome.storage.local;
|
|
32
|
+
let journalTimerRevision = 0;
|
|
33
|
+
async function refreshPopupJournalTimers() {
|
|
34
|
+
const request = ++journalTimerRevision;
|
|
35
|
+
try {
|
|
36
|
+
const timers = await chrome.runtime.sendMessage({ action: 'journalCountdowns' });
|
|
37
|
+
if (request !== journalTimerRevision)
|
|
38
|
+
return;
|
|
39
|
+
journalTimers = Array.isArray(timers) ? timers : [];
|
|
40
|
+
showJournalCountdown();
|
|
41
|
+
}
|
|
42
|
+
catch { /* A closed popup no longer needs an update. */ }
|
|
43
|
+
}
|
|
44
|
+
journalTimerStorage.onChanged.addListener(changes => {
|
|
45
|
+
if (Object.keys(changes).some(key => key.startsWith('assistantJournalTimer') || key.startsWith('assistantJournalConfirmed:'))) {
|
|
46
|
+
void refreshPopupJournalTimers();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
void refreshPopupJournalTimers();
|
|
50
|
+
setInterval(showJournalCountdown, 1000);
|
|
51
|
+
const reminderForm = document.querySelector('#journal-reminder-form');
|
|
52
|
+
if (reminderForm) {
|
|
53
|
+
const key = 'assistantJournalReminderConfig';
|
|
54
|
+
const urlInput = document.querySelector('#journal-webhook-url');
|
|
55
|
+
const tokenInput = document.querySelector('#journal-webhook-token');
|
|
56
|
+
const status = document.getElementById('journal-reminder-status');
|
|
57
|
+
const disable = document.querySelector('#journal-reminder-disable');
|
|
58
|
+
void journalTimerStorage.get(key).then(data => {
|
|
59
|
+
const config = data[key];
|
|
60
|
+
if (config)
|
|
61
|
+
urlInput.value = config.url;
|
|
62
|
+
status.textContent = config ? 'Reminder enabled.' : 'Reminder disabled.';
|
|
63
|
+
}).catch(() => { status.textContent = 'Could not load reminder settings.'; });
|
|
64
|
+
reminderForm.addEventListener('submit', async (event) => {
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
try {
|
|
67
|
+
const url = new URL(urlInput.value);
|
|
68
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
69
|
+
if (!(url.protocol === 'https:' || (local && url.protocol === 'http:')) || url.username || url.password ||
|
|
70
|
+
url.search || url.hash || !url.pathname.includes('/webhook/')) {
|
|
71
|
+
throw Error('Enter the n8n production webhook URL. Use HTTPS for remote n8n.');
|
|
72
|
+
}
|
|
73
|
+
const token = tokenInput.value.trim();
|
|
74
|
+
if (!/^[\x21-\x7e]{20,256}$/.test(token))
|
|
75
|
+
throw Error('Use a webhook token of at least 20 characters, with no spaces.');
|
|
76
|
+
await journalTimerStorage.set({ [key]: { url: url.href, token } });
|
|
77
|
+
tokenInput.value = '';
|
|
78
|
+
status.textContent = 'Reminder enabled.';
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
status.textContent = error.message;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
disable.addEventListener('click', async () => {
|
|
85
|
+
try {
|
|
86
|
+
await journalTimerStorage.remove(key);
|
|
87
|
+
urlInput.value = '';
|
|
88
|
+
tokenInput.value = '';
|
|
89
|
+
status.textContent = 'Reminder disabled.';
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
status.textContent = 'Could not disable reminder.';
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
package/extension/styles.css
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
/* Browser Assistant – Styles v2.0 */
|
|
2
2
|
|
|
3
|
+
#assistant-journal-countdown-badge {
|
|
4
|
+
all: initial;
|
|
5
|
+
position: fixed;
|
|
6
|
+
bottom: 16px;
|
|
7
|
+
left: 16px;
|
|
8
|
+
z-index: 2147483647;
|
|
9
|
+
padding: 8px 12px;
|
|
10
|
+
border: 1px solid #a78bfa;
|
|
11
|
+
border-radius: 999px;
|
|
12
|
+
background: #20163b;
|
|
13
|
+
color: #fff;
|
|
14
|
+
font: 600 13px/1.3 system-ui, sans-serif;
|
|
15
|
+
font-variant-numeric: tabular-nums;
|
|
16
|
+
pointer-events: none;
|
|
17
|
+
user-select: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
#assistant-root {
|
|
4
21
|
position: fixed;
|
|
5
22
|
top: 20px;
|
package/package.json
CHANGED
package/server/main.py
CHANGED
|
@@ -24,7 +24,7 @@ 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, solve_matrix_choice,
|
|
27
|
+
answer_permutation, cross_check_text_answers, solve_matrix_choice, solve_row_parallel_choice,
|
|
28
28
|
VisionRateLimitError, VisionUnconfirmedError)
|
|
29
29
|
|
|
30
30
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
@@ -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.21", lifespan=lifespan)
|
|
195
195
|
install_local_guards(app)
|
|
196
196
|
|
|
197
197
|
@app.exception_handler(HTTPException)
|
|
@@ -1191,10 +1191,14 @@ async def review_and_correct_code(
|
|
|
1191
1191
|
|
|
1192
1192
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
|
1193
1193
|
|
|
1194
|
-
def
|
|
1194
|
+
def local_choice_result(text, language):
|
|
1195
1195
|
item = solve_matrix_choice(text)
|
|
1196
|
+
provider = "local matrix calculation"
|
|
1197
|
+
if item is None:
|
|
1198
|
+
item = solve_row_parallel_choice(text)
|
|
1199
|
+
provider = "local tensor dimension check"
|
|
1196
1200
|
if item is not None:
|
|
1197
|
-
return {"results": json.dumps([item], ensure_ascii=False), "provider": "
|
|
1201
|
+
return {"results": json.dumps([item], ensure_ascii=False), "provider": f"{provider} [verified]",
|
|
1198
1202
|
"language": language, "answer_verified": True, "code_reviewed": False}
|
|
1199
1203
|
|
|
1200
1204
|
|
|
@@ -1241,13 +1245,13 @@ REPAIR MODE:
|
|
|
1241
1245
|
strict_code_review = require_code and (content.review_before_submit or not FAST_CODE_MODE or STRICT_REPAIR_REVIEW)
|
|
1242
1246
|
verify_answers = not require_code and (content.verify_answer or not FAST_SCAN_MODE)
|
|
1243
1247
|
if not require_code and content.practice_type in {'auto', 'mcq', 'math', 'aptitude'}:
|
|
1244
|
-
|
|
1245
|
-
if
|
|
1246
|
-
return
|
|
1248
|
+
local_result = local_choice_result(trimmed_text, language)
|
|
1249
|
+
if local_result is not None:
|
|
1250
|
+
return local_result
|
|
1247
1251
|
|
|
1248
1252
|
async def confirm_answers(cleaned, source_model=None):
|
|
1249
1253
|
if not verify_answers or not any(item['type'] == 'mcq' for item in json.loads(cleaned)):
|
|
1250
|
-
return False
|
|
1254
|
+
return cleaned, False
|
|
1251
1255
|
|
|
1252
1256
|
async def independent_solve(prompt_messages):
|
|
1253
1257
|
if source_model is None:
|
|
@@ -1261,14 +1265,14 @@ REPAIR MODE:
|
|
|
1261
1265
|
return await try_groq(prompt_messages, model=source_model, temperature=0.0)
|
|
1262
1266
|
|
|
1263
1267
|
try:
|
|
1264
|
-
await cross_check_text_answers(messages, cleaned, independent_solve)
|
|
1268
|
+
checked = await cross_check_text_answers(messages, cleaned, independent_solve, question_text=trimmed_text)
|
|
1265
1269
|
except Exception as exc:
|
|
1266
1270
|
if isinstance(exc, VisionUnconfirmedError):
|
|
1267
1271
|
message = str(exc)
|
|
1268
1272
|
else:
|
|
1269
1273
|
message = 'An independent solve was unavailable.'
|
|
1270
1274
|
raise HTTPException(422, detail={'code': 'answer_unconfirmed', 'message': message}) from exc
|
|
1271
|
-
return True
|
|
1275
|
+
return checked, True
|
|
1272
1276
|
|
|
1273
1277
|
# Try each Groq text model in the fallback chain
|
|
1274
1278
|
for model_id in TEXT_MODELS:
|
|
@@ -1294,7 +1298,7 @@ REPAIR MODE:
|
|
|
1294
1298
|
)
|
|
1295
1299
|
if cleaned != "[]":
|
|
1296
1300
|
logger.info(f"Groq ({model_id}) returned a validated result ({len(cleaned)} characters)")
|
|
1297
|
-
verified = await confirm_answers(cleaned, model_id)
|
|
1301
|
+
cleaned, verified = await confirm_answers(cleaned, model_id)
|
|
1298
1302
|
return {"results": cleaned, "provider": f"groq ({model_id})" + (' [cross-checked]' if verified else ''),
|
|
1299
1303
|
"language": language, "answer_verified": verified,
|
|
1300
1304
|
"code_reviewed": strict_code_review and language != 'web'}
|
|
@@ -1330,7 +1334,7 @@ REPAIR MODE:
|
|
|
1330
1334
|
starter_code=content.starter_code,
|
|
1331
1335
|
)
|
|
1332
1336
|
if cleaned != "[]":
|
|
1333
|
-
verified = await confirm_answers(cleaned)
|
|
1337
|
+
cleaned, verified = await confirm_answers(cleaned)
|
|
1334
1338
|
return {
|
|
1335
1339
|
"results": cleaned,
|
|
1336
1340
|
"provider": f"ollama ({OLLAMA_MODEL})" + (' [cross-checked]' if verified else ''),
|
|
@@ -1409,9 +1413,9 @@ async def _solve_vision(content: VisionContent):
|
|
|
1409
1413
|
raise HTTPException(422, f'Image {index}: {exc}') from exc
|
|
1410
1414
|
is_code_scan = content.practice_type == "code" or (content.practice_type == "auto" and bool(content.language))
|
|
1411
1415
|
if not is_code_scan and content.practice_type in {'auto', 'mcq', 'math', 'aptitude'}:
|
|
1412
|
-
|
|
1413
|
-
if
|
|
1414
|
-
return
|
|
1416
|
+
local_result = local_choice_result(content.text, content.language or CODE_DEFAULT_LANGUAGE)
|
|
1417
|
+
if local_result is not None:
|
|
1418
|
+
return local_result
|
|
1415
1419
|
image_blocks, image_positions = unique_image_blocks(compressed)
|
|
1416
1420
|
|
|
1417
1421
|
clients = tuple(_groq_clients)
|
package/server/vision_images.py
CHANGED
|
@@ -196,21 +196,61 @@ def numeric_matrix_key(value):
|
|
|
196
196
|
return None
|
|
197
197
|
|
|
198
198
|
|
|
199
|
-
def
|
|
199
|
+
def numeric_matrix(value):
|
|
200
200
|
rows = matrix_cells(value)
|
|
201
|
-
if not rows or len(
|
|
201
|
+
if not rows or any(len(row) != len(rows[0]) for row in rows):
|
|
202
202
|
return None
|
|
203
203
|
entries = []
|
|
204
|
-
for
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
204
|
+
for row in rows:
|
|
205
|
+
numbers = []
|
|
206
|
+
for cell in row:
|
|
207
|
+
cell = re.sub(r'^([+-]?)\\(?:[dt]?frac)\{([+-]?\d{1,15})\}\{(\d{1,15})\}$', r'\1\2/\3', cell)
|
|
208
|
+
if not re.fullmatch(rf'(?:{DECIMAL_VALUE}|[+-]?\d{{1,15}}/\d{{1,15}})', cell):
|
|
209
|
+
return None
|
|
210
|
+
try:
|
|
211
|
+
numbers.append(Fraction(cell))
|
|
212
|
+
except ZeroDivisionError:
|
|
213
|
+
return None
|
|
214
|
+
entries.append(numbers)
|
|
215
|
+
return entries
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def matrix_2x2(value):
|
|
219
|
+
rows = numeric_matrix(value)
|
|
220
|
+
if rows and len(rows) == 2 and len(rows[0]) == 2:
|
|
221
|
+
return tuple(rows[0] + rows[1])
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def rref_pivot_count(matrix):
|
|
225
|
+
rows = numeric_matrix(matrix)
|
|
226
|
+
if not rows:
|
|
227
|
+
return None
|
|
228
|
+
previous = -1
|
|
229
|
+
count = 0
|
|
230
|
+
for index, row in enumerate(rows):
|
|
231
|
+
column = next((col for col, entry in enumerate(row) if entry), None)
|
|
232
|
+
if column is None:
|
|
233
|
+
# Any remaining rows must also be zero.
|
|
234
|
+
return None if any(any(later) for later in rows[index + 1:]) else count
|
|
235
|
+
if column <= previous or row[column] != 1 or any(other[column] for i, other in enumerate(rows) if i != index):
|
|
212
236
|
return None
|
|
213
|
-
|
|
237
|
+
previous = column
|
|
238
|
+
count += 1
|
|
239
|
+
return count
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def parse_labeled_choices(option_text):
|
|
243
|
+
lines = ('A.' + option_text).splitlines()
|
|
244
|
+
if not 2 <= len(lines) <= 26:
|
|
245
|
+
return {}
|
|
246
|
+
choices = {}
|
|
247
|
+
for index, line in enumerate(lines):
|
|
248
|
+
letter = chr(ord('A') + index)
|
|
249
|
+
option = re.fullmatch(rf'{letter}\.\s*(\S.*)', line)
|
|
250
|
+
if not option:
|
|
251
|
+
return {}
|
|
252
|
+
choices[letter] = option[1]
|
|
253
|
+
return choices
|
|
214
254
|
|
|
215
255
|
|
|
216
256
|
def solve_matrix_choice(text):
|
|
@@ -231,6 +271,10 @@ def solve_matrix_choice(text):
|
|
|
231
271
|
r'([A-Z]) with shape \(\s*([a-z])\s*,\s*([a-z])\s*\),\s*'
|
|
232
272
|
r'which condition must hold for the product (\1\4|\4\1) to be defined\?', prompt,
|
|
233
273
|
)
|
|
274
|
+
pivots = re.fullmatch(
|
|
275
|
+
r'How many (?:pivot(?: position)?s) does the following (?:RREF|reduced row echelon form) matrix have\?\s*(.+)',
|
|
276
|
+
prompt, flags=re.I,
|
|
277
|
+
)
|
|
234
278
|
if inverse:
|
|
235
279
|
entries = matrix_2x2(inverse[2])
|
|
236
280
|
if entries is None:
|
|
@@ -254,23 +298,26 @@ def solve_matrix_choice(text):
|
|
|
254
298
|
return frozenset(equation.groups()) if equation else None
|
|
255
299
|
|
|
256
300
|
reasoning = f'The left matrix has {left_cols} columns and the right matrix has {right_rows} rows. These must be equal.'
|
|
301
|
+
elif pivots:
|
|
302
|
+
expected = rref_pivot_count(pivots[1])
|
|
303
|
+
if expected is None:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
def parse_option(value):
|
|
307
|
+
count = re.fullmatch(r'\d{1,5}', unwrap_math(value))
|
|
308
|
+
return int(count[0]) if count else None
|
|
309
|
+
|
|
310
|
+
reasoning = f'Each nonzero row in reduced row echelon form has one leading 1. There are {expected} such rows.'
|
|
257
311
|
else:
|
|
258
312
|
return None
|
|
259
313
|
|
|
260
|
-
lines = ('A.' + option_text).splitlines()
|
|
261
|
-
if not 2 <= len(lines) <= 26:
|
|
262
|
-
return None
|
|
263
314
|
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])
|
|
315
|
+
for letter, option in parse_labeled_choices(option_text).items():
|
|
316
|
+
value = parse_option(option)
|
|
270
317
|
if value is None:
|
|
271
318
|
return None
|
|
272
319
|
if value == expected:
|
|
273
|
-
matches.append(f'Option {letter}: {option
|
|
320
|
+
matches.append(f'Option {letter}: {option}')
|
|
274
321
|
if len(matches) != 1:
|
|
275
322
|
return None
|
|
276
323
|
return {'type': 'mcq', 'question': question, 'answer': matches[0], 'reasoning': reasoning}
|
|
@@ -326,7 +373,56 @@ def parse_answer_check(raw, count):
|
|
|
326
373
|
return answers
|
|
327
374
|
|
|
328
375
|
|
|
329
|
-
|
|
376
|
+
def displayed_single_choices(text):
|
|
377
|
+
question, separator, option_text = text.strip().rpartition('\nA.')
|
|
378
|
+
if not separator or not re.search(r'\n(?:Question format: )?Select one answer\.$', question, re.I):
|
|
379
|
+
return {}
|
|
380
|
+
return parse_labeled_choices(option_text)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def solve_row_parallel_choice(text):
|
|
384
|
+
"""Check the shared multiplication dimension in a row-parallel linear layer."""
|
|
385
|
+
choices = displayed_single_choices(text)
|
|
386
|
+
if not choices:
|
|
387
|
+
return None
|
|
388
|
+
question = re.split(r'\n(?:Question format: )?Select one answer\.', text.strip(), maxsplit=1, flags=re.I)[0]
|
|
389
|
+
prompt = ' '.join(question.split())
|
|
390
|
+
if not re.fullmatch(r'In row(?:[- ]tensor)? parallelism, why must the input also be split, not just the weights\?',
|
|
391
|
+
prompt, flags=re.I):
|
|
392
|
+
return None
|
|
393
|
+
matches = [(letter, option) for letter, option in choices.items() if re.fullmatch(
|
|
394
|
+
r'(?:so that )?the input (?:shape|feature dimension) matches the (?:corresponding )?weight (?:rows|shard)\.?', option, re.I)]
|
|
395
|
+
if len(matches) != 1:
|
|
396
|
+
return None
|
|
397
|
+
letter, option = matches[0]
|
|
398
|
+
return {
|
|
399
|
+
'type': 'mcq', 'question': question, 'answer': f'Option {letter}: {option}',
|
|
400
|
+
'reasoning': 'In Y = XW, rows of W index input features. Each GPU needs the corresponding columns of X '
|
|
401
|
+
'so the inner dimensions match. The GPUs sum their partial products.',
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def bind_displayed_choice(value, choices, *, matrix_math):
|
|
406
|
+
if type(value) not in (str, int, float):
|
|
407
|
+
return None
|
|
408
|
+
answer = str(value).strip()
|
|
409
|
+
|
|
410
|
+
def key(text):
|
|
411
|
+
return normalize_checked_answer(text, matrix_math=matrix_math)
|
|
412
|
+
|
|
413
|
+
labeled = re.fullmatch(r'(?:Option\s+)?([A-Z])\s*[:.)]\s*(\S.*)', answer, re.I)
|
|
414
|
+
if labeled:
|
|
415
|
+
letter, body = labeled[1].upper(), labeled[2]
|
|
416
|
+
return letter if letter in choices and key(choices[letter]) == key(body) else None
|
|
417
|
+
matches = [letter for letter, body in choices.items() if key(body) == key(answer)]
|
|
418
|
+
letter_only = re.fullmatch(r'(?:Option\s+)?([A-Z])\s*[:.)]?', answer, re.I)
|
|
419
|
+
if letter_only and letter_only[1].upper() in choices:
|
|
420
|
+
matches.append(letter_only[1].upper())
|
|
421
|
+
unique = set(matches)
|
|
422
|
+
return unique.pop() if len(unique) == 1 else None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
async def cross_check_text_answers(messages, results, complete, *, question_text=''):
|
|
330
426
|
items = json.loads(results)
|
|
331
427
|
if not items or any(item.get('type') != 'mcq' for item in items):
|
|
332
428
|
raise VisionUnconfirmedError('Verification requires a complete set of question answers.')
|
|
@@ -340,22 +436,42 @@ async def cross_check_text_answers(messages, results, complete):
|
|
|
340
436
|
'If the information is incomplete or any answer is uncertain, return {"readable":false,"answers":[]}.'
|
|
341
437
|
)
|
|
342
438
|
check_messages = [{'role': 'system', 'content': instruction}, *messages[1:]]
|
|
439
|
+
matrix_math = matrix_question(messages)
|
|
440
|
+
choices = displayed_single_choices(question_text) if len(items) == 1 else {}
|
|
441
|
+
|
|
442
|
+
def answer_key(entries):
|
|
443
|
+
if choices:
|
|
444
|
+
return bind_displayed_choice(entries[0].get('answer'), choices, matrix_math=matrix_math)
|
|
445
|
+
keys = tuple(normalize_checked_answer(item.get('answer'), matrix_math=matrix_math) for item in entries)
|
|
446
|
+
return keys if all(keys) else None
|
|
447
|
+
|
|
448
|
+
first = answer_key(items)
|
|
449
|
+
previous = None
|
|
343
450
|
for attempt in range(2):
|
|
344
451
|
raw = await complete(check_messages)
|
|
345
452
|
try:
|
|
346
453
|
verified = parse_answer_check(raw, len(items))
|
|
454
|
+
current = answer_key(verified)
|
|
455
|
+
except ValueError:
|
|
456
|
+
current = None
|
|
457
|
+
if current is not None and (current == first or current == previous):
|
|
458
|
+
if choices:
|
|
459
|
+
if current != first:
|
|
460
|
+
items[0].pop('reasoning', None)
|
|
461
|
+
items[0]['answer'] = f'Option {current}: {choices[current]}'
|
|
462
|
+
return json.dumps(items, ensure_ascii=False)
|
|
463
|
+
return results
|
|
464
|
+
if not choices and current is not None:
|
|
347
465
|
break
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
raise VisionUnconfirmedError('The independent solve could not verify every answer after retrying.') from exc
|
|
466
|
+
if not attempt:
|
|
467
|
+
previous = current
|
|
351
468
|
check_messages = [*check_messages, {
|
|
352
469
|
'role': 'user',
|
|
353
470
|
'content': 'Solve again using the original question and options. Return the full verification JSON with the required ids.',
|
|
354
471
|
}]
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
raise VisionUnconfirmedError('The two solves disagreed on at least one answer.')
|
|
472
|
+
if current is None and previous is None:
|
|
473
|
+
raise VisionUnconfirmedError('The independent solve could not verify every answer after retrying.')
|
|
474
|
+
raise VisionUnconfirmedError('The independent solves could not agree on the displayed answer.')
|
|
359
475
|
|
|
360
476
|
|
|
361
477
|
async def cross_check_visual_answers(messages, results, model, complete, comparison=None, *, require_agreement=False):
|