From b9f70ff88bdd6d66747f06b1edccd74391a46dbd Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 11 Jun 2026 23:16:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D1=83=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20=D0=B2=D0=B8=D0=B4=D0=B8=D1=82=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C=20=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=9A=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=D0=BA=D0=B0=20(=D0=B0=D0=B3=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B0=D1=82,=20=D0=B1=D0=B5=D0=B7=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /assistant/student-profile/:id (teacher/admin): производный профиль ученика — слабые предметы, трудные темы экзамена, цель, серия. Сырые заметки НЕ отдаются (приватны). Доступ: свой класс или «Мои ученики»; чужой → 403; админ — любой (проверено). На /my-students — кнопка «Профиль» с поповером. Ученику в панели памяти уже написано «учитель видит лишь общие слабые темы». Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/controllers/assistantController.js | 23 ++++++++++++++++- backend/src/routes/assistant.js | 4 ++- frontend/my-students.html | 25 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index 5bfbc92..2553bce 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -221,6 +221,27 @@ async function _extractMemory(uid, q, answer) { } catch (e) {} } +// Доступ учителя к ученику: свой класс или личный список «Мои ученики». +function _teacherCanSeeStudent(teacherId, studentId) { + try { + const inClass = db.prepare('SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id WHERE cm.user_id = ? AND c.teacher_id = ? LIMIT 1').get(studentId, teacherId); + if (inClass) return true; + return !!db.prepare('SELECT 1 FROM teacher_students WHERE teacher_id = ? AND student_id = ? LIMIT 1').get(teacherId, studentId); + } catch (e) { return false; } +} + +/* ── GET /api/assistant/student-profile/:id — для учителя/админа ──────── + * Только производный профиль (слабые предметы/темы, цель). БЕЗ сырых заметок. */ +function getStudentProfile(req, res) { + const sid = Number(req.params.id); + if (!sid) return res.status(400).json({ error: 'bad id' }); + const role = req.user && req.user.role; + if (role !== 'admin' && !_teacherCanSeeStudent(req.user.id, sid)) return res.status(403).json({ error: 'нет доступа к ученику' }); + let name = null; + try { name = db.prepare('SELECT name FROM users WHERE id = ?').get(sid)?.name || null; } catch (e) {} + res.json({ name, profile: _studentProfile(sid) }); +} + /* ── GET /api/assistant/memory — что Квантик знает об ученике ──────────── */ function getMemory(req, res) { const uid = req.user.id; @@ -595,4 +616,4 @@ async function flashcardsFromText(req, res) { res.json({ title, cards }); } -module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, feedback, getMemory, clearMemory, llmConfig, pingLLM, clearFailover: _clearFailover }; +module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, feedback, getMemory, clearMemory, getStudentProfile, llmConfig, pingLLM, clearFailover: _clearFailover }; diff --git a/backend/src/routes/assistant.js b/backend/src/routes/assistant.js index a10d26e..c168a99 100644 --- a/backend/src/routes/assistant.js +++ b/backend/src/routes/assistant.js @@ -2,7 +2,7 @@ /* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт * 'pet' навешивается при монтировании в server.js. */ const router = require('express').Router(); -const { authMiddleware } = require('../middleware/auth'); +const { authMiddleware, requireRole } = require('../middleware/auth'); const ctrl = require('../controllers/assistantController'); router.use(authMiddleware); @@ -18,5 +18,7 @@ router.get('/memory', ctrl.getMemory); router.delete('/memory', ctrl.clearMemory); // clearMemory удаляет только строки вызывающего (WHERE user_id = req.user.id) router.delete('/memory/:id', authMiddleware, ctrl.clearMemory); +// Учитель/админ — производный профиль ученика (без сырых заметок); доступ проверяется в хендлере +router.get('/student-profile/:id', requireRole('teacher', 'admin'), ctrl.getStudentProfile); module.exports = router; diff --git a/frontend/my-students.html b/frontend/my-students.html index f63b0cb..70eb6dd 100644 --- a/frontend/my-students.html +++ b/frontend/my-students.html @@ -235,6 +235,9 @@
${s.assignment_count || 0}
${fmtDate(s.added_at)}
+ Задание @@ -276,6 +279,28 @@ } }; + window.showProfile = async function (id, name) { + const r = await LS.api('/api/assistant/student-profile/' + id).catch(() => null); + if (!r) return; + const p = r.profile || {}, rows = []; + if (p.exam) rows.push('Готовится к экзамену: ' + esc(p.exam.key) + (p.exam.date ? ' (до ' + esc(p.exam.date) + ')' : '')); + if (p.weakSubjects && p.weakSubjects.length) rows.push('Слабые предметы: ' + p.weakSubjects.map(s => esc(s.name) + ' ' + s.avg + '%').join(', ')); + if (p.weakTopics && p.weakTopics.length) rows.push('Трудные темы экзамена: ' + p.weakTopics.map(t => esc(t.topic) + ' ' + t.rate + '%').join(', ')); + if (p.streak >= 3) rows.push('Серия занятий: ' + p.streak + ' дн.'); + const body = rows.length + ? rows.map(x => '
' + x + '
').join('') + : '
Пока недостаточно данных — слабые темы появятся после тестов и тренировок по экзамену.
'; + const ov = document.createElement('div'); + ov.style.cssText = 'position:fixed;inset:0;z-index:1200;display:flex;align-items:center;justify-content:center;background:rgba(15,23,42,.45);backdrop-filter:blur(6px);padding:20px'; + ov.innerHTML = '
' + + '
' + esc(r.name || name || 'Ученик') + ' — профиль для Квантика' + + '
' + + body + + '
Эти данные Квантик использует для персонализации объяснений ученику. Личные заметки ученика остаются приватными.
'; + document.body.appendChild(ov); + ov.addEventListener('click', function (e) { if (e.target === ov || e.target.hasAttribute('data-x')) ov.remove(); }); + }; + window.removeStudent = async function (id, name) { const ok = await LS.confirm( `Убрать «${name}» из списка?\nСозданные задания не удалятся — ученик продолжит их видеть.`,