4224a22092
- Источники: RAG возвращает sources (slug/§/ref), под ответом ссылка «по учебнику X, §N» на параграф (миграция 064: section_ref в textbook_chunks; headless-индексатор его заполняет). Статический индексатор теперь не затирает headless-данные. - Режим-наставник: переключатель Ответ/Подсказка/Проверить решение в «Спроси» (mode в ask + промпт); на карточке экзамена кнопка «Подсказка» (mode hint). - Оценка ответов: лайк/дизлайк под ответом (assistant_feedback) + сводка в админке. - Утренний бриф на дашборде: «занимался N из 5 дн + план на сегодня». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
75 lines
3.5 KiB
JavaScript
75 lines
3.5 KiB
JavaScript
'use strict';
|
|
/* index-textbooks.js — наполняет textbook_chunks текстом учебников для RAG
|
|
* «Спроси Квантика». Парсит HTML учебников (frontend/textbooks/<html_path>) по
|
|
* параграфам (.sec-h + тело секции), снимает теги, режет на куски.
|
|
*
|
|
* Запуск: node backend/scripts/index-textbooks.js (полная переиндексация)
|
|
* Также вызывается из админки (POST /api/admin/assistant/reindex) через reindex().
|
|
*
|
|
* Ограничение: учебники, рендерящие контент через JS-виджеты (напр. physics-9),
|
|
* в статическом HTML текста почти не содержат — они покрываются контекстом
|
|
* текущей страницы (getPageContext на клиенте), а не этим индексом. */
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const db = require('../src/db/db');
|
|
|
|
const TEXTBOOKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
|
|
|
function stripTags(html) {
|
|
return String(html || '')
|
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
.replace(/<svg[\s\S]*?<\/svg>/gi, ' ')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/&[a-z]+;/gi, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function chunksFromHtml(html) {
|
|
const body = String(html || '').replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ');
|
|
const out = [];
|
|
const re = /<h2[^>]*class="[^"]*sec-h[^"]*"[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2[^>]*class="[^"]*sec-h[^"]*"|<\/body|$)/gi;
|
|
let m;
|
|
while ((m = re.exec(body))) {
|
|
const title = stripTags(m[1]).slice(0, 160);
|
|
const text = stripTags(m[2]);
|
|
if (text.length >= 80) out.push({ section: title, text: text.slice(0, 2000) });
|
|
}
|
|
if (!out.length) {
|
|
const all = stripTags(body);
|
|
for (let i = 0; i < all.length && out.length < 6; i += 1500) out.push({ section: '', text: all.slice(i, i + 1500) });
|
|
if (out.length && out[0].text.length < 80) out.length = 0;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function reindex() {
|
|
let books;
|
|
try { books = db.prepare('SELECT slug, title, html_path FROM textbooks WHERE is_active = 1').all(); }
|
|
catch (e) { return { error: 'textbooks table missing', chunks: 0 }; }
|
|
// Замещаем чанки только тех книг, что реально распарсились — не трогаем
|
|
// данные, наполненные headless-индексатором (JS-рендеримые учебники).
|
|
const del = db.prepare('DELETE FROM textbook_chunks WHERE slug = ?');
|
|
const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text) VALUES (?, ?, ?, ?)');
|
|
let total = 0, files = 0;
|
|
for (const b of books) {
|
|
const fp = path.join(TEXTBOOKS_DIR, b.html_path || '');
|
|
let html;
|
|
try { html = fs.readFileSync(fp, 'utf8'); } catch (e) { continue; }
|
|
files++;
|
|
const chunks = chunksFromHtml(html);
|
|
if (!chunks.length) continue;
|
|
del.run(b.slug);
|
|
for (const c of chunks) { ins.run(b.slug, b.title || b.slug, c.section || '', c.text); total++; }
|
|
}
|
|
return { books: books.length, files, chunks: total };
|
|
}
|
|
|
|
module.exports = { reindex };
|
|
|
|
if (require.main === module) {
|
|
const r = reindex();
|
|
console.log('[index-textbooks]', JSON.stringify(r));
|
|
}
|