Replace Ollama docx import with mammoth HTML conversion
Remove all AI processing from the import-docx endpoint. Instead: - Convert the .docx to HTML with mammoth.convertToHtml (preserves bold, headings, lists, etc.) — the result goes straight into the item body and renders in the lesson viewer - Extract raw text in parallel to pick the document title from the first non-empty line - No external services, no AI, works on the deployed server Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
4100107c0a
commit
19f922c4ba
2 files changed
+25
-126
No files matched your search
+10
-15
@@ -606,9 +606,8 @@ $('#import-go-btn').addEventListener('click', async () => {
|
|||||||
const file = $('#import-local-input').files[0];
|
const file = $('#import-local-input').files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const type = $('#ie-type').value;
|
const type = $('#ie-type').value;
|
||||||
const mode = type === 'quiz' ? 'quiz' : 'lesson';
|
|
||||||
$('#import-go-btn').disabled = true;
|
$('#import-go-btn').disabled = true;
|
||||||
$('#import-status').textContent = '🔍 Extracting content… this may take a minute.';
|
$('#import-status').textContent = '📄 Extracting text…';
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const bytes = new Uint8Array(arrayBuffer);
|
const bytes = new Uint8Array(arrayBuffer);
|
||||||
@@ -617,21 +616,17 @@ $('#import-go-btn').addEventListener('click', async () => {
|
|||||||
binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + 8192, bytes.length)));
|
binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + 8192, bytes.length)));
|
||||||
}
|
}
|
||||||
const base64 = btoa(binary);
|
const base64 = btoa(binary);
|
||||||
const result = await api('/api/admin/import-docx', { method: 'POST', body: { base64, mode } });
|
const result = await api('/api/admin/import-docx', { method: 'POST', body: { base64 } });
|
||||||
if (!$('#ie-title').value && result.title) $('#ie-title').value = result.title;
|
if (!$('#ie-title').value && result.title) $('#ie-title').value = result.title;
|
||||||
if (mode === 'quiz' && result.questions) {
|
if (result.body) {
|
||||||
$('#ie-questions').innerHTML = '';
|
|
||||||
questionCount = 0;
|
|
||||||
const normalized = result.questions.map((q) => ({
|
|
||||||
...q,
|
|
||||||
correct_answer: q.correctAnswer ?? q.correct_answer ?? '',
|
|
||||||
}));
|
|
||||||
normalized.forEach(addQuestionRow);
|
|
||||||
$('#import-status').textContent = `✅ Imported ${result.questions.length} question(s). Review before saving.`;
|
|
||||||
} else if (result.body) {
|
|
||||||
const bodyField = type === 'assignment' ? '#ie-body-assignment' : '#ie-body-lesson';
|
const bodyField = type === 'assignment' ? '#ie-body-assignment' : '#ie-body-lesson';
|
||||||
$(bodyField).value = result.body;
|
const bodyEl = $(bodyField);
|
||||||
$('#import-status').textContent = '✅ Content imported. Review before saving.';
|
if (bodyEl) {
|
||||||
|
bodyEl.value = result.body;
|
||||||
|
$('#import-status').textContent = '✅ Text extracted. Review and edit before saving.';
|
||||||
|
} else {
|
||||||
|
$('#import-status').textContent = '✅ Title set. Paste content from your doc manually for quiz questions.';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$('#import-status').textContent = '⚠️ File imported but no content was found.';
|
$('#import-status').textContent = '⚠️ File imported but no content was found.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1518,127 +1518,31 @@ app.get('/api/admin/homeschool-files', requirePin, (req, res) => {
|
|||||||
res.json({ files: files.map(({ name, path }) => ({ name, path })) });
|
res.json({ files: files.map(({ name, path }) => ({ name, path })) });
|
||||||
});
|
});
|
||||||
|
|
||||||
async function callOllamaTextLocal(fullPrompt) {
|
|
||||||
const host = (getSetting('ollama_host') || 'http://localhost:11434').replace(/\/+$/, '');
|
|
||||||
const model = getSetting('ollama_model') || 'llava';
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), 180_000);
|
|
||||||
let r;
|
|
||||||
try {
|
|
||||||
r = await fetch(`${host}/api/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ model, prompt: fullPrompt, stream: false, format: 'json' }),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err.name === 'AbortError') throw new Error('Ollama timed out (3 min).');
|
|
||||||
throw new Error(`Couldn't reach Ollama at ${host} — is it running? (${err.message})`);
|
|
||||||
} finally { clearTimeout(timer); }
|
|
||||||
if (!r.ok) {
|
|
||||||
const t = await r.text().catch(() => '');
|
|
||||||
if (r.status === 404) throw new Error(`Model "${model}" not found on Ollama. Run: ollama pull ${model}`);
|
|
||||||
throw new Error(`Ollama error (${r.status}): ${t.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
const data = await r.json();
|
|
||||||
return parseVisionJson(data.response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callOllamaTextCloud(fullPrompt) {
|
|
||||||
const apiKey = getSetting('ollama_cloud_api_key') || '';
|
|
||||||
if (!apiKey) throw new Error('Ollama Cloud API key not set — add it in Settings.');
|
|
||||||
const host = (getSetting('ollama_host') || 'http://localhost:11434').replace(/\/+$/, '');
|
|
||||||
const baseModel = (getSetting('ollama_cloud_model') || 'gemma4').replace(/:cloud$/, '');
|
|
||||||
const model = `${baseModel}:cloud`;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), 180_000);
|
|
||||||
let r;
|
|
||||||
try {
|
|
||||||
r = await fetch(`${host}/api/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ model, messages: [{ role: 'user', content: fullPrompt }], stream: false }),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err.name === 'AbortError') throw new Error('Ollama Cloud timed out (3 min).');
|
|
||||||
throw new Error(`Couldn't reach local Ollama at ${host} — is it running? (${err.message})`);
|
|
||||||
} finally { clearTimeout(timer); }
|
|
||||||
if (!r.ok) {
|
|
||||||
const t = await r.text().catch(() => '');
|
|
||||||
if (r.status === 404) throw new Error(`Cloud model "${model}" not found. Run: ollama pull ${model}`);
|
|
||||||
throw new Error(`Ollama Cloud error (${r.status}): ${t.slice(0, 300)}`);
|
|
||||||
}
|
|
||||||
const data = await r.json();
|
|
||||||
const content = data.message?.content;
|
|
||||||
if (!content) throw new Error('Ollama Cloud returned an empty response.');
|
|
||||||
return parseVisionJson(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TEXT_SCAN_PROMPTS = {
|
|
||||||
lesson: `You are given the text content of a homeschool lesson or assignment document. Extract it as structured content.
|
|
||||||
|
|
||||||
Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences:
|
|
||||||
{"title": "the document title or heading", "body": "the full text content, paragraphs separated by a blank line"}`,
|
|
||||||
|
|
||||||
quiz: `You are given the text content of a homeschool quiz or test document. Extract every question exactly as written.
|
|
||||||
|
|
||||||
For each question determine:
|
|
||||||
- "type": "mc" if it has lettered/numbered answer choices, "tf" if it is true/false, "short" if it expects a written answer
|
|
||||||
- "prompt": the exact question text
|
|
||||||
- "choices": for "mc", an array of the exact answer choice texts (empty array for "tf" or "short")
|
|
||||||
- "correctAnswer": the marked answer if clearly indicated; for "tf" use exactly "true" or "false"; otherwise use ""
|
|
||||||
- "points": 1 unless the document states a different value
|
|
||||||
|
|
||||||
Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences:
|
|
||||||
{"title": "the quiz/test title", "questions": [{"type": "mc", "prompt": "...", "choices": ["...", "..."], "correctAnswer": "...", "points": 1}]}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
app.post('/api/admin/import-docx', requirePin, async (req, res) => {
|
app.post('/api/admin/import-docx', requirePin, async (req, res) => {
|
||||||
const { path: relPath, base64: b64, mode } = req.body;
|
const { base64: b64 } = req.body;
|
||||||
if (!TEXT_SCAN_PROMPTS[mode] || (!relPath && !b64)) {
|
if (!b64) return res.status(400).json({ error: 'base64 required' });
|
||||||
return res.status(400).json({ error: 'mode and either path or base64 required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let buffer;
|
let buffer;
|
||||||
if (b64) {
|
try { buffer = Buffer.from(b64, 'base64'); }
|
||||||
buffer = Buffer.from(b64, 'base64');
|
catch (err) { return res.status(400).json({ error: 'Invalid base64' }); }
|
||||||
} else {
|
|
||||||
const fullPath = join(HOMESCHOOL_DIR, relPath);
|
|
||||||
if (!fullPath.startsWith(HOMESCHOOL_DIR)) {
|
|
||||||
return res.status(403).json({ error: 'Access denied' });
|
|
||||||
}
|
|
||||||
try { buffer = await readFile(fullPath); }
|
|
||||||
catch (err) { return res.status(404).json({ error: `Could not read file: ${err.message}` }); }
|
|
||||||
}
|
|
||||||
|
|
||||||
let docText;
|
let html, rawText;
|
||||||
try {
|
try {
|
||||||
const result = await mammoth.extractRawText({ buffer });
|
const [htmlResult, textResult] = await Promise.all([
|
||||||
docText = result.value.trim();
|
mammoth.convertToHtml({ buffer }),
|
||||||
|
mammoth.extractRawText({ buffer }),
|
||||||
|
]);
|
||||||
|
html = htmlResult.value.trim();
|
||||||
|
rawText = textResult.value.trim();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(500).json({ error: `Could not parse docx: ${err.message}` });
|
return res.status(500).json({ error: `Could not parse docx: ${err.message}` });
|
||||||
}
|
}
|
||||||
if (!docText) return res.status(400).json({ error: 'Document appears to be empty' });
|
if (!rawText) return res.status(400).json({ error: 'Document appears to be empty' });
|
||||||
|
|
||||||
const fullPrompt = TEXT_SCAN_PROMPTS[mode] + '\n\nDOCUMENT TEXT:\n' + docText;
|
// Use first non-empty line of raw text as the title
|
||||||
|
const firstLine = rawText.split('\n').map(l => l.trim()).find(Boolean) || '';
|
||||||
const primary = getSetting('scan_primary') || 'local';
|
return res.json({ title: firstLine, body: html });
|
||||||
const providerFns = { local: callOllamaTextLocal, ollama_cloud: callOllamaTextCloud };
|
|
||||||
const order = primary === 'ollama_cloud' ? ['ollama_cloud', 'local'] : ['local', 'ollama_cloud'];
|
|
||||||
const isConfigured = { local: true, ollama_cloud: !!getSetting('ollama_cloud_api_key') };
|
|
||||||
|
|
||||||
const errors = [];
|
|
||||||
for (const provider of order) {
|
|
||||||
if (!isConfigured[provider]) continue;
|
|
||||||
try {
|
|
||||||
const result = await providerFns[provider](fullPrompt);
|
|
||||||
return res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
errors.push(`${provider}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res.status(502).json({ error: errors.join(' | ') || 'No AI providers configured.' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||||
|
|||||||
Reference in new issue
Block a user