124 Commits

Author SHA1 Message Date
Maxim Dolgolyov ff9900bdcc feat(trainer): геометрические чертежи задач — движок фигур + иллюстрации во всех геом. темах
TrainerFigures (frontend/js/trainer/figures.js) — безопасный SVG-рендер
«фигуры как данные» (модель SimForge): 11 типов — прямоугольный треугольник,
углы треугольника/смежные/внешний, прямоугольник, квадрат, треугольник по
основанию и высоте, трапеция, параллелограмм, ромб, правильный n-угольник,
подобные треугольники. Чертёж строится из чисел (params),  без eval/Function,
подписи экранируются, искомая величина — «?». Белые штрихи под индиго-сцену.

- generators.js: figure-спека на всех 15 геом-генераторах (Углы, Пифагор,
  Площади, Многоугольники, Подобие) — привязка размеров к параметрам задачи.
- _trainer_engine.js: figure прокидывается в problem.
- trainer.html: контейнер #tr-figure в шапке-герое, renderFigure() в newProblem,
  скрыт для текстовых задач, скрипт-тег, CSS.

Верификация: headless-смоук 5489 проверок / 900 рендеров (нет NaN/<script>/
обработчиков, «?» на искомой); адверсариал-ревью 4/4 группы clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:23:03 +03:00
Maxim Dolgolyov 393de56c42 feat(trainer): ИИ-репетитор — разбор ошибок и наводящие подсказки (направление A)
Безопасно через grounding: модели ДАЮТСЯ задача, правильный ответ и шаги (вычислены движком детерминированно) — ИИ только ОБЪЯСНЯЕТ, не считает. Поэтому даже слабая модель не выдаст неверную математику.

- сервис practiceExplainService.explain({problem, studentAnswer, mode, ask}): mode 'mistake' (разбор ошибки, можно назвать ответ) / 'hint' (наводящая подсказка БЕЗ ответа). Текст модели чистится от markdown и экранируется; LLM-вызов инъектируется (тесты), реальный — callLLMFailover (провайдеры Квантик-ассистента)
- POST /api/practice/explain (auth-only, ученикам); нет/выключен LLM → 503, клиент мягко падает на пошаговое решение
- клиент LS.practiceExplain; на странице кнопка «Объяснить»: после неверного ответа → разбор (с ответом ученика), иначе → подсказка; рендер в .tr-ai-box (текст экранирован сервером)
- тест practice-explain 7/7 (grounding: ответ в промпте; hint не раскрывает ответ; off→not ok; cleanText экранирует; endpoint 503/400/401)
- бэкенд practice-тесты 40/40, страница 42/42, lint:routes 0 unprotected, эмодзи 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:23:36 +03:00
Maxim Dolgolyov b5916e7f3b feat(trainer): полные решения систем + шапка «Математика»; решения по всем темам
- Шапка-пилюля теперь УНИВЕРСАЛЬНАЯ и не мигает: всегда «Математика · 5–9 класс» (не переключается на «Алгебра»; предмет выбирается сегментом Алгебра/Геометрия)
- Системы 2 ур-ний — ПОЛНОЕ решение методом сложения (6 шагов): уравнять коэффициенты при x, вычесть (исключить x → coefY·y=rhsY), найти y, подставить, найти x, ответ-пара. Коэффициенты 2..4 / |коэф|≥2 — без «1x» в шагах
- Аудит решений по ВСЕМ темам: 7 «тонких» (1 шаг) генераторов (simp-like/expand, pow-mult/pow, sq-sum/diff, diff-sq) развёрнуты в 2 шага (правило → итог)
- смоук T21: у каждого из 60 генераторов решение ≥2 шагов; движок 1214/1214, страница 42/42; эмодзи 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:05:43 +03:00
Maxim Dolgolyov fb16821b0a feat(trainer): НОД/НОК с нормальными числами; универсальная шапка; НОК теперь появляется
НОД/НОК (числа больше + без степеней):
- движок: фича factorize ({name, of}) кладёт в шаги решения СТРОКУ разложения на простые множители без степеней (36 -> «2*2*3*3»); helper primeFactorString
- генераторы: a=g·m, b=g·n (g,m,n из 2..9) -> нормальные числа (14, 35, 16, 112…), общий множитель гарантирован; решение показывает разложение обоих + НОД/НОК = произведение множителей
- пример: 16 = 2·2·2·2, НОК = 2·2·2·2·7 = 112

НОК теперь появляется (раньше показывался только НОД):
- причина: smart-подбор брал первый неосвоенный навык ГЛОБАЛЬНО -> из НОД прыгал на lin-basic, НОК не доходил
- фикс: умная тренировка теперь адаптируется В ПРЕДЕЛАХ выбранной темы (pickNext scope = skillsOf(curTopic)) -> в теме «НОД и НОК» ведёт по обоим навыкам; тему выбирает ученик в рейле

Шапка: пилюля стала универсальной и динамической (updateSubjectPill: «Алгебра · 5–9 класс» / «Геометрия · 7–8 класс» по текущему предмету), вместо статичной «Алгебра · 7–8 класс».

Смоук движка 1154/1154, страница 42/42; эмодзи 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:57:30 +03:00
Maxim Dolgolyov 2a50ff740a fix(trainer): решение НОД/НОК без степеней — произведением простых множителей
По просьбе: показывать разложение без показателей (2·2·7-стиль вместо 2²·7). Из-за статичных шаблонов переменное число множителей со степенями не развернуть, поэтому модель упрощена и стала нагляднее:
- a = p·q, b = p·r (три различных простых из {2,3,5,7}); общий множитель p стоит ПЕРВЫМ в обеих строках разложения → общее видно сразу
- НОД = p (общий множитель), НОК = p·q·r (все множители, общий один раз)
- разложение печатается как 10 = 5·2, 15 = 5·3 → НОД = 5; НОК(6,15): 3·2·5 = 30 — без степеней
- gcd/lcm дают эталон проверки; смоук движка 1154/1154, эмодзи 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:44:20 +03:00
Maxim Dolgolyov 664751e273 feat(trainer): красивое решение НОД/НОК — через разложение на простые множители
- НОД/НОК переписаны: число строится из двух простых p<q (из {2,3,5,7}) со степенями 1..2 (a=p^e1·q^f1) → разложение известно из параметров. Решение показывает СТАНДАРТНЫЙ школьный метод: разложить оба числа, НОД = общие множители в наименьших степенях, НОК = все в наибольших. Пример: 225=3²·5², 45=3²·5 → НОД=3²·5=45
- выбор простого — тернарником в derive (ip/iq, НЕ pi — pi это π в SimExpr!)
- exprToLatex: x^1→x, x^0→1 (чтобы 7^1 печаталось как 7) + ставит · между числовыми множителями (2·7², а не слипшееся «27²»); алгебраическое неявное умножение (2x, 3(x+1)) сохранено
- gcd/lcm дают эталон для проверки, min/max — степени для шагов
- смоук движка 1154/1154, страница 40/40; эмодзи 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:39:21 +03:00
Maxim Dolgolyov a7d20a0c90 feat(trainer): НОД и НОК (5-6 кл) + функции gcd/lcm в SimExpr
- SimExpr: добавлены whitelisted-функции gcd (алгоритм Евклида) и lcm (арность 2), защищены от NaN/0/отрицательных. Аддитивно — существующие спеки SimForge/Quantik не затронуты
- НОВАЯ тема НОД и НОК: gcd-pair (НОД), lcm-pair (НОК). Числа строятся как g·m и g·k (общий множитель) → НОД нетривиален; gcd/lcm считаются и проверяются движком
- 60 генераторов, 20 тем; смоук движка 1154/1154, страница 40/40; gcd(36,24)=12, lcm(4,6)=12 верны; эмодзи 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:30:36 +03:00
Maxim Dolgolyov 169501f12a feat(trainer): контент 5-6 кл — десятичные дроби + отрицательные числа
- НОВАЯ тема Десятичные (5 кл): сложение/вычитание/умножение десятичных. Строятся через десятые/сотые (целые ÷10, ÷100) → ответ печатается чисто, без float-мусора (0.8, 0.07, 0.16)
- НОВАЯ тема Отрицательные (6 кл): сумма/разность/произведение с отрицательными. Словесные формулировки («Найдите сумму чисел -8 и 9», «Из числа 6 вычтите -12») — без двусмысленных операторов; constraint гарантирует хотя бы одно отрицательное
- всё kind compute (движок не трогал); LEVELS проставлены
- 58 генераторов, 19 тем; смоук движка 1114/1114, страница 40/40; эмодзи 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:17:22 +03:00
Maxim Dolgolyov db5c6b80ec feat(trainer): контент — геометрия (площади/многоугольники/подобие) + дроби 5-6
- Площади (+3): трапеция (a+b)/2·h, параллелограмм a·h, ромб d1·d2/2
- НОВАЯ тема Многоугольники (8 кл): сумма углов 180·(n−2), угол правильного n-угольника
- НОВАЯ тема Подобие (8 кл): сходственная сторона/периметр по коэффициенту
- НОВАЯ тема Дроби (5 кл): часть от числа (целый ответ), сложение дробей с одинаковым знаменателем (дробный ответ — ученик вводит «7/4» или «1.75», SimExpr считает)
- всё kind compute (новой движковой работы нет); LEVELS проставлены
- 52 генератора, 17 тем (геометрия: Углы, Пифагор, Площади, Многоугольники, Подобие)
- смоук движка 997/997, страница 40/40 (T1 целочисленность теперь только для integerAnswer-генераторов — дроби легитимны); эмодзи 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:04:39 +03:00
Maxim Dolgolyov 03c6ebfdce feat(trainer): страница — ввод систем (пара) + подсказки-разбор ошибок (C1)
Подключение на странице (после редизайна-премиум-консоли):
- applyInputMode: kind system в multi (скрыт префикс «x =») + placeholder «напр. x = 2; y = 3»
- answerLabel/isLabelKind: для системы показываем пару «x = 2,  y = 3»
- check(): на неверном ответе зовём TE.analyzeMistake -> адресная подсказка (не разделил на коэффициент / перепутан знак / арифметика), не выдавая ответ; иначе общий текст
- смоук страницы 40/40 (системы: пара принята, префикс скрыт; неверный ответ -> разбор-подсказка)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:55:43 +03:00
Maxim Dolgolyov fb81beca39 feat(trainer): разбор типовых ошибок (репетитор C1, движок)
- TrainerEngine.analyzeMistake(problem, value) -> {type, hint} | null: по неверному числовому ответу распознаёт типовую ошибку и даёт адресную подсказку, НЕ выдавая ответ
- solve: уравнение восстанавливается как линейное f(x)=A·x+B по двум точкам (без структуры генератора) -> ловит «забыл разделить на коэффициент»
- общие эвристики: перепутан знак (value≈-correct), близкая арифметическая ошибка (|Δ|≤20%), иначе generic
- работает для solve/compute; пара/корни/неравенство пропускаются
- смоук движка 825/825 (T20: nodivide/sign/arith/generic/null)
- страница НЕ тронута (редизайн в параллельной сессии); показ подсказки на неверном ответе подключу на странице вместе с полировкой ввода систем после редизайна

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:52:00 +03:00
Maxim Dolgolyov 21ffbbfe6c style(trainer): премиум-консоль — двухпанельный рабочий стол
Полный визуальный редизайн страницы /trainer (разметка-каркас + CSS).
Логика не тронута (генераторы, адаптив, шаги, аналитика, конструктор):
сохранены все DOM-ID и инжектируемые классы. Кольцо мастерства в рейле
запитано из существующей updateOverall() (только отражает данные).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:48:08 +03:00
Maxim Dolgolyov 5226deb975 feat(trainer): системы 2 уравнений (kind system, пара-ответ) + текстовые задачи
- НОВЫЙ kind system: движок строит \begin{cases}, хранит пару {x,y}, самопроверка подстановкой обоих уравнений; checkStudentAnswer._checkSystem парсит «x=2; y=3» или «2; 3» (метки опциональны), проверяет ОБА уравнения
- тема Системы: sys-2x2 (полож. коэф., ур.2) + sys-2x2-neg (отрицательные, ур.3); приём корень-вперёд (берём решение, выводим правые части, det≠0)
- тема Задачи (compute, текстовые семьи): движение (путь/время/скорость), сплав (%), цена со скидкой
- exprToLatex: единичный коэффициент 1*x->x, -1*x->-x (латентная недоработка)
- 43 генератора, 14 тем; смоук движка 817/817 (T19 системы + T19b текстовые)
- страница (trainer.html) НЕ тронута — её редизайнит параллельная сессия; полировка ввода систем (скрыть «x=», placeholder, фидбэк пары) — после редизайна. Системы уже работают через checkStudentAnswer (ввод «2; 3»)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:44:19 +03:00
Maxim Dolgolyov 8df7d1713c docs(trainer): план развития v3 — по всем направлениям
Комплексный план: A визуал/UX (премиум-консоль, мокап готов), B контент/ЦТ (системы, текстовые семьи, геометрия-глубина, 5-6 кл), C репетитор/разбор ошибок+подсказки (P9), D адаптивность/граф навыков, E геймификация/карта (P11), F учительский процесс/задания (P12), G масштаб авторинга, H техника/a11y/PWA. Рекомендуемая последовательность.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:31:23 +03:00
Maxim Dolgolyov f0af2079c3 fix(trainer): сложность = структура задачи, а не масштаб чисел
Пользователь верно заметил: масштабирование чисел (больше/меньше) — не настоящая сложность. Настоящая = больше действий, скобки, дроби, переменная в обеих частях.

- генераторы размечены структурным level 1-3 (generators.js, LEVELS): напр. Уравнения ax+b=c (1) -> a(x+b)=c (2) -> a(x+b)=c(x+d) (3); Степени: вычислить -> произведение -> степень степени
- контрол сложности выбирает ВАРИАНТ-генератор нужного уровня в теме (pickByLevel с клампом к доступным), а не масштабирует числа
- клик по чипу навыка закрепляет конкретный вариант (pinned); Авто = адаптивный подбор (умная тренировка от простого к сложному) + показ ур.N текущего
- кросс-тематический адаптив pickNext — только в Авто без закрепления
- движковое _scaleRange/level оставлено как capability (T18), страница его НЕ использует
- смоук движка 682/682, страница 36/36 (Сложный->ген ур.3, Лёгкий->ур.1); эмодзи/eval 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:24:31 +03:00
Maxim Dolgolyov d07cb2a434 feat(trainer): уровни сложности заданий (Лёгкий/Средний/Сложный + Авто)
- движок: instantiate(gen,{level}) масштабирует диапазоны pick (_scaleRange): L2=база, L1 меньше магнитуды/меньше отрицательных, L3 шире → сложнее; универсально для всех генераторов (корень-вперёд + самопроверка держат корректность), opt-out gen.noScale; generateBatch прокидывает level
- страница: контрол «Сложность: Авто / Лёгкий / Средний / Сложный» в рабочей зоне; «Авто» поднимает уровень с серией верных (streak≥2→2, ≥4→3, ошибка→1); скрыт для текстовых задач из банка
- смоук движка 682/682 (T18: 36 ген × L1/L2/L3, L3 шире L1, L2==база), страница 34/34; эмодзи/eval 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:10:28 +03:00
Maxim Dolgolyov 6eaf68a158 feat(trainer): контент по программе учебников + геометрия + фильтр предмета
- классы тем выровнены по нашим учебникам (степени/формулы/упрощение/неравенства=7, пропорции/проценты=6, квадратные=8, прогрессии=9)
- Прогрессии (9 кл): n-й член арифм./геом. прогрессии (compute)
- ГЕОМЕТРИЯ (subject geometry): Углы (сумма углов треугольника, смежные, внешний — 7 кл), Пифагор (гипотенуза/катет через тройки — 8 кл), Площади (прямоугольник/треугольник/квадрат — 8 кл)
- 36 генераторов, 12 тем; всё kind compute (числовой ответ, проверка подстановкой, sqrt в SimExpr)
- страница: фильтр предмета Алгебра/Геометрия (segmented), синхрон с adaptive/ручным выбором; иерархия Предмет → Тема → Навык
- смоук движка 572/572, страница 33/33; эмодзи/eval 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:59:11 +03:00
Maxim Dolgolyov d5587b4eb1 style(trainer): структурировал область выбора темы и навыков
- подпись «Тема» над вкладками тем; бейдж класса (7/8) на каждой теме — видна ступень программы
- навыки вынесены в отдельную подписанную панель «Навыки темы «…»» (для текстовых/авторских — свой заголовок) → ясная иерархия тема → навыки
- лёгкая тонировка панели отделяет навыки от тем; анимация появления на контейнерах
- смоук страницы 33/33; эмодзи/eval 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:48:48 +03:00
Maxim Dolgolyov 123200e759 style(trainer): янтарное выделение пункта «Конструктор задач» в сайдбаре
- .sb-admin-tool в ls.css — амбровый цвет текста/иконки + подсветка hover/active (как кнопка «Конструктор» в тренажёре)
- пункт /trainer-builder помечен sb-admin-tool (только админ, hidden:!isAdm)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:40:45 +03:00
Maxim Dolgolyov aa20892a79 feat(trainer): «сцена-герой» редизайн + конструктор только для админов
- мощный визуал: уравнение на яркой градиентной «сцене» (бело на индиго→фиолет, текстура-клетка), рабочая зона снизу на белом; верный ответ заливает сцену изумрудом, неверный — красным (с pop/shake)
- конструктор генераторов — ТОЛЬКО админ: страница /trainer-builder гейт ip.isAdmin; роуты POST/PUT/DELETE /generators → requireRole(admin); сайдбар-пункт hidden:!isAdm
- выделен отдельным цветом: янтарная кнопка «Конструктор» в баре режима (только админ) → /trainer-builder
- тема пользовательских генераторов: «Мои генераторы» для админа / «Авторские» для остальных (видят published)
- тесты custom-generators 13/13 (админ создаёт; учитель/ученик 403); страница-смоук 33/33; эмодзи/eval 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:38:26 +03:00
Maxim Dolgolyov 6d600ad576 feat(trainer): P13 — конструктор параметрических генераторов
- custom_generators (мигр.084, spec_json + draft/published); customGeneratorController: validateGenSpec без исполнения (лимиты/типы), CRUD own+published + ownership
- /api/practice/generators[/:id]; клиент LS.practiceGen*
- страница /trainer-builder (учитель): форма (pick/derive/lhs/rhs/display/answer/solution) + живое превью через TE.instantiate(strict) (материализация + проверка ответа подстановкой) + список своих (правка/удаление/публикация)
- тренажёр грузит свои+опубликованные генераторы в тему «Мои генераторы» (пошаговый режим работает); пункт сайдбара /trainer-builder (teacher-only)
- тесты custom-generators.test.js 12/12; смоук движка 402/402 (T17 кастомный спек + strict-валидация); страница 33/33; ROADMAP_V2 P13 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:30:08 +03:00
Maxim Dolgolyov 47d4f71eac feat(trainer): P10 — контент 8 класса (степени, формулы, неравенства)
- новый тип kind:inequality: answerRel{op,bound}, парсер отношения (_parseRel/_checkInequality) — нормализация «x op c», приём обратной записи, сверка op+границы; self-check внутри/снаружи решения
- темы: Степени (aⁿ, xᵃ·xᵇ, (xᵃ)ᵇ), Формулы сокр. умножения (квадрат суммы/разности, разность квадратов), Неравенства (вкл. смену знака при делении на отрицательное) → 26 генераторов, 8 тем
- движок: simplify рендерит выражение в KaTeX (exprToLatex(srcExpr)); неравенство — в KaTeX с отношением; fallback-display учитывает op
- страница: ввод/лейбл для неравенств, isLabelKind
- смоук движка 397/397 (T15 неравенства, T16 степени/формулы; T3 ≥10 для малых пространств), страница 33/33; ROADMAP_V2 P10 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:20:45 +03:00
Maxim Dolgolyov 277bddf1fd feat(trainer): P7 пошаговое решение (репетитор) + P8 мат-клавиатура
- движок checkStep(problem, line): шаг = равносильное уравнение (держится во всех корнях И не выполняется в не-корнях) → ловит арифметику, потерю корня, тождество; статусы equivalent/solved/wrong/identity/parse
- страница: тумблер «Решить по шагам» (kind solve), ввод и проверка каждого шага, список принятых шагов (KaTeX + галочка), подсказка следующего шага, завершение по solved-форме; общий onSolved; stepPref между задачами
- P8: экранная мат-клавиатура (( ) x / ^ √ ; ⌫, вставка в курсор, без либ) + live-превью KaTeX; для поля ответа и поля шага
- ROADMAP_V2: P7+P8 → DONE; смоук движка 300/300 (T14 checkStep), страница 33/33 (шаг-сценарии)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:06:46 +03:00
Maxim Dolgolyov 10c9b007d8 style(trainer): полный визуальный редизайн — «рабочий лист в клетку»
- фон-ambient «тетрадь в клетку» (материал предмета) на .sb-content
- карточка-герой с акцентной градиентной полосой; празднование верного ответа (изумруд+pop), мягкая встряска при ошибке
- акцент индиго→фиолет градиент, успех изумруд, мастерство золото; фидбек-«таблетка», нумерованные шаги решения с градиентными бейджами
- вкладки тем/навыки/режим — тактильные пилюли с hover-lift; цифры статистики градиентным текстом
- модалки с blur-backdrop; итог сессии с золотой полосой; общий прогресс пилюлей
- появление с лёгким stagger; mobile-адаптация + prefers-reduced-motion
- селекторы навыков scoped (.tr-skills .tr-skill) + #tr-skill для eyebrow (убран конфликт); все id/классы и логика сохранены (смоук 27/27)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:48:08 +03:00
Maxim Dolgolyov cd7c75ff08 feat(trainer): P4 — авторинг задач учителем + раздача классу
- POST /api/practice/author: учитель пишет story/lhs/rhs/answer → та же проверка подстановкой (validateAndVerify) → пул; не сходится → 422
- POST /api/practice/assign: выдать тему классу → durable pushNotif каждому ученику (ссылка /trainer); владелец/админ, чужой → 403
- клиент: LS.practiceAuthor/Assign; в теме «Текстовые задачи» учителю кнопки «Своя задача» (модалка-форма) и «Выдать классу» (пикер классов)
- тесты: author (валид→пул, неверный→422, ученик→403), assign (владелец уведомляет, чужой→403) — practice 19/19 + practice-gen 16/16
- смоук страницы 27/27; план P4 → DONE (lean: ручной авторинг + раздача, без полного DSL-конструктора)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:30:02 +03:00
Maxim Dolgolyov d003a0e100 feat(trainer): P6 — учительская аналитика класса + общий прогресс
- GET /api/practice/class-stats (classStats): агрегаты по навыкам + матрица ученик×навык; доступ владелец класса/админ
- клиент: кнопка «Аналитика класса» (учителю) → модалка с тепловой картой (точность/освоено) + пикер классов; LS.practiceClassStats
- лёгкая геймификация: строка «Освоено навыков M из N · решено всего K» из агрегатов practice_progress
- тесты practice.test.js +4 (владелец видит; чужой/ученик → 403; без class_id → 400); смоук страницы 27/27; план P6 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:24:05 +03:00
Maxim Dolgolyov 7cc2a9d526 feat(trainer): P5 — несколько корней, эквивалентность выражений, новые темы
- движок: gen.answers → несколько корней (_checkMultiRoot, ввод через «;», сверка мультимножеством)
- kind simplify: эквивалентность выражений численным сэмплингом (_sampleEquiv, _checkEquiv), фикс. точки без Math.random
- exprToLatex: знаковые коэффициенты — -5x, x²−5x+6, a−(−b)→a+b (вынос ведущего минуса, схлопывание)
- темы: Упрощение (подобные, скобки) + Квадратные (Виета x²+bx+c=0, разность квадратов) → 17 генераторов, 5 тем
- страница: префикс «x=»/подсказка ввода и ответ-лейбл по типу задачи
- смоук движка 291/291 (T11 roots, T12 simplify, T13 latex), страница 26/26, adaptive 12/12; план P5 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:15:21 +03:00
Maxim Dolgolyov 8c4c9bf04c feat(trainer): P3 — текстовые задачи от LLM с серверной проверкой подстановкой
- practiceVerify.js: грузит SimExpr в Node (require), verifyRoot подстановкой корня
- practiceGenService.js: LLM (инъектируемый ask) → parse → validateAndVerify (SimExpr + подстановка + санитизация) → авторетрай по фидбэку; дефолт ask = assistantController.callLLMFailover
- пул practice_problems (мигр.083); POST /api/practice/generate (учитель/админ) + GET /api/practice/pool
- инвариант: невалидная/неверная задача в БД НЕ пишется → ученику не попадёт
- клиент: LS.practicePool/Generate, тема «Текстовые задачи» (из пула; учителю кнопка «Сгенерировать»)
- тесты practice-gen.test.js 13/13 (verify, ретраи, off→503, 403 ученику, пул); смоуки страница 26/26; план P3 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:00:39 +03:00
Maxim Dolgolyov 48a73d9f8e feat(trainer): P2 — умная тренировка, интервальное повторение, итог сессии
- adaptive.js (TrainerAdaptive): nextSkill (in-session повтор → серверный due → прогрессия → удержание), onWrong/onCorrect (очередь повторения), sessionStats
- умная тренировка на странице (тумблер, по умолч. вкл): авто-подбор навыка от простого к сложному, возврат ошибок
- сессия из 10 задач + экран «Итог сессии» (верно/точность/навыки/стоит повторить); неверный ответ авто-показывает решение
- сервер: SR-поля box+due_at на practice_progress (мигр.082, Leitner 0/1/3/7/16/30 дн), listProgress отдаёт box/due_at/due
- смоуки: adaptive 12/12, страница 23/23, practice.test.js 11/11 (+SR box/due); план P2 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:46:29 +03:00
Maxim Dolgolyov 20b8ce2c5b feat(trainer): P1 — темы/навыки, +8 генераторов, подробные пошаговые решения
- таксономия тема→навык (topics/byTopic), метаданные topic/order/subject/grade
- 13 генераторов в 3 темах: Уравнения (+a(x+b)=c(x+d), (ax+b)/c=d), Пропорции (3), Проценты (3)
- проценты как compute-задачи: текстовый prompt + проверка подстановкой (latex уравнения скрыт)
- подробные объяснения: каждый шаг расписан словами + шаг «Проверка» (подстановка корня)
- UI: вкладки тем + чипы навыков, бейджи мастерства, авто-выбор первой неосвоенной темы/навыка
- движок: exprToLatex чинит отрицательные множители (7·(−5)), поле kind, нумерованные шаги решения
- смоуки 238/238 (движок) + 19/19 (страница); план: P1 отмечен DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:29:44 +03:00
Maxim Dolgolyov c370eaa803 feat(trainer): ИИ-тренажёр — генераторы задач + SimExpr-верификатор, прогресс, фича-флаг
- движок _trainer_engine.js: instantiate/generateBatch/verifyRoot/checkStudentAnswer/exprToLatex
- 5 генераторов уравнений 7 класса (generators.js), приём «корень-вперёд» → целые ответы
- страница /trainer: KaTeX-рендер, чипы-темы, мгновенная проверка, подсказка/решение, авто-выбор навыка
- прогресс practice_progress (мигр.081) + /api/practice/progress|attempt + LS.practiceProgressList/Submit
- фича-флаг trainer: тумблер в админке (Модули), requireFeature, FEATURE_HREFS (скрытие сайдбара+редирект), MODULE_CATALOG
- fix: подключён Lucide CDN на странице (иначе иконки сайдбара пустые)
- тесты practice.test.js (10/10); план развития plans/ai-trainer/PLAN.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:11:47 +03:00
Maxim Dolgolyov 91917f952c fix(security): харднинг загрузки файлов, контроль доступа и XSS
Подхвачено из закрытой параллельной сессии (план project_hardening_2026).

Загрузки: magic.js получает safeExt/EXT_FOR_MIME — имя файла на диске берёт
расширение из проверенного MIME, а не из client originalname (анти stored-XSS
.html/.svg). avatar/flashcard/chat-загрузки дополнительно проверяют magic-байты:
содержимое должно соответствовать MIME, иначе файл удаляется и 400.

Доступ: fileController.getFolderAccess отдаёт список раздачи только владельцу
или админу (была утечка имён/email учеников). testController.getOne гейтит
видимость как list() — ученик не прочитает тексты заданий черновиков/вариантов
по id.

XSS: classes.html escJ() экранирует строку для JS-литерала в inline-onclick
(имя ученика с кавычкой больше не ломает обработчик).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:03:06 +03:00
Maxim Dolgolyov e38abff02a feat(assistant): срок жизни диалога — 7 дней без общения
saveChat пишет метку времени, loadChat сбрасывает диалог, если с последней
реплики прошло больше CHAT_TTL (7 дней). Обратная совместимость со старым
форматом-массивом. Сноска обновлена.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:07:11 +03:00
Maxim Dolgolyov 7c0ccc7282 feat(assistant): диалог сохраняется между заходами (localStorage, per-user)
_chat теперь персистится в localStorage (ключ asst_chat_<uid>, последние 30
сообщений) при каждом ответе и восстанавливается при загрузке виджета. Живёт,
пока ученик сам не нажмёт «Очистить» (чистит и хранилище). Сноска обновлена.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:04:43 +03:00
Maxim Dolgolyov c045ea02b0 feat(assistant): больше рабочей памяти (~14 сообщений) + оформление сноски
История разговора расширена с 6 до 14 сообщений (фронт _chat.slice и бэкенд
история в ask/askStream). Сноска о памяти переоформлена: иконка-история + чище
текст, акцент на количестве.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:56:51 +03:00
Maxim Dolgolyov b310029e8d style(assistant): компактные режимы — иконка + короткая подпись
Кнопки режимов перерисованы как тулбар: вертикально иконка (inline SVG .ic) +
лаконичная подпись. Длинные ярлыки сокращены (Проверить решение -> Проверить,
Тест в банк -> В банк, Нарисовать -> Рисунок); полный смысл в title. Рендер
data-driven из MODE_DEFS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:54:19 +03:00
Maxim Dolgolyov ee740817a8 fix(assistant): русские названия трудных тем в памяти
Темы экзамена в exam_tasks.topic хранятся англ. ключами (algebra, geometry,
functions, ...). Добавлена карта _EXAM_TOPIC_RU; в _studentProfile экзаменные
темы переводятся на русский перед слиянием с темами банка тестов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:51:10 +03:00
Maxim Dolgolyov 29fc270c0e fix(assistant): «Забыть всё» теперь сбрасывает и производный профиль
clearMemory ставит точку отсчёта asst_forget_<uid> (datetime now); слабые
предметы/темы в _studentProfile считаются только по активности после неё, так
что панель памяти видимо очищается. Кнопка «Забыть всё» в виджете показывается
лишь при наличии заметок/слабых тем, профиль помечен как авто-обновляемый.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:36:29 +03:00
Maxim Dolgolyov 7b4a274aed feat(assistant): умная память Квантика — свежесть, категории, темы по всем предметам
Память об ученике (1+2+3 из плана), всё строго на русском:
- СВЕЖЕСТЬ: эффективный вес заметок с затуханием по времени (полураспад ~31 день),
  в промпт идут только актуальные (порог по effWeight). Старое тихо тает.
- УМНОЕ СЛИЯНИЕ: вместо дедупа по первым 24 символам — стем-токены (русская
  морфология) + Jaccard; похожие заметки сливаются (вес+, текст освежается),
  а не плодят дубли. Лимит 18.
- КАТЕГОРИИ: экстрактор классифицирует факт (трудность/предпочтение/цель/
  сильная сторона/личное), возвращает JSON; запоминаются и сильные стороны/
  интересы, не только проблемы. Гард по кириллице — не-русский текст не попадает.
- ТРУДНЫЕ ТЕМЫ ПО ВСЕМ ПРЕДМЕТАМ: профиль считает слабые темы из user_answers+
  topics (любой предмет, русские названия), объединяя с экзаменом, а не только math9.
- UI «Что я о тебе помню»: у заметок русская плашка-категория.
Без миграции (колонки kind/weight/updated_at уже есть). Проверено: логика 8/8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:27:28 +03:00
Maxim Dolgolyov 4c9656e8a8 style(assistant): вау-уровень окна Квантика
- стекло-блюр фона окна (backdrop-filter blur+saturate), глубже тень;
- полноширинная градиентная шапка (фиолетово-бирюзовый) со скруглением;
- аватар с зелёным «онлайн»-пульсом;
- анимированный индикатор печати (три прыгающие точки) вместо текста Думаю;
- плавное появление каждого сообщения (slide-in).
Всё уважает prefers-reduced-motion. Только frontend/js/assistant.js, inline SVG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:17:55 +03:00
Maxim Dolgolyov b6c08f1b16 style(assistant): премиальный редизайн окна Квантика
Окно «Спроси Квантика» — удобнее, красивее, солиднее:
- шире (418px), многослойная мягкая тень с фиолетовым отливом, скругление 20px;
- настоящая шапка с аватаром в круге (градиентный фон) и разделителем;
- пузыри сообщений: ученик — фиолетовый градиент с хвостиком, ассистент —
  светлая карточка с тонкой рамкой; мягкая тень;
- поле ввода с фокус-кольцом + отдельная кнопка отправки (стрелка-самолётик),
  градиентная — удобнее на телефоне и нагляднее;
- режимы и чипы — мягкие пилюли, активный режим градиентный с тенью;
- область чата выше (54vh) с аккуратным фиолетовым скроллбаром.
Только frontend/js/assistant.js, без эмодзи (inline SVG).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:13:02 +03:00
Maxim Dolgolyov c3be921dfb fix(assistant): таймаут генерации тестов/карточек 15с был мал → 502
Симптом: POST /api/assistant/questions отдавал 502 «Не удалось сгенерировать
вопросы» ровно через ~15с. Причина: callLLM имел жёсткий таймаут 15с, а
бесплатная модель (owl-alpha) на генерацию 2200 токенов JSON порой тратит
больше — abort по таймауту, failover не выручал. Чат-ответам 15с хватает,
а генерации — нет.

callLLM/callLLMFailover получили опц. параметр timeoutMs (деф. 15с — чат не
тронут). questionsFromText → 45с, flashcardsFromText → 40с. Клиент req()
без своего таймаута, дождётся ответа.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:06:37 +03:00
Maxim Dolgolyov 368cf30d58 docs(teacher-guide): актуализация под текущее состояние системы
Добавлено/обновлено в руководстве для учителей:
- Квантик-ассистент: режим «Тест в банк» (генерация вопросов в банк), контекст
  страницы/урока; админ-глава A7 — сканер моделей, keyless-провайдер (Pollinations),
  сократический режим, авто-проверка провайдеров, «Знания о системе» (индексация).
- Глава «Ещё модули»: игра «Квантик: Законы Мира» (/quantik), трекер пожеланий
  (/wishes), Путеводитель (/sitemap); уточнено описание Коллекции (карточки-темы,
  бронза→платина); Мои материалы — папки и теги.
- Перечни фича-флагов дополнены новыми модулями + заметка про админ-оверрайд.

Только teacher-guide.html. Inline-скрипт валиден (node --check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:56:03 +03:00
Maxim Dolgolyov 81bf5d75eb fix(features): админ-оверрайд в requireFeature — API отключённого модуля 404-ил админа
Симптом: collection выключен, админ открывал страницу (фронтовый админ-оверрайд),
но GET /api/collection отдавал 404 — requireFeature 404-ил всех. requireFeature
идёт ДО authMiddleware (req.user нет), поэтому сам декодирую Bearer-токен: если
роль admin — пропускаем к API даже выключенного модуля. Для student/teacher всё
по-прежнему 404 (модуль скрыт). Зеркалит фронтовый _isAdminUser. Чинит ВСЕ
отключённые модули для админа, не только коллекцию.

Проверено: admin→bypass, student/teacher/нет токена/мусор/чужой секрет→404 (6/6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:41:25 +03:00
Maxim Dolgolyov f37796f07b feat(assistant): описания модулей exam9/игры/quantik/live-quiz/classroom/sitemap в каталоге
В MODULE_CATALOG добавлены/исправлены записи под реальные фича-флаги:
exam9 (Подготовка к экзамену), classroom (Онлайн-урок), отдельно crossword и
hangman вместо общего games, + quantik (игра), live_quiz (Live-викторина),
sitemap (Путеводитель). Эти модули больше не «без описания» — Квантик отвечает
по ним подробно. Пометка «без описания» в админке очистится после переиндексации.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:38:04 +03:00
Maxim Dolgolyov 1915026bff feat(assistant): авто-переиндексация системы при смене флагов + пометка «без описания»
- updateFeatures вызывает _autoReindexSystem(): при тоггле любого модуля снимок
  знаний о системе обновляется сам (только если уже индексировали — не создаёт
  KB на пустом месте). Кнопку жать больше не нужно после смены флагов.
- getAssistant отдаёт systemUndoc — модули с фича-флагом, но без записи в
  каталоге; админ-карточка показывает «Без описания: …» (пассивная подсказка,
  без пушей), чтобы при желании дополнить «Описание системы».

Проверено: авто-реиндекс (не создаёт пустой / обновляет существующий) + undoc 3/3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:34:43 +03:00
Maxim Dolgolyov 36eb0b980b feat(assistant): авто-подхват новых модулей по фича-флагам в индексации системы
buildSystemKb теперь добавляет в снимок ЛЮБОЙ фича-флаг, которого нет в
MODULE_CATALOG, как «функция X — вкл/выкл» (assistant-сам пропускается).
Новый модуль с фича-флагом попадает в знания Квантика автоматически после
«Проиндексировать», без правки кода. Для красивого описания/ссылки — запись
в каталоге или поле «Описание системы».

Проверено: авто-подхват 6/6, node --check OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:30:49 +03:00
Maxim Dolgolyov 08da26afca feat(assistant): индексация системы из админки — Квантик знает актуальные модули
Кнопка «Сохранить и проиндексировать систему» в /admin#assistant собирает снимок:
- статус модулей по фича-флагам (что ВКЛЮЧЕНО/ВЫКЛЮЧЕНО сейчас) + каталог разделов;
- редактируемое «Описание системы» админа.
Снимок кладётся в app_settings.assistant_system_kb и подмешивается в ответы:
systemContext(q) ищет по знаниям (стем-префикс под русскую морфологию) и
добавляет в контекст — Квантик опирается на актуальное состояние и не предлагает
отключённое.

Бэкенд: MODULE_CATALOG + buildSystemKb + indexSystem (POST /admin/assistant/index-system),
saveAssistant(+systemDoc), getAssistant(+systemDoc/Count/At), systemContext в ask и askStream.
Клиент: LS.adminAssistantIndexSystem. Без миграции (хранение в app_settings).
Проверено: логика снимка/поиска 5/5, node --check всех файлов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:27:53 +03:00
Maxim Dolgolyov 64ea552cf8 feat(flashcards): системный skeleton-лоадер вместо текста «Загрузка…»
Простой текст «Загрузка…» в сетке колод заменён на общесистемный лоадер:
loadDecks() рисует LS.skeleton(6,'card') (шиммер-карточки под форму будущих
колод) на время запроса; статический плейсхолдер — системный .spinner из ls.css.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:11:27 +03:00
Maxim Dolgolyov 86b2ac1e2d fix(flashcards): TDZ — _collLabels объявлен до init(), а был после
let _collLabels стоял после bootstrap init()-loadDecks(), который читает его
синхронно, отсюда ReferenceError (доступ до инициализации). Перенёс объявление
в верхний блок состояния (до IIFE), убрал позднее повторное let.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:07:41 +03:00
Maxim Dolgolyov 4be7f9a07c fix(lab-organic): жидкость в пробирке не вылезает за стекло
Клип жидкости был прямоугольник на всю ширину + полукруг — вместе они
шире трубки в зоне закруглённого дна, и заливка торчала за стеклом.
Заменён на путь по внутреннему контуру пробирки (прямые стенки +
дугообразное дно, тем же радиусом w/2-4, что и обводка стекла).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:45:02 +03:00
Maxim Dolgolyov 7829360391 fix(lab-organic): колбы больше + гарантированно в зоне видимости
Пробирки «Качественных реакций» масштабируются по высоте канваса
(190..340px вместо фикс. 150, шире), а вертикальная позиция ty
клампится (≤210) — даже при некорректно большой высоте канваса
колбы всегда остаются в верхней видимой части, а не уезжают за экран.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:38:54 +03:00
Maxim Dolgolyov 09dc62dc96 fix(lab-organic): колбы «Качественных реакций» уезжали за пределы экрана
Canvas был прямым flex-ребёнком (flex:1). _drawQual выставляет
c.height = rect.height*dpr → intrinsic-высота канваса росла, а при
min-height:auto flex-элемент не сжимался ниже неё → разгон высоты
(×dpr на каждую перерисовку). Пробирки центрировались по H/2 и
оказывались далеко ниже видимой области.

Канвас обёрнут в position:relative;flex:1;overflow:hidden и сам стал
position:absolute (как рабочий канвас конструктора молекул) —
intrinsic-размер больше не влияет на раскладку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:34:58 +03:00
Maxim Dolgolyov 8303d483cc fix(labs): SVG-стрелки уравнений рисовались как сырой текст на canvas
Уравнения реакций содержат inline <svg class=ic> стрелки. На canvas
(fillText) разметка показывалась буквально. Добавлен общий хелпер
ChemVisuals.cleanIcons (SVG→Unicode →/↑/↓), применён в flask (eq),
redox (s.txt) и chemsandbox (ответ квиза — был единственный незакрытый
путь мимо _csClean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:31:35 +03:00
Maxim Dolgolyov 59ea5e7d65 fix(lab-measure): оверлей измерений на весь экран (SVG не растягивался по inset)
#lm-svg — это <svg> (заменяемый элемент с intrinsic 300x150); inset:0 без явных
размеров его не растягивал, поэтому линейка/угол рисовались за пределами видимой
области и казались нерабочими (панель-div при этом видна). Добавлены width:100vw;
height:100vh — оверлей теперь покрывает вьюпорт, инструменты видны и перетаскиваются.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:26:31 +03:00
Maxim Dolgolyov 254f373522 fix(lab-measure): «Скрыть» (×) закрывает панель целиком, а не только оверлей
Кнопка off раньше прятала линейку/угол, но оставляла тулбар на экране —
теперь снимает и bar (полное закрытие, как ожидается от ×).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:17:22 +03:00
Maxim Dolgolyov 70e1b0db53 fix(admin): синхрон вайтлиста FREE_STUDENT_MODULES с FS_FEATURES
Тумблеры imggen и quantik в админ-разделе «Бесплатный ученик» были
дохлыми: фронт (FS_FEATURES) их показывал, а бэкенд-вайтлист их отбрасывал
(updateFreeStudentFeatures: continue), getFreeStudentFeatures не возвращал —
тумблер всегда «вкл» и ничего не делал. Добавил imggen и quantik в список.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:16:37 +03:00
Maxim Dolgolyov cd0ce17a60 feat(lab-graph): удобство и красота — скрытие функций, точки, контролы вида, пинч
«График функции», большой апгрейд UX:
- у каждой функции кнопки «глаз» (скрыть/показать, не удаляя) и «очистить»;
  скрытая — приглушена и зачёркнута, исключается из графика/hover/значений
- плавающие контролы вида поверх canvas: зум +/−, сброс вида, тумблер «Особые точки»
- ОСОБЫЕ ТОЧКИ: нули функций, y-перехваты и пересечения кривых — ringed-точки
  с подписью координат (бисекция по смене знака; правка: точные нули на узлах
  сетки больше не теряются; дедуп; подписи скрываются при «частоколе» >22 точек)
- пинч-зум двумя пальцами к центру жеста (к 1-пальцевой панораме)

Движок: setHidden/setShowPoints/_drawPoints/_findZeros/_visible; hover и
инфобар уважают скрытие. Только фронт. node --check OK; zero-finder 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:11:21 +03:00
Maxim Dolgolyov fa29332bcd feat(lab-graph): введённые функции — редактируемое KaTeX-поле
Введённая функция показывается отрисованной формулой KaTeX прямо в строке;
клик по формуле → правка текста на месте (raw input + живое превью под полем),
клик мимо/blur → снова формула. Реализовано без MathQuill: .fn-field держит
<input> и .fn-math (KaTeX), класс has-math переключает отображение по фокусу.

- renderFnMath() рисует формулу в строке; _fnDisplay() решает режим (фокус+значение)
- focus/blur/mousedown-обработчики в _initGraphPanel (идемпотентно)
- живое превью .fn-preview теперь видно ТОЛЬКО при правке (:focus-within), цвет функции
- graphInsert/applyPreset/state-apply/clearAll/default-fn0 обновляют math-поле
- _katexInto() — общий безопасный рендер

Только фронт. node --check OK; логика вставки 5/5 (прошлый прогон).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:02:08 +03:00
Maxim Dolgolyov 000e42f9b3 feat(lab-graph): KaTeX-формулы + панель ввода как редактор формул
«График функции»:
- примеры (чипы) и живой предпросмотр каждой функции рендерятся в KaTeX
  (data-tex на чипах, _initGraphPanel рендерит при открытии)
- предпросмотр теперь всегда виден, крупный и в цвет функции; пустое поле
  показывает плейсхолдер-формулу приглушённо
- НОВОЕ: keypad вставки структур (x², xⁿ, √, a/b, |x|, π, sin/cos/tg/ln/eˣ, ())
  — клик вставляет в активное поле по каретке (как редактор формул в PowerPoint)
- graphInsert(token) с маркером каретки |; активное поле отслеживается по focus

Только фронт. Проверено: node --check, логика вставки 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:56:31 +03:00
Maxim Dolgolyov ce4f1dcec1 fix(qbank): ручная транзакция вместо db.transaction (node:sqlite не имеет её)
DatabaseSync (node:sqlite) не имеет .transaction() как better-sqlite3 —
в контроллерах она добавлена обёрткой в db/db.js, а скрипт открывает БД
напрямую. Заменил на runTx() с ручным BEGIN/COMMIT/ROLLBACK. Применение
предложений (--apply) теперь работает; перегенерация JSON не требуется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:21:43 +03:00
Maxim Dolgolyov 9858108556 feat(qbank): гард публикации теста + ИИ-инструмент ремонта банка вопросов
P0 целостность банка. Аудит показал: «180 битых» — ложная тревога (177 это
fill-blank с ответом в correct_text). Реально битых MCQ — 3 (single без верного
варианта), без темы — 1020 (все по математике).

- testController.update: нельзя опубликовать тест ученикам, если в нём есть
  вопрос без правильного ответа (нет верного варианта И нет correct_text);
  возвращает список brokenQuestions. unanswerableInTest() — переиспользуемо.
- scripts/fix-question-bank.js: ИИ-ремонт через шлюз Kilo. --broken (выбрать
  верный вариант среди существующих), --topics (привязать матем-вопросы к
  существующим темам, батчами). DRY-RUN по умолчанию → предложения в JSON на
  вычитку → --apply применяет. --limit N для теста. Проверено: broken 3/3
  (graph-вопросы корректно → ручная проверка), topics 12/12 в адекватные темы.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:53:15 +03:00
Maxim Dolgolyov e53c107d83 feat(materials): теги и фильтр по тегам в «Мои материалы»
- теги показываются чипами на карточках (клик по тегу — фильтр)
- панель тегов над сеткой: «Все теги» + все теги пользователя, активный подсвечен
- теги редактируются в модалках «Изменить» и «Новая заметка» (через запятую,
  normTags: тримминг, дедуп без учёта регистра, лимит 12)
- фильтр по тегу + существующий текстовый поиск (поиск уже включал tags в haystack)

Только фронт: колонка tags и приём в create/update/list уже были на бэкенде.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:35:11 +03:00
Maxim Dolgolyov c49077abbc feat(assistant): живость питомца — лицо реагирует на диалог (фича 6/6)
Лицо Квантика в шапке чата (PetSprite) меняет настроение по состоянию:
- думает (нейтральное + лёгкая анимация-покачивание asstThink) пока ждём/стримим
- радуется (happy) на готовый ответ; грустит (sad) на ошибку/лимит/«не нашёл»
- ликует (ecstatic) на сгенерированный тест и нарисованную картинку
Вплетено в send/sendNonStream/makeQuiz/drawInChat через setNameFace().
Анимация уважает prefers-reduced-motion. Только frontend.

Серия из 6 фич доработки Квантика завершена (стриминг, контекст урока,
сократический режим, авто-здоровье провайдеров, генерация тестов, живость).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:12:49 +03:00
Maxim Dolgolyov 78aea47619 feat(assistant): генерация тестов в банк вопросов (фича 5/6)
Учитель: режим «Тест в банк» в Квантике — тема/текст превращается ИИ в вопросы
с выбором ответа, ревью в чате (варианты, верный подсвечен, пояснение),
кнопка «Сохранить в банк» (выбор предмета + тема) создаёт их через POST /questions.

Бэкенд: questionsFromText (по образцу flashcardsFromText, надёжный парс JSON
с починкой обрезанного) + роут POST /assistant/questions (requireRole
teacher/admin, fcLimiter). Клиент: LS.assistantQuestions. Виджет: режим quiz
только для учителя + makeQuiz (рендер и сохранение через createQuestion/getSubjects).

Проверено на живом шлюзе: 5 валидных вопросов, верный индекс в диапазоне.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:09:02 +03:00
Maxim Dolgolyov bc0ed1892f feat(assistant): авто-здоровье провайдеров + ручная проверка (фича 4/6)
Новый модуль assistant-health.js (по образцу classroom-cleanup): каждые 15 мин
пингует каждого провайдера (pingLLM) → app_settings.assistant_health
{ id:{ok,at,error,ms,fails} }. Авто-понижение: если активный провайдер
не отвечает 2+ раза подряд, а есть здоровый рабочий запасной — автоматически
переключает assistant_active и пишет assistant_failover (баннер «health»).
schedule() из server.js (unref).

Админка: тумблер «Авто-проверка провайдеров», кнопка «Проверить сейчас»
(POST /admin/assistant/health → runHealth), цветной индикатор здоровья на
каждой карточке провайдера (зелёный/красный + время/ошибка в title).
keyless-шлюзы и провайдеры без ключа учтены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:02:37 +03:00
Maxim Dolgolyov 40c3152fe8 feat(assistant): сократический / анти-чит режим (фича 3/6)
- тумблер учителя «Сократический режим» (/admin#assistant): для УЧЕНИКОВ
  Квантик объясняет теорию полно, но конкретные задачи не решает «под ключ» —
  даёт метод, первый шаг и наводящий вопрос (assistant_socratic в app_settings)
- авто-анти-чит: явная просьба «сделай за меня / реши моё дз / do my homework»
  включает сократический режим даже без тумблера (_CHEAT_RE)
- учителей/админов и режимы hint/check не ограничивает; работает и в /ask, и в стриме

_socraticFor(role,mode,q) + проброс socratic в buildAskMessages. Бэкенд+админ-UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:57:24 +03:00
Maxim Dolgolyov 2506a72806 feat(assistant): контекст урока/страницы для Квантика (фича 2/6)
Квантик теперь знает, где находится ученик:
- pageHint() — лёгкий ситуативный контекст («ученик на странице учебник:
  «Физика 7, §12»») подмешивается к ЛЮБОМУ свободному вопросу автоматически
- getPageContext() расширен с учебника на уроки (theory/course/lesson):
  «Объяснить этот урок / Конспект урока / Флешкарты из урока»
- метки чипов адаптируются (параграф/урок)

Бэкенд уже принимал context (pageCtx) — правок не потребовалось.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:52:42 +03:00
Maxim Dolgolyov 089f93b8ee feat(assistant): стриминг ответов Квантика (фича 1/6)
Ответ модели «печатается» вживую через SSE поверх POST (fetch-stream,
не EventSource). Бэкенд: callLLMStream (stream:true, парсинг SSE upstream) +
callLLMStreamFailover (failover только до первого куска) + endpoint
POST /assistant/ask/stream (события meta|delta|done; быстрые пути FAQ/кэш/мета
отдаются одним done). buildAskMessages выделен из askModel (DRY).
Клиент: LS.assistantAskStream (fetch-stream + парсер SSE). Виджет: send()
стримит дельты как plain-текст с CSS-кареткой, на done — KaTeX-рендер,
источники, ссылки, оценка. Фоллбэк на sendNonStream (старый путь) если
стриминг недоступен/упал до первого куска. Cache-Control: no-transform
отключает буферизацию compression.

Проверено против живого шлюза: 24 дельты, первый текст ~1.3с, 100% русский.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:50:11 +03:00
Maxim Dolgolyov 5b4d9324a4 feat(assistant): поддержка keyless-шлюзов + пресет Pollinations
Pollinations (text.pollinations.ai/openai, модель openai) даёт бесплатный
инференс БЕЗ ключа — проверено: 98% чистый русский. Чтобы такой провайдер
считался рабочим (раньше ключ требовался всем, кроме localhost):
- _noKeyNeeded/_aNoKey: localhost ИЛИ pollinations.ai → ключ не обязателен
  (используется в providersOrdered, pingLLM, active-check, testAssistant)
- пресет «Pollinations (без ключа)» в ASSISTANT_PRESETS
- бейдж провайдера: «без ключа» (зелёный) вместо «нет ключа» для keyless

Кейд-провайдеры (Kilo/Gemini/HF/…) по-прежнему требуют ключ — затронуты
только URL с pollinations.ai (спуф в пути отвергается).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:35:56 +03:00
Maxim Dolgolyov 4d2d02f080 feat(assistant): пресет провайдера HuggingFace Router
Добавлен OpenAI-совместимый шлюз HuggingFace Router
(router.huggingface.co/v1/chat/completions, дефолт Qwen/Qwen2.5-72B-Instruct)
в ASSISTANT_PRESETS — выбирается из выпадашки при добавлении провайдера.
Эндпоинт /models публичный (121 модель), «Загрузить модели» работает;
нужен HF access-token с правом inference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:23:55 +03:00
Maxim Dolgolyov d15c15ef2a feat(assistant): сканер бесплатных моделей Kilo в админке
Кнопка «Сканировать модели» в /admin#assistant: тянет live-список со шлюза
провайдера, отбирает бесплатные чат-модели (музыка/картинки/модерация
отсекаются), прогоняет каждую тест-запросом на русском и показывает отчёт
(новые / исчезнувшие / % кириллицы / скорость). «Применить выбранные»
сохраняет список в app_settings (assistant_kilo_models); хардкод KILO_MODELS
остаётся сидом, есть «Вернуть встроенный список».

Backend: scanModels/probeModel/applyModels (admin-only роуты), _kiloModels()
делает список динамическим. Переиспользует _fetchModels. Клиент: adminAssistantScan/Probe/ApplyModels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:18:04 +03:00
Maxim Dolgolyov dc5501d723 fix(assistant): актуализирован список бесплатных моделей Kilo
Сверено с live-списком шлюза kilocode.ai + протестировано на русский (2026-06-24):
- удалён исчезнувший со шлюза nex-agi/nex-n2-pro:free
- исправлены устаревшие лимиты: nemotron-super ctx 1M->262K, owl-alpha опечатка 1048756->1048576
- добавлен openrouter/free (авто-роутер, 100% русский, устойчив к пропаже отдельных моделей)
- дефолтный пресет Kilo: ultra-550b (таймаутит) -> super-120b (быстрый, чистый русский)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:04:13 +03:00
Maxim Dolgolyov 4c1ce8394c feat(trigcircle): шкала значений по оси Y на графике (координатная плоскость)
- y-ось графика теперь подписана значениями (KaTeX):
  sin/cos — 1, ½, 0, −½, −1; tg/ctg — 3, 2, 1, 0, −1, −2, −3
- пунктирные линии уровней + подписи слева от панели
- подписи прячутся при смене функции (лишние уровни tg/ctg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:44:29 +03:00
Maxim Dolgolyov 0640efc82c feat(trigcircle): развёртка угла на графике + KaTeX-подписи граф-вида
- развёртка: участок кривой [0, α] выделяется ярче (с свечением) —
  видно, как угол на окружности «разворачивается» в график
- подпись текущего угла (π/3 и т.п.) на вертикальном маркере, KaTeX
- подписи делений оси X (π/2, π, 3π/2, 2π) — теперь KaTeX-оверлеем
- название функции (y = sin x / cos x / tg x / ctg x) — KaTeX-оверлеем
- _ovLabel: любая LaTeX-команда (\pi, \sin…) теперь рендерится через KaTeX

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:35:28 +03:00
Maxim Dolgolyov 7562d1a77b fix(trigcircle): координатная подпись больше не перекрывает угол
Координатный тултип (cos; sin) выносится радиально НАРУЖУ за точку (вдоль луча от центра),
а не просто со смещением — так KaTeX-плашка значений всегда дальше от центральной дуги угла
и её подписи (π/3 и т.п.), наложения нет.

Verified: node --check; смоук — coord-подпись дальше от центра, чем сама точка.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:08:09 +03:00
Maxim Dolgolyov 1707a510a9 feat(trigcircle): KaTeX-оверлей для подписей на canvas (координаты, значения, угол)
На <canvas> KaTeX не рисуется (fillText), поэтому подписи, которые были юникод-текстом
(√2/2, координаты точки, π/4, значение на графике), переведены на HTML-оверлей #trig-overlay
поверх холста с KaTeX-рендером и точным позиционированием (transform по CSS-px = координаты
canvas). Переведены: координатная подсказка (cos; sin), бейджи значений sin/cos, метка угла
у дуги, бейдж значения на графике. Подписи-слова sin/cos/tg/ctg и мелкие точки табличных
углов остаются на canvas (не математика / 16 мелких меток).

Механика: _ov/_ovLabel/_ovClearUnused — кэш по ключу (ре-рендер только при смене LaTeX),
KaTeX лишь для дробей/корней, простые числа — текстом (быстро при перетаскивании), неис-
пользованные за кадр подписи прячутся. Старые canvas-методы _badge/_tooltip больше не зовутся.

Verified: node --check; headless-смоук оверлея 12/12 (coord/vsin/vcos/angle/gval создаются,
KaTeX-LaTeX для √2/2 и π/4, позиционирование/плашка, десятичные как текст, скрытие при
выкл. слоя/графика). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:03:39 +03:00
Maxim Dolgolyov e70bf819ce docs(trigcircle): статус — Ф1–Ф6 + KaTeX-везде готовы (Ф3 уже был)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:52:44 +03:00
Maxim Dolgolyov 48158ea88d feat(trigcircle): Фаза 5 — чётность/нечётность (−α) + периоды
Тумблер «Чётность (−α)»: на окружности рисуется зеркальная точка −α (отражение через
ось Ox, пунктир P↔−α) — наглядно нечётность sin и чётность cos. Блок-справка на KaTeX
(строится один раз): sin(−α)=−sin α, cos(−α)=cos α, tg(−α)=−tg α, периоды
T_sin=T_cos=2π, T_tg=T_ctg=π. (Формулы приведения для текущего угла — уже Фаза 2.)

Аддитивно: this.showParity + _drawParity + хук в draw(); glue trigToggleParity;
тумблер + #trig-parity в панели.

Verified: node --check; headless-смоук 9/9 (_drawParity без throw для 30/150/210/300;
toggle строит блок один раз с верными тождествами+периодами, показ/скрытие). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:51:55 +03:00
Maxim Dolgolyov fe6df8fb98 feat(trigcircle): Фаза 4 — таблица значений (особые углы, KaTeX)
Тумблер «Таблица значений» → компактная таблица первой четверти (0/30/45/60/90°)
с точными sin/cos/tg/ctg на KaTeX (строится один раз). Строка опорного острого угла
текущего положения подсвечивается (150° → подсвечена строка 30°). По симметрии/приведению
(Фаза 2) это покрывает все 16 углов.

Glue: _trigBuildValueTable (один раз) + trigToggleTable; подсветка строки в _trigUpdateUI
по refDeg. Панель: тумблер + #trig-table.

Verified: node --check; headless-смоук 10/10 (5 строк, заголовки, значения 30/45/90,
tg «не опр.», toggle показ/скрытие). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:49:33 +03:00
Maxim Dolgolyov 244df71aec feat(trigcircle): вся математика панели на KaTeX (значения + угол)
Значения sin/cos/tg/ctg в панели и стат-баре теперь рендерятся KaTeX для дробей/корней
(\tfrac{1}{2}, \tfrac{\sqrt{3}}{2}, …), а простые числа (0, ±1, десятичные) — текстом
(быстро при перетаскивании, без лишних KaTeX-вызовов). Бейдж угла — KaTeX π-доли по
таблице 16 углов (150° = 5π/6, 210° = 7π/6, …) + радианы + котерминальные.

Хелперы: _tex (общий рендер с фолбэком), _angleLatex/_piLabelToLatex (рад → LaTeX π-доли),
setMathVal (KaTeX только для нетривиальных форм). Формулы значений/приведения и уравнений
уже были на KaTeX.

Verified: node --check; headless-смоук 9/10 (10-я — артефакт стаба: в реальном DOM
textContent= очищает прежний innerHTML; логика LaTeX верна). Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:47:30 +03:00
Maxim Dolgolyov 5bb0aeb940 docs(trigcircle): отметка статуса фаз (Ф1/Ф2/KaTeX/Ф6 готовы)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:41:37 +03:00
Maxim Dolgolyov dfa0535b63 feat(trigcircle): Фаза 6 — простейшие тригонометрические уравнения
Режим уравнения fn(x)=a (sin/cos/tg): окружность подсвечивает ВСЕ решения на [0,2π)
(точки + направляющая линия значения), а панель показывает общую формулу через KaTeX:
  sin x=a → x=(-1)ⁿ·arcsin a + πn;  cos x=a → x=±arccos a + 2πn;  tg x=a → x=arctg a + πn.
Для табличных значений главное значение подставляется точно (arcsin½=π/6 и т.п.), для
нетабличных — символьно (\arcsin a). |a|>1 для sin/cos → «нет решений». Список решений
в градусах. setEquation встаёт на первое решение; clearEquation выходит из режима.

Аддитивно: новое поле this.eq + методы setEquation/clearEquation/_drawEquation + хук в draw();
glue trigSetEqFn/trigSolve/trigClearEq/trigEqKey; секция «Уравнение» в панели labs-bodies.

Verified: node --check; headless-смоук 13/13 (решения sin/cos/tg/один/нет; формулы
(-1)ⁿ/±/+πn/none/нетабличное→arcsin) + изолированная отрисовка _drawEquation без throw.
Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:41:07 +03:00
Maxim Dolgolyov cefb5e0836 feat(trigcircle): рендер формул через KaTeX
Блок «Точные значения · приведение» теперь рендерится KaTeX (katex.renderToString,
как в graph.js/_sim_engine), с фолбэком на сырой LaTeX если katex ещё не загрузился.
Добавлен _latexVal (точное значение → LaTeX: \tfrac{1}{2}, \tfrac{\sqrt{3}}{2}, …),
функции \sin/\cos/\operatorname{tg}/\operatorname{ctg}, цвет наследуется от CSS-цвета
контейнера (без \textcolor). Формула приведения и значения — те же (проверено).

Verified: node --check; headless-смоук LaTeX-вывода (дамп + проверки) — 150°=180°−30°
sin=½ cos=−√3/2 tg=−√3/3; 45° без приведения √2/2; 90° tg «не опр.»; 210°=180°+30°;
300°=360°−60°; 137° нетабличный. Эмодзи нет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:35:25 +03:00
Maxim Dolgolyov 5eed248702 feat(trigcircle): Фаза 2 — точные значения + формулы приведения
При исследовании выяснилось: Пифагор (sin²+cos²=1, _pythBar) и знаки по четвертям
(_quadSigns) уже рисуются на canvas. Поэтому Фаза 2 даёт главное недостающее по программе —
блок «Точные значения · приведение»: для текущего угла показывает sin/cos/tg/ctg точными
значениями (½, √2/2, √3/2, √3/3, √3) и для нетривиальных четвертей — формулу приведения
к острому углу (напр. 150° = 180°−30°, cos 150° = −cos 30° = −√3/2). Нетабличный угол →
сообщение. Без KaTeX (чистый HTML + готовый форматтер _f), без новых зависимостей.

Verified: node --check; headless-смоук рендера 11/11 (150° приведение+знаки, 45° QI без
головы, 210° QIII tg+, 137° нетабличный). Эмодзи нет.

sec/csc (5-я/6-я функции) — вторичны для школьной программы, отложены (предложу опционально).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:30:02 +03:00
Maxim Dolgolyov d395e1083b feat(trigcircle): Фаза 1 — работа с углами + обзор (тренажёр тригонометрии)
План тренажёра в plans/trig-circle/PLAN.md (всё по теме на окружности, кроме графиков функций).
Фаза 1 (аддитивно к рабочему режиму):
- Ввод угла в градусах (поле + Enter/кнопка) → goToAngle (нормализует, показывает
  котерминальность). Подсказка «+360°·k» в бейдже угла.
- Тумблер «График/функции» — скрыть график (тема «функции») → круг на всю ширину
  (переиспользует существующий слой graph + _layout).
- Полная сетка табличных углов (16: 0…330°) вместо 8.
- Опорный (острый) угол к оси Ox в выводе (основа формул приведения) + знаки sin/cos/tg
  по текущей четверти. stats() расширен полями refAngle/refDeg.

Verified: node --check; headless-смоук (vm + canvas-Proxy) 9/9 — опорный угол 30/150/210→30°,
300→60°, 90→90°, 0→0; знаки по четвертям (II: sin+ cos− tg−; IV: sin− cos+); новые
глобальные glue-функции определены. Эмодзи нет (стрелка — inline SVG .ic, tg-неопр. — em-dash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:23:31 +03:00
Maxim Dolgolyov 40df8893cc fix(lab): значок «связанной симуляции» на карточках учебников не скрывался при выключенной лаборатории
В каталоге учебников (textbooks.html) у карточек есть кнопка .tb-lab-btn «открыть
связанную симуляцию» (openLabSim → /lab?sim=…). Это <button onclick>, а не <a href="/lab">,
поэтому kill-switch `[href="/lab"]` её не ловил, и значок-колба оставался при отключённой
«Лаборатории».

Фикс: добавил `.tb-lab-btn` в FEATURE_WIDGETS.lab → api.js скрывает её через инъекцию
при lab=false (работает и без ls.css). Плюс страховка в openLabSim: при lab=false не
открываем (тост «Лаборатория отключена»); админ — всегда (admin-override).

Verified vm-смоук на реальном api.js 4/4 (lab off → .tb-lab-btn скрыта; lab on → нет;
admin → ничего). node --check api.js + инлайн textbooks.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:36:11 +03:00
Maxim Dolgolyov 8027d9fda0 fix(gamification): kill-switch не доходил до учебников (нет ls.css)
Учебники (frontend/textbooks/*.html, 112 шт.) грузят api.js, но НЕ ls.css. api.js ставил
класс .no-gamification на <html>, но сами правила kill-switch (`[data-gamified]`, попап XP)
живут в ls.css → до учебников не доходили, и встроенная XP-механика (бейдж/карточка XP,
ачивки, level-up попап #ach-popup) продолжала отображаться при выключенной геймификации.

Фикс — централизованно в _applyFeatureCss: при gamification=false дублируем правила
`.no-gamification [data-gamified],#ach-popup{display:none}` в инъектируемый <style>, поэтому
kill-switch работает на ЛЮБОЙ странице с api.js, без ls.css. Плюс на страницах без сайдбара
(учебники/embed) теперь авторитетно дёргаем loadFeatures() (только для залогиненных,
in-memory-дедуп) — кэш фич там не обновлялся. Админ по-прежнему видит всё (admin-override).

Verified vm-смоук на реальном api.js 7/7 (student+off → правило инъектится; student+on → нет;
admin → ничего).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:25:45 +03:00
Maxim Dolgolyov 43df41287f feat(errors): сбор клиентских (браузерных) ошибок в админ-вкладку «Ошибки»
Глобальный репортер в api.js (грузится на всех страницах) ловит необработанные JS-ошибки
(window 'error') и rejected-промисы ('unhandledrejection') в браузере пользователя и шлёт
в POST /api/client-errors. Дедуп по сигнатуре + лимит 15/страницу, только для залогиненных,
keepalive, не флудит и сам не падает.

Бэкенд: routes/clientErrors (auth + rate-limit 20/мин на юзера) → clientErrorController
пишет в общий error_log с level='client' (message/stack/route=url/method=kind/user_id),
поля обрезаются. Появляются в существующей админ-вкладке «Ошибки» с бейджем «БРАУЗЕР»
(фиолетовый акцент vs розовый у серверных). Тест client-errors.test.js 5/5.

lint:routes 0; node --check всех файлов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:17:04 +03:00
Maxim Dolgolyov db1db68488 fix(wishes): TypeError в toggleForm — lucide заменял <i> на <svg>
Кнопка «Поделиться идеей» падала: btn.querySelector('i') возвращал null, т.к. lucide.createIcons
при первом рендере заменяет <i data-lucide> на <svg>. Обернул иконку в стабильный
контейнер #wq-new-ic и пере-вставляю свежий <i> в его innerHTML перед icons() (с guard).

Headless-смоук toggleForm 5/5 (open/close, смена иконки chevron-up/plus, без throw).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:02:54 +03:00
Maxim Dolgolyov 3c45c606bf feat(admin/tests): пикер вопросов — серверный поиск по всему банку + «Показать ещё» + фильтры
Раньше «Добавить вопросы» в конструкторе тестов грузил лишь первые 100 вопросов предмета
(дефолтный лимит API), а поиск фильтровал клиентски только эти 100 — для математики
(1753 вопроса) 1653 были недоступны и не находились.

Теперь пикер ходит на сервер (бэкенд уже умеет q/difficulty/type/page): поле поиска
(debounce 300мс) ищет по ВСЕМУ банку предмета; кнопка «Показать ещё» подгружает
страницами по 100 с индикатором «Показано N из total»; добавлены фильтры по сложности
и типу. Поиск/фильтры сохраняются между перерисовками (после добавления вопроса).

Чистый фронтенд (tests.js + CSS в admin.html); бэкенд не тронут. Verified:
backend list q/difficulty/type/paging 8/8; headless-смоук пикера 12/12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:17:17 +03:00
Maxim Dolgolyov 1649d6c2ec fix(admin): список сессий показывает и незавершённые (зависшие), +фильтр ?status
Корень бага «зависший тест есть в алерте, но нет в списке»: getAllSessions жёстко
фильтровал WHERE status='completed' → in_progress (зависшие) сессии в списке /admin#sessions
не появлялись. Теперь по умолчанию показываются все статусы (UI sessions.js уже null-safe:
percent/score/duration рисуются как «—»), плюс опциональный ?status=completed|in_progress|abandoned
для сужения. Зависшая сессия теперь находится и в списке, и открывается по deep-link из алерта.

admin-sessions.test.js 4/4; node --check OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:14:56 +03:00
Maxim Dolgolyov 4b5be8442b fix(admin): «Открыть» зависшей сессии ведёт на её детали, а не в пустой список
Алерт «Зависла» (in_progress >1ч) вёл на /admin#sessions, но список сессий показывает
ТОЛЬКО completed (getAllSessions: WHERE status='completed') — поэтому зависшей сессии там
не было (симптом: «показывает зависший тест, но в списке его нет»). Теперь «Открыть»
делает deep-link на детали конкретной сессии /admin#sessions/<id> — страница деталей
открывает сессию при любом статусе (getSessionDetail без фильтра по статусу) и позволяет
её посмотреть и удалить.

node --check OK; id присутствует в payload overview (stuckSessions → ts.id).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:02:53 +03:00
Maxim Dolgolyov 3898080f04 fix(features): админ открывает отключённые модули — пейдж-гейты уважают admin-override
Причина бага «из админа конструктор симуляций редиректит на дашборд»: у sim-builder.html
свой пейдж-гейт, который при feature_sim_builder=false уводил на /dashboard НЕЗАВИСИМО от
роли (мой прошлый admin-override был только в hideDisabledFeatures, а этот гейт его не знал).

Тот же недочёт нашёлся ещё у 3 страниц с собственным фича-редиректом (на /403):
collection.html, knowledge-map.html, red-book.html. Во все 4 добавил обход для админа
(админ управляет модулями → видит и открывает всё, даже отключённое) — согласно правилу
admin-override. Поведение для ученика/учителя не изменилось.

node --check инлайна всех 4 страниц — OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:59:51 +03:00
Maxim Dolgolyov efba722977 feat(wishes): редизайн страницы — удобнее и красивее
Полный фронт-редизайн /wishes (бэкенд не тронут):
- Hero с градиентной иконкой; «Поделиться идеей» — сворачиваемая форма (по умолчанию
  свёрнута, если пожелания уже есть; список сразу виден).
- Визуальный выбор категории чипами с иконками/цветом вместо select; счётчик символов.
- Статус-пилюли вверху с counts — кликабельный фильтр (для всех ролей, не только админ).
- Подбар: фильтр по категориям + живой поиск (по заголовку/тексту/автору); адаптивно
  скрывается, когда мало данных.
- Карточки: цветная иконка категории, статус-бейдж с иконкой, ответ админа в выделенном
  блоке, анимация появления, hover. Дружелюбные empty-состояния (нет идей / ничего не найдено)
  и скелетоны при загрузке.
- Клиентская фильтрация (один fetch, мгновенно) + точечные обновления списка без перезагрузки
  после создания/сохранения/удаления.

Verified: рендер-смоук 13/13 (карточки, иконки категорий, статусы, ответ, фильтры
status/cat/поиск с тогглом, empty); node --check инлайна; эмодзи нет (иконки — lucide).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:26:32 +03:00
Maxim Dolgolyov be9fdfa703 feat(wishes): трекер пожеланий по улучшению системы
Любой авторизованный пользователь подаёт пожелание (заголовок, категория, описание);
видит только свои. Админ видит все, фильтрует по статусу, ведёт по статусам
(новое → запланировано → в работе → готово / отклонено) и пишет ответ автору. Автор
получает уведомление при смене статуса (pushNotif).

Бэкенд: миграция 080 (таблица wishes), wishController (list/create/update/remove с
валидацией и whitelist категорий/статусов), routes/wishes (PATCH — только админ, DELETE —
автор«новое»/админ, проверка в хендлере), смонтировано в server.js. Тесты 15/15.

Фронт: страница /wishes (форма + список со статус-бейджами; у админа — фильтры,
смена статуса, ответ, удаление), пункт «Пожелания» в сайдбаре (все роли), фиче-флаг
feature_wishes_enabled (тумблер в админ-модулях + whitelist + FEATURE_HREFS; админ
видит всегда). Клиентские врапперы LS.wish*.

⚠️ Живой БД нужен npm run migrate (080). lint:routes 0; node --check всех файлов + инлайна.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:12:10 +03:00
Maxim Dolgolyov 758e1bf6cb feat(dashboard): статус «идёт онлайн-урок» с присоединением
На дашборде ученика/учителя — баннер активной classroom-сессии: заголовок урока,
для учителя «N онлайн», для ученика «Присоединиться/Вернуться», ссылка на /classroom
(там сессия подхватывается автоматически). Данные — LS.crGetMySession (учитель → своя
сессия, ученик → сессия его класса/приглашения). Нет активной сессии → баннер скрыт.

Доска работает по WebSocket, дашборд — по SSE, поэтому добавлен отдельный SSE-сигнал
classroom_live (state started/ended) ученикам класса/приглашённым/учителю в createSession
и endSession (аддитивно, в try/catch — не ломает создание/завершение сессии). Баннер
живо появляется/исчезает по этому событию + обновляется при возврате на вкладку.

Verified: рендер баннера 10/10 (ученик/учитель/нет сессии, online-счёт без вышедших,
пустой title→«Онлайн-урок»); node --check sessions.js + инлайна dashboard; sse-путь резолвится.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:24:14 +03:00
Maxim Dolgolyov 0d4c658d93 refactor(assignments): единый модуль assignment-utils.js (тип/«сдано»/срочность)
Логика классификации типа задания и статуса «сдано» дублировалась в трёх местах
(dashboard.html, homework.html, assignmentController.js) и начала расходиться. Вынес в
один UMD-модуль frontend/js/assignment-utils.js (грузится и в браузере, и в Node через
require — как svg-sanitize.js): type(a), isDone(a, sub, opts), urgencyScore(a).

Нюанс «сдано» для upload/file параметризован: вид ученика (acceptedOnly) — закрыто только
при принятой сдаче; учитель/обзор долгов — любая сдача не на доработке. Поведение всех трёх
поверхностей сохранено 1:1.

- homework.html: asgnType/asgnDone/urgencyScore → тонкие делегаты в AssignmentUtils.
- dashboard.html: urgencyScore делегирует; classify и upload-ветка buildAssignCard через
  AssignmentUtils.type/isDone (заодно корректнее: учебник-ДЗ больше не путается с upload).
- assignmentController: classOutstanding/_assignTypeOf → AssignmentUtils.

Verified: AU-контракт 25/25 (типы, isDone teacher vs acceptedOnly, порядок urgency);
интеграция 8/8 (classOutstanding те же 14 уч./42 просрочено; homework делегирует). node --check
всех файлов + инлайна обоих HTML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:09:34 +03:00
Maxim Dolgolyov 5a4bc48027 feat(classes): вкладка «Долги» — что висит у учеников + удаление ДЗ класса/ученика
Новый read-only эндпоинт GET /api/classes/:id/outstanding (teacher/admin, ownership):
по каждому ученику класса — его незакрытые задания (классовые + личные от этого учителя)
со статусом не начато / в процессе / на доработке / просрочено и дедлайном. Логика «сдано»
совпадает с /homework (тест — завершён/исчерпаны попытки; учебник — всё прочитано;
загрузка/файл — есть сдача не на доработке). Общий запрос вынесен в assignmentRowsForUser(uid)
— им же теперь питается /assignments/my (поведение не изменилось, +поле created_by).

Фронт (classes.html): вкладка «Долги» в карточке класса — сводка «должников X из Y,
просрочено Z», по каждому должнику карточка со статус-чипами и списком зависших заданий;
бейдж с числом просрочек на вкладке. Удаление прямо из списка: личное → «у ученика»,
классовое → «у всего класса» (через DELETE /assignments/:id, ownership на бэке).

Verified: classOutstanding смоук на живой БД (14 учеников/58 позиций/42 просрочено,
ownership 403/404/admin); node --check; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:46:45 +03:00
Maxim Dolgolyov 73ba5a3530 fix(homework): блок ДЗ — только задания с флагом is_homework; ясная подпись типа
(1) На /homework блок «Актуальные задания» и выпадашка загрузки теперь показывают
только задания с флагом ДЗ (is_homework). Обычные тесты/экзамены и не-ДЗ файлы сюда
не попадают. Выпадашка привязки — только типы upload/file (куда ученик реально сдаёт
файл), без тест-ДЗ и учебников.

(2) В конструкторе задания (classes.html) вкладка типа «Сдать работу» → «Загрузка
работы»: остальные вкладки — существительные-типы контента (Файл, Готовый тест), а
«Сдать работу» читалась как действие ученика. Это тип upload — ДЗ, куда ученик грузит
файл (saveAssignment: is_homework=1, count=1); сам тип нужен, поправлена только подпись.

Headless-смоук фильтра ДЗ 6/6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:08:57 +03:00
Maxim Dolgolyov a7f2ae9937 fix(features): админ видит и открывает все модули, даже отключённые
Скрытие фич в сайдбаре (и kill-switch геймификации) применялось независимо от роли —
у админа тоже пропадали пункты отключённых модулей. Админ управляет модулями, поэтому
должен видеть и открывать всё.

Добавлен admin-override (_isAdminUser, читает getUser() синхронно из localStorage —
работает и на ранней FOUC-инъекции): для админа _applyFeatureCss чистит <style>-скрытие
и снимает .no-gamification; hideDisabledFeatures выходит до любых скрытий/редиректов;
showBoardIfAllowed показывает доску админу всегда. Поведение для student/teacher не
изменилось. Verified vm-смоук на реальном api.js 10/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:03:09 +03:00
Maxim Dolgolyov 748b0aaab1 feat(homework): блок «Актуальные задания» на странице /homework
Раньше страница «Домашние задания» показывала только историю сдач, а сам список
актуальных ДЗ (что нужно сделать, с дедлайнами) жил лишь на дашборде. Теперь у ученика
сверху секция «Актуальные задания» на тех же данных (LS.myAssignments) — карточки с
дедлайном/просрочкой/срочностью, действие по типу задания: тест → Начать/Продолжить
(LS.startAssignment), учебник → Открыть §, файл → Скачать, ДЗ-загрузка → Сдать
(прокрутка к области загрузки + преднабор задания). Закрытые задания (пройденный тест,
прочитанный учебник, принятая работа) скрыты; пустая секция не показывается.

Заодно убрано ограничение «только первый класс» в выпадашке загрузки: задания берутся
по всем классам, а класс для отправки выводится из выбранного задания (data-class) —
чинит сдачу для учеников в нескольких классах.

Чисто фронтенд, аддитивно. Headless-смоук рендера 19/19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:57:50 +03:00
Maxim Dolgolyov 22c7b38e9a feat(admin): сброс системы «чистый запуск» в веб-панели
Добавлено такое же действие, как [Z] в control-panel: POST /api/admin/reset-system
(+ /reset-system/plan для предпросмотра), только admin. Общая логика вынесена в
src/services/systemReset.js (classify/pickKeptAdmin/runReset) — реюзится CLI и эндпоинтом.

Веб-эндпоинт безопаснее CLI: сохраняет ТЕКУЩЕГО админа (оператор остаётся залогинен),
делает бэкап БД ДО сброса (wal_checkpoint + копия в data/backups/), требует body.confirm='СБРОС'.
UI — «Опасная зона» в overview-секции: предпросмотр плана + ввод «СБРОС» + результат с именем бэкапа.

db.js: добавлен db._path (нужен бэкапу при сбросе). Логика проверена смоуком на копии живой БД
(16 юзеров удалено, контент сохранён, REASSIGN на админа, гейм-счётчики обнулены, 0 висячих FK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:45:13 +03:00
Maxim Dolgolyov 205290139d feat(control-panel): сброс системы «чистый запуск» (с бэкапом и подтверждением)
Пункт [Z] в control-panel.ps1: предпросмотр → бэкап БД → подтверждение вводом
«СБРОС» → очистка. Скрипт backend/scripts/reset-system.js (dry-run по умолчанию,
выполнение только с --apply --confirm=RESET):
• сохраняет одного админа (min id), переназначает ему авторский контент
  (courses/tests/flashcard_decks/custom_sims/шаблоны/библиотека/lab-ссылки/board);
• стирает всех остальных пользователей + классы/задания/сессии/геймификацию/
  уведомления/прогресс/историю классрума/доступы/логи;
• сохраняет контент: учебники, вопросы, темы, уроки, exam-prep, симуляции,
  биохимия, красная книга, магазин/достижения-определения, роли/права, app_settings;
• обнуляет игровые счётчики админа; классифицирует ВСЕ 119 таблиц, неизвестные не трогает;
• FK off + транзакция + foreign_key_check + VACUUM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:32:44 +03:00
Maxim Dolgolyov c6d323ec6d feat(tests): витрина доступных тестов ученику + флаг «доступен ученикам»
Раньше ученик видел лишь 1 тест на предмет (дефолтный). Теперь учитель/админ
может пометить любой свой тест доступным, и он появляется в каталоге на дашборде.

- Миграция 079: tests.available_to_students (default 0).
- testController: list для ученика отдаёт тесты с available_to_students=1 и вопросами;
  create/update принимают флаг; update сделан частичным (не затирает поля при toggle).
- admin «Тесты»: бейдж «Доступен ученикам» + быстрый тумблер «Ученикам/Скрыть»
  (toggleTstAvail; конструктор доступен и учителям — видят свои тесты).
- Дашборд: виджет «Тесты» → секция «Доступные тесты» (loadAvailableTests), клик
  запускает фикс-тест. Прячется, если доступных нет.

⚠️ Живой БД нужен npm run migrate (колонка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:42 +03:00
Maxim Dolgolyov c5d440a7a9 fix(tests): режимы доступных тестов только exam/practice + скрытие пустых предметов
Рассогласование: админ-настройка допускала режимы topic/random, но POST /api/sessions
принимает только exam/practice → клик по такому предмету падал с 400. Убрал topic/random
из валидатора subjects.js и из админ-дропдауна (SC_MODES). Дашборд: старые значения
topic/random коэрсятся в practice; предметы без вопросов в банке И без фикс-теста больше
не показываются (раньше давали 404 «No questions found» при запуске).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:53:43 +03:00
Maxim Dolgolyov 1aa95a6776 fix(dashboard): hero «Лаборатория дня» виден при выключенной лабе
Hero-карточка #hc-lab имела href="/lab", но loadLabOfDay меняет его на
/lab?sim=<id> → CSS [href="/lab"] больше не матчит, карточка оставалась видной.
Прячем по стабильному id: #hc-lab/#hc-pet/#hc-read добавлены в FEATURE_WIDGETS
(lab/pet/textbooks). .hero-row переведён на grid auto-fit (minmax 240) — сетка сама
подстраивается под видимые карточки без дыры; syncHeroRow прячет весь ряд, если
карточек не осталось (мобайл-медиазапрос не трогаем — без инлайн-колонок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:37:41 +03:00
Maxim Dolgolyov 399a222b65 fix(dashboard): пустой бокс колонки прогресса, когда флешкарты отключены
#w-flashcard прятался, но он — секция внутри #w-progress-col (один .widget-бокс с
рамкой/паддингом: карточка + прогресс по предметам + результаты). Если все секции
скрыты (флешкарты выкл и нет данных), оставался пустой бокс. Добавлена
syncProgressCol(): прячет #w-progress-col, если ни одна секция не видна (computed-
display, учитывает и инъект-CSS флешкарт). Зовётся в конце loadFlashcardWidget /
loadLastResultsWidget / loadSubjProgressWidget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:26:33 +03:00
Maxim Dolgolyov 796a2416cb chore(admin): секция «Игры» → «Модули» (там уже не только игры)
Вкладка/заголовок админ-панели переименованы: nav «Игры»→«Модули» (иконка
gamepad-2→layout-grid), section-title «Управление играми»→«Управление модулями»,
описание про «отключённые игры»→«отключённые модули». В секции теперь лаба,
теория, путеводитель, доска, классрум и т.д. — не только игры. Free-student
подсекция уже звалась «Модули…» — консистентно.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:11:09 +03:00
Maxim Dolgolyov 604fa7ac0b fix(sidebar): убрать мигание ссылок «Подготовка к экзамену» при отключении
Ссылки exam-prep (/exam-prep/*) скрывались отдельным async-механизмом (/api/exam-prep/
tracks), не входящим в синхронный кэш-CSS → мелькали на долю секунды после обновления.
Теперь hideDisabledFeatures кэширует точные хрефы скрытых треков в localStorage
(ls_examhide), а _applyFeatureCss добавляет их в инъект-CSS синхронно из кэша на ранней
загрузке (до сборки сайдбара). При включении трека он убирается из кэша → снова виден
(re-apply _applyFeatureCss после свежего fetch). hideEmptySidebarGroups перенесён в конец
hideDisabledFeatures (учитывает скрытие exam-prep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:56:12 +03:00
Maxim Dolgolyov 38f8be9389 feat(features): тумблер «Путеводитель» (/sitemap)
Пункт «Путеводитель» теперь отключается как остальные модули: ключ sitemap в
вайтлист updateFeatures (backend) + тумблер в admin → фичи (GAME_FEATURES) +
/sitemap,/sitemap.html в FEATURE_HREFS (скрытие из сайдбара + редирект при выкл).
По умолчанию ВКЛ (opt-in disable). Пустая группа схлопнётся авто (hideEmptySidebarGroups).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:51:36 +03:00
Maxim Dolgolyov c04a8c2178 fix(sidebar): прятать пустые группы (заголовок без видимых пунктов)
Когда все пункты группы сайдбара скрыты (фичи отключены / teacher-only у ученика),
оставался висеть пустой заголовок-аккордеон (напр. «Практика и игры»). Добавлена
hideEmptySidebarGroups(): по .sb-group проверяет computed-display пунктов .sb-link
в теле и прячет группу, если ни одного видимого. Зовётся синхронно из sidebar.js
после сборки (по кэш-CSS — без мигания) и в конце hideDisabledFeatures (по свежим
данным; re-show при включении).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:49:51 +03:00
Maxim Dolgolyov 83f0ba9c04 fix(features): пустой блок флешкарт, лаба в сайдбаре, мигание (FOUC)
Три проблемы UX отключения модулей:
1) Пустой блок флешкарт на дашборде: виджет #w-flashcard — <div>, а скрытие шло
   только по [href]. Добавлена карта FEATURE_WIDGETS (флешкарты→#w-flashcard) —
   контейнер прячется целиком.
2) Лаборатория не уходила из сайдбара: не было ГЛОБАЛЬНОГО тумблера lab (только
   free-student). Добавлен в whitelist updateFeatures + GAME_FEATURES; map уже знал
   lab→/lab. Теперь выключение скрывает пункт и редиректит со страницы.
3) Мигание выключенных модулей (FOUC): hideDisabledFeatures асинхронный. Теперь
   loadFeatures кэширует /api/features в localStorage, а _applyFeatureCss инъектит
   <style id=ls-feat-hide> синхронно из кэша на ранней загрузке (api.js идёт до
   sidebar.js) — сайдбар/виджеты строятся уже скрытыми. Геймификация: класс
   no-gamification ставится на <html> (раньше body), ls.css правило body.no-gamification
   → .no-gamification (работает и для html, и для body).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:41:11 +03:00
Maxim Dolgolyov d5fbd0168e feat(permissions): +10 прав ролей с энфорсом (Доступ · роли)
Реестр (registry.js) пополнен правами, которыми раньше нельзя было управлять:
• Учитель: classroom.host (онлайн-уроки), livequiz.host (живые викторины),
  simbuilder.use (конструктор симуляций), flashcards.manage (общие колоды).
• Ученик: homework.submit (сдача ДЗ), materials.save («Мои материалы»),
  assistant.use (ИИ-ассистент), games.play (учебные игры),
  flashcards.access / exam.access (доступ к разделам).
Все default=1 → текущее поведение сохранено; админ может выключить по роли/классу/юзеру.

Энфорс на роутах: учительские — requirePermission (роуты уже teacher-only);
ученические на ОБЩИХ роутах (assistant/materials/games/flashcards/exam-prep) —
новый requirePermissionForStudents(key) (учитель/админ проходят всегда, проверка
только ученику — иначе isEnabled=false сломал бы учителя). PERM_DEFAULTS строится
из реестра → фолбэк до сидирования = enabled, никто не блокируется. Группы UI —
существующие (новых ярлыков нет). seedDefaults авто-сидит новые ключи на чтении.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:31:00 +03:00
Maxim Dolgolyov 54be84e74a fix(admin): глобальный мастер-тумблер «Геймификация» в админ-UI
Геймификация (gamification) была только в FS_FEATURES (free-student grid) — UI
позволял выключить её лишь для роли «свободный ученик», а ГЛОБАЛЬНОГО тумблера в
GAME_FEATURES не было, хотя бэкенд флаг feature_gamification_enabled и kill-switch
это поддерживают. Добавлен в GAME_FEATURES как «Геймификация (всё)» — теперь админ
может выключить XP/монеты/ачивки/магазин/стрики/лидерборд глобально из UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:08:59 +03:00
Maxim Dolgolyov dc71d7b4d9 fix(gamification): полнота kill-switch — испытания/стрик/монеты + гейт счётчиков
Аудит выключателя геймификации выявил элементы, НЕ покрытые body.no-gamification:
испытания недели (#ch-section/.ch-widget), календарь стриков (.streak-cal),
стат-кольцо стрика (#sr-streak), монеты в профиле (#p-coins-row), чипы стрик/цель
на карточке питомца. Добавлены в CSS kill-switch (ls.css). Бэкенд: updateChallenges
и onLabExperiment писали прогресс/счётчики без проверки флага — добавлен гейт
isGamificationEnabled() (XP/coins/achievements уже гейтились в award*-функциях).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:04:30 +03:00
Maxim Dolgolyov d8f2a7f98d fix(features): доска уходит из сайдбара при отключении + тумблер «Теория»
1) Доска при feature_board_enabled=0 не пропадала у учителя/админа: showBoardIfAllowed()
   зовётся напрямую на ~20 страницах и показывала доску БЕЗ проверки флага, перекрывая
   hideDisabledFeatures(). Теперь функция сперва грузит features и при board===false
   держит ссылку скрытой (для всех ролей).
2) Добавлен фич-флаг theory: ключ в вайтлист updateFeatures (backend), тумблер «Теория»
   в admin → games (GAME_FEATURES), и /theory,/theory.html в map hideDisabledFeatures
   (скрытие из сайдбара + редирект с /theory при выключении). По умолчанию ВКЛ (opt-in disable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:56:22 +03:00
Maxim Dolgolyov 9d35aaf673 fix(admin/access): нативные confirm() → стилизованная модалка LS.confirm
Раздел «Доступ · контент» использовал браузерный confirm() («Подтвердите
действие на localhost…») для закрытия доступа у всех классов и копирования.
Заменены все 5 вызовов на LS.confirm (та же модалка, что в остальном админ-
разделе): «Закрыть доступ» (danger) и «Скопировать доступ» (danger:false).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:48:24 +03:00
Maxim Dolgolyov bd7dd06e47 fix(exam-prep): репаратор рендеринга ctmath — потерянные \ в опциях + <,> в $…$
Root cause: в seed-ах вариантов 101–121 опции писались как mc('$\sqrt..$') в
обычных кавычках вместо R`…` → JS-парсер съедал \s→s, \d→d и т.п., в БД легло
«$sqrt{17}$», «$dfrac{pi}{3}$» (KaTeX рисует «sqrt17», «dfracpi3»). Плюс литеральные
<,> внутри $…$ ломали HTML до KaTeX. Скрипт fix_ctmath_render.js (dry-run/--apply,
идемпотентный): восстанавливает \ перед командами (+нормализует управляющие символы)
в opts_json и заменяет <,>→\lt,\gt внутри $…$ в text/opts/solution. DRY-RUN: 307
строк (143 opts/128 text/157 sol), остаточных багов 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:34:59 +03:00
Maxim Dolgolyov f381873c34 fix(exam-prep): список «Варианты» показывает метку (ЦТ-2015…), а не «Вариант N»
variants.js хардкодил «Вариант ${n}» в гриде-пикере, заголовке и подписи кнопки,
игнорируя поле label из listVariants (бэкенд его уже отдаёт через examVariantLabel).
Добавлен хелпер labelOf(n) → подставляет v.label с фолбэком. mock.js дропдаун уже
использовал label — там достаточно перезапуска сервера, чтобы бэкенд отдал метки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:20:45 +03:00
Maxim Dolgolyov dd69c026ec content(ctmath): вариант 121 — ЦТ-2011 (А1–А18 + В1–В12, 30 заданий)
Перенабор Вариант 1 из ЦТ 2011 В1-В10.pdf (тест полный, не только В), все 30
ответов сверены с официальной таблицей (полное совпадение). Уточнения по таблице:
A6 степень 3x+4 (→15·2^3x), A9=(3^-2)^-5→1/9, A7 корень -3, A8=10, A10=10π.
Фигурные A1/A2/B6 реконструированы (B6: y=x²-6x+9 ∩ y=1,25 → 4x₁x₂=31). Все В
числовые. Без авторских ссылок. Дедуп-гейт 0, KaTeX 30/30, DRY-RUN 30/30. Метка 121='ЦТ-2011'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:53:06 +03:00
Maxim Dolgolyov 84625cd72a content(ctmath): вариант 120 — ЦТ-2012 (А1–А18 + В1–В12, 30 заданий)
Перенабор Вариант 1 из ЦТ 2012.pdf, все 30 ответов сверены с официальной
таблицей (полное совпадение). Фигурные A1/A13/B6 реконструированы с явными
данными (A1 — углы 70/40→равнобедренный; B6 — середины сторон→S=4). A15
уточнена по таблице: √(5⁵·20)=250, знаменатель ⁴√10 → 25·⁴√10. Все В числовые.
Без авторских ссылок. Дедуп-гейт 0, KaTeX 30/30, DRY-RUN 30/30. Метка 120='ЦТ-2012'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:42:27 +03:00
Maxim Dolgolyov 0fb16ef85e content(ctmath): вариант 119 — ЦТ-2013 (А1–А18 + В1–В12, 30 заданий)
Перенабор Вариант 1 из ЦТ2013.pdf, все 30 ответов сверены с официальной
таблицей (полное совпадение). Фигурные A2/A3/A6/A16 реконструированы с явными
описаниями (A2 — образующая=AD, A6 — порядок лучей→40°, A16 — сечение 12×6=72).
Все В-задания числовые (long нет). Без авторских ссылок. Дедуп-гейт 0,
KaTeX 30/30, DRY-RUN 30/30. VARIANT_LABEL: 119='ЦТ-2013'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:35:28 +03:00
Maxim Dolgolyov b9a82c326e content(ctmath): вариант 118 — ЦТ-2017 (А1–А18 + В1–В12, 30 заданий)
Перенабор Вариант 1 из CT-2017.pdf, все 30 ответов сверены с официальной
таблицей (полное совпадение). Фигурные A1/A3/A9/A11/A14 реконструированы с
явными числами (A9 — биссектриса, AM/MC=AB/BC→13,8). Без авторских ссылок.
Прогнан через дедуп-гейт (0 совпадений с пулом) + KaTeX-структуру + DRY-RUN 30/30.
VARIANT_LABEL: 118='ЦТ-2017'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:28:06 +03:00
109 changed files with 12008 additions and 444 deletions
+146
View File
@@ -0,0 +1,146 @@
'use strict';
/* ─────────────────────────────────────────────────────────────────────────
Ремонт банка вопросов через ИИ (шлюз Kilo из настроек ассистента).
Режимы:
--broken починить single/multiple без отмеченного верного варианта
(ИИ выбирает верный среди СУЩЕСТВУЮЩИХ вариантов)
--topics привязать математические вопросы без темы к СУЩЕСТВУЮЩИМ темам
Поток: по умолчанию DRY-RUN — пишет предложения в JSON + сводку, БД не трогает.
С флагом --apply — применяет ранее сгенерированный JSON к БД.
--limit N ограничить число вопросов (для теста)
Пример:
node fix-question-bank.js --topics # сгенерировать предложения
node fix-question-bank.js --topics --apply # применить после вычитки
───────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const fs = require('fs');
const path = require('path');
const DB_PATH = path.resolve(__dirname, '../data/learnspace.db');
const argv = process.argv.slice(2);
const MODE = argv.includes('--broken') ? 'broken' : argv.includes('--topics') ? 'topics' : null;
const APPLY = argv.includes('--apply');
const LIMIT = (() => { const i = argv.indexOf('--limit'); return i >= 0 ? Number(argv[i + 1]) || 0 : 0; })();
if (!MODE) { console.error('Укажите режим: --broken или --topics (опц. --apply, --limit N)'); process.exit(1); }
const OUT = path.join(__dirname, `_qbank_proposals_${MODE}.json`);
const db = new DatabaseSync(DB_PATH);
try { db.exec('PRAGMA busy_timeout=8000'); } catch (e) {}
// node:sqlite (DatabaseSync) НЕ имеет .transaction() — оборачиваем вручную.
function runTx(fn) { db.exec('BEGIN'); try { fn(); db.exec('COMMIT'); } catch (e) { try { db.exec('ROLLBACK'); } catch (_) {} throw e; } }
/* ── провайдер Kilo ── */
function aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key=?').get(k); return r && r.value != null ? r.value : null; }
function pickProvider() {
let arr = []; try { arr = JSON.parse(aset('assistant_providers') || '[]'); } catch (e) {}
const active = arr.find(p => p.id === aset('assistant_active'));
return (active && active.key && active) || arr.find(p => p.key) || null;
}
const PROV = pickProvider();
if (!APPLY && !PROV) { console.error('Нет провайдера ИИ с ключом (настрой в /admin#assistant).'); process.exit(1); }
async function llm(messages, maxTokens) {
const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 40000);
try {
const r = await fetch(PROV.url, {
method: 'POST', signal: ctrl.signal,
headers: Object.assign({ 'Content-Type': 'application/json' }, PROV.key ? { Authorization: 'Bearer ' + PROV.key } : {}),
body: JSON.stringify({ model: PROV.model, temperature: 0.1, max_tokens: maxTokens || 1500, messages }),
});
if (!r.ok) return { err: 'HTTP ' + r.status };
const j = await r.json();
return { text: (j.choices && j.choices[0] && j.choices[0].message && (j.choices[0].message.content || j.choices[0].message.reasoning)) || '' };
} catch (e) { return { err: e.name === 'AbortError' ? 'timeout' : 'network' }; }
finally { clearTimeout(timer); }
}
function parseJson(raw) {
let s = String(raw || '').replace(/```(?:json)?/gi, '').trim();
const a = s.search(/[[{]/); if (a > 0) s = s.slice(a);
const lastArr = s.lastIndexOf(']'), lastObj = s.lastIndexOf('}');
const end = Math.max(lastArr, lastObj); if (end >= 0) s = s.slice(0, end + 1);
try { return JSON.parse(s); } catch (e) { return null; }
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
/* ═══ APPLY: применить сохранённые предложения ═══ */
function applyProposals() {
if (!fs.existsSync(OUT)) { console.error('Нет файла предложений ' + OUT + ' — сначала запусти без --apply.'); process.exit(1); }
const props = JSON.parse(fs.readFileSync(OUT, 'utf8'));
let n = 0;
if (MODE === 'broken') {
const upd = db.prepare('UPDATE options SET is_correct = 1 WHERE id = ? AND question_id = ?');
runTx(() => { for (const p of props) { if (p.optionId) { upd.run(p.optionId, p.id); n++; } } });
console.log(`Применено: отмечено верных вариантов — ${n}`);
} else {
const upd = db.prepare('UPDATE questions SET topic_id = ? WHERE id = ? AND topic_id IS NULL');
runTx(() => { for (const p of props) { if (p.topicId) { upd.run(p.topicId, p.id); n++; } } });
console.log(`Применено: привязано тем — ${n}`);
}
process.exit(0);
}
if (APPLY) applyProposals();
/* ═══ DRY-RUN: сгенерировать предложения ═══ */
(async () => {
if (MODE === 'broken') {
let qs = db.prepare(`
SELECT q.id, q.text FROM questions q
WHERE q.type IN ('single','multiple')
AND NOT EXISTS (SELECT 1 FROM options o WHERE o.question_id=q.id AND o.is_correct=1)
AND EXISTS (SELECT 1 FROM options o WHERE o.question_id=q.id)`).all();
if (LIMIT) qs = qs.slice(0, LIMIT);
console.log(`Битых MCQ к разбору: ${qs.length}`);
const props = [];
for (const q of qs) {
const opts = db.prepare('SELECT id, text FROM options WHERE question_id=? ORDER BY order_index').all(q.id);
const list = opts.map((o, i) => `${i + 1}. ${o.text}`).join('\n');
const sys = 'Ты эксперт-предметник. Определи ЕДИНСТВЕННЫЙ правильный вариант ответа. ' +
'Верни СТРОГО JSON {"correct": N} где N — номер варианта (1..K), либо {"correct": 0} если определить нельзя (например, нужен рисунок/график). Только JSON.';
const user = `Вопрос: ${q.text}\n\nВарианты:\n${list}`;
const r = await llm([{ role: 'system', content: sys }, { role: 'user', content: user }], 300);
const j = r.text ? parseJson(r.text) : null;
const n = j && Number(j.correct);
const opt = (n >= 1 && n <= opts.length) ? opts[n - 1] : null;
props.push({ id: q.id, optionId: opt ? opt.id : null, optionText: opt ? opt.text : null, q: q.text.slice(0, 70), err: r.err || null });
console.log(` #${q.id}: ${opt ? 'верный → «' + String(opt.text).slice(0, 40) + '»' : (r.err || 'не определено (ручная проверка)')}`);
await sleep(400);
}
fs.writeFileSync(OUT, JSON.stringify(props, null, 2));
console.log(`\nПредложения: ${OUT}\nВычитай, затем: node fix-question-bank.js --broken --apply`);
return;
}
// topics: математика, вопросы без темы → существующие темы
const subj = db.prepare("SELECT id FROM subjects WHERE name LIKE '%атематик%' OR slug='math'").get();
if (!subj) { console.error('Предмет «Математика» не найден'); process.exit(1); }
const topics = db.prepare('SELECT id, name FROM topics WHERE subject_id=? ORDER BY id').all(subj.id);
if (!topics.length) { console.error('У математики нет тем'); process.exit(1); }
let qs = db.prepare('SELECT id, text FROM questions WHERE subject_id=? AND topic_id IS NULL').all(subj.id);
if (LIMIT) qs = qs.slice(0, LIMIT);
console.log(`Тем: ${topics.length} | вопросов без темы: ${qs.length}`);
const topicList = topics.map((t, i) => `${i + 1}. ${t.name}`).join('\n');
const props = []; const BATCH = 12;
for (let i = 0; i < qs.length; i += BATCH) {
const chunk = qs.slice(i, i + BATCH);
const sys = 'Ты классифицируешь вопросы по математике по СУЩЕСТВУЮЩИМ темам из списка. ' +
'Для каждого вопроса верни номер наиболее подходящей темы или 0, если ни одна явно не подходит. ' +
'Верни СТРОГО JSON-массив [{"id":<id вопроса>,"t":<номер темы 1..K или 0>}]. Только JSON.';
const user = `Темы:\n${topicList}\n\nВопросы:\n` + chunk.map(q => `[id ${q.id}] ${String(q.text).replace(/\s+/g, ' ').slice(0, 240)}`).join('\n');
const r = await llm([{ role: 'system', content: sys }, { role: 'user', content: user }], 900);
const arr = r.text ? parseJson(r.text) : null;
const map = {}; if (Array.isArray(arr)) arr.forEach(x => { if (x && x.id != null) map[x.id] = Number(x.t); });
for (const q of chunk) {
const n = map[q.id]; const t = (n >= 1 && n <= topics.length) ? topics[n - 1] : null;
props.push({ id: q.id, topicId: t ? t.id : null, topicName: t ? t.name : null, q: String(q.text).replace(/\s+/g, ' ').slice(0, 70) });
}
const done = Math.min(i + BATCH, qs.length);
console.log(` ${done}/${qs.length}${r.err ? ' (ошибка батча: ' + r.err + ')' : ''}`);
await sleep(500);
}
const assigned = props.filter(p => p.topicId).length;
fs.writeFileSync(OUT, JSON.stringify(props, null, 2));
const byTopic = {}; props.forEach(p => { if (p.topicName) byTopic[p.topicName] = (byTopic[p.topicName] || 0) + 1; });
console.log(`\nРазмечено: ${assigned}/${props.length} (остальные — без уверенной темы, останутся как есть)`);
Object.entries(byTopic).sort((a, b) => b[1] - a[1]).slice(0, 12).forEach(([t, n]) => console.log(` ${t}: ${n}`));
console.log(`\nПредложения: ${OUT}\nВычитай, затем: node fix-question-bank.js --topics --apply`);
})();
+171
View File
@@ -0,0 +1,171 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
fix_ctmath_render.js — починка двух дефектов рендеринга в exam_tasks (ctmath).
ПРИЧИНА (root cause):
В seed-скриптах вариантов 101–121 опции писались как mc('$\sqrt{17}$', ...) —
в ОБЫЧНЫХ кавычках, а не в String.raw `…`. JS-парсер съедал управляющие
эскейпы: \s→s, \d→d (теряется «\»), а \f→0x0C, \t→0x09, \b→0x08, \v,\n,\r —
превращались в УПРАВЛЯЮЩИЕ символы. Итог в БД: «$sqrt{17}$», «$dfrac{pi}{3}$»,
KaTeX рендерит их как «sqrt17», «dfracpi3». (text/solution писались через R`…`
и НЕ пострадали — там «\» на месте.)
ВТОРОЙ ДЕФЕКТ: литеральные < и > ВНУТРИ $…$ (напр. «$-1{,}6<x<-1$»). При вставке
в innerHTML браузер парсит «<x…» как HTML-тег ДО запуска KaTeX → ломает карточку.
Лечится заменой < → \lt, > → \gt (только внутри $…$).
ЧТО ДЕЛАЕТ СКРИПТ (идемпотентно, повторный запуск безопасен):
• opts_json: (1) нормализует управляющие символы обратно в \f \t \b \v \n \r;
(2) восстанавливает «\» перед известными KaTeX-командами; (3) < > → \lt \gt.
• text_html, solution_html: только (3) < > → \lt \gt внутри $…$ (HTML-теги вне
математики не трогаются).
Восстановление «\» применяется ТОЛЬКО к opts_json (text/sol не повреждены).
Запуск:
node backend/scripts/fix_ctmath_render.js # DRY-RUN (показывает правки)
node backend/scripts/fix_ctmath_render.js --apply # запись в БД
⚠️ Запись запускает ПОЛЬЗОВАТЕЛЬ. После --apply — перезапуск сервера не нужен
(данные в БД; фронт перечитает их при следующем запросе), но hard-refresh браузера.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
/* ── Управляющие символы → их LaTeX-эскейп (обратная нормализация) ── */
const CTRL_MAP = { '\b': '\\b', '\t': '\\t', '\n': '\\n', '\v': '\\v', '\f': '\\f', '\r': '\\r' };
function normalizeCtrl(s) {
return s.replace(/[\b\t\n\v\f\r]/g, ch => CTRL_MAP[ch] || ch);
}
/* ── Команды, у которых первая буква НЕ из {f,t,b,v,n,r} (их «\» просто пропал,
без управляющего символа). Длинные/составные — РАНЬШЕ коротких префиксов,
чтобы не разорвать слово (dfrac до frac, arccos до cos, leq до le, …). ── */
const BARE_CMDS = [
'arccos', 'arcsin', 'arctg',
'overline', 'operatorname', 'varnothing', 'varphi', 'varepsilon',
'dfrac', 'cdots', 'cdot', 'sqrt', 'left',
'lambda', 'gamma', 'delta', 'sigma', 'omega', 'alpha', 'angle', 'approx',
'infty', 'ldots', 'oplus',
'cos', 'sin', 'cot', 'ctg', 'cup', 'cap', 'leq', 'geq', 'neq',
'sim', 'lim', 'log',
'pm', 'mp', 'le', 'ge', 'ln', 'lg', 'pi', 'mu', 'in',
'phi', 'psi', 'rho', 'chi', 'tau',
];
/* (frac, tfrac, times, theta, tan, tg, text, beta, vec, ne, nu, right, nabla —
приходят из управляющих символов и чинятся normalizeCtrl, поэтому в этом
списке их НЕТ: иначе «dfrac»→«d\frac».) */
function restoreBackslashes(math) {
let s = normalizeCtrl(math);
for (const cmd of BARE_CMDS) {
// «\bcmd», не уже-экранированное (нет «\» перед), как самостоятельное слово
const re = new RegExp('(^|[^\\\\A-Za-z])' + cmd + '(?![A-Za-z])', 'g');
s = s.replace(re, (m, pre) => pre + '\\' + cmd);
}
return s;
}
/* ── < > → \lt \gt ТОЛЬКО внутри $…$ / $$…$$ ── */
function fixAngles(field) {
if (!field) return field;
return String(field).replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$/g, seg =>
seg.replace(/</g, '\\lt ').replace(/>/g, '\\gt '));
}
/* ── Полная починка одной опции (внутри $…$): \ + < > ── */
function fixOptionText(t) {
if (!t) return t;
// обрабатываем содержимое каждого $…$: восстановить «\», затем < >
return String(t).replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$/g, seg => {
const open = seg.startsWith('$$') ? '$$' : '$';
const inner = seg.slice(open.length, seg.length - open.length);
let fixed = restoreBackslashes(inner);
fixed = fixed.replace(/</g, '\\lt ').replace(/>/g, '\\gt ');
return open + fixed + open;
});
}
/* ── Открытие БД ── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const rows = db.prepare(
`SELECT id, variant, task_idx, task_type, text_html, opts_json, solution_html
FROM exam_tasks WHERE exam_key=? ORDER BY variant, task_idx`).all(EXAM);
let changedRows = 0, changedOpts = 0, changedText = 0, changedSol = 0;
const samples = [];
const upd = db.prepare(
`UPDATE exam_tasks SET text_html=?, opts_json=?, solution_html=? WHERE id=?`);
if (APPLY) db.exec('BEGIN');
try {
for (const r of rows) {
let newOpts = r.opts_json;
let newText = r.text_html;
let newSol = r.solution_html;
let touched = false;
// opts_json — восстановление «\» + < >
if (r.opts_json) {
try {
const arr = JSON.parse(r.opts_json);
const fixed = arr.map(([l, t]) => [l, fixOptionText(t)]);
const cand = JSON.stringify(fixed);
if (cand !== r.opts_json) { newOpts = cand; changedOpts++; touched = true; }
} catch { /* не-JSON — пропускаем */ }
}
// text_html / solution_html — только < >
const ft = fixAngles(r.text_html);
if (ft !== r.text_html) { newText = ft; changedText++; touched = true; }
const fs = fixAngles(r.solution_html);
if (fs !== r.solution_html) { newSol = fs; changedSol++; touched = true; }
if (touched) {
changedRows++;
if (samples.length < 12) {
samples.push({ v: r.variant, i: r.task_idx,
beforeOpts: r.opts_json && r.opts_json.length > 90 ? r.opts_json.slice(0, 90) + '…' : r.opts_json,
afterOpts: newOpts && newOpts.length > 90 ? newOpts.slice(0, 90) + '…' : newOpts });
}
if (APPLY) upd.run(newText, newOpts, newSol, r.id);
}
}
if (APPLY) db.exec('COMMIT');
} catch (e) {
if (APPLY) db.exec('ROLLBACK');
console.error('✗ Ошибка, откат:', e.message);
db.close();
process.exit(1);
}
console.log(`\n=== fix_ctmath_render (${APPLY ? 'APPLY' : 'DRY-RUN'}) ===`);
console.log(`Всего задач ctmath: ${rows.length}`);
console.log(`Будет изменено строк: ${changedRows} (opts: ${changedOpts}, text: ${changedText}, sol: ${changedSol})`);
console.log(`\nПримеры (opts до → после):`);
for (const s of samples) {
console.log(`\n v${s.v}#${s.i}`);
console.log(` ДО: ${s.beforeOpts}`);
console.log(` ПОСЛЕ: ${s.afterOpts}`);
}
/* контроль остаточных «голых» команд после починки (для self-check в dry-run) */
if (!APPLY) {
const after = db.prepare(`SELECT opts_json FROM exam_tasks WHERE exam_key=? AND opts_json IS NOT NULL`).all(EXAM);
let leftover = 0;
for (const r of after) {
let arr; try { arr = JSON.parse(r.opts_json); } catch { continue; }
for (const [, t] of arr) {
const fixedNow = fixOptionText(t);
// ищем подозрительные «\bdfrac/sqrt/frac…» БЕЗ слэша уже ПОСЛЕ починки
if (/(^|[^\\A-Za-z])(sqrt|dfrac|frac|tfrac|cdot|times|alpha|beta|theta|pi)(?![A-Za-z])/.test(fixedNow.replace(/\$/g,''))) leftover++;
}
}
console.log(`\nКонтроль: потенциально не починенных опций после прогона: ${leftover}`);
console.log(`\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/fix_ctmath_render.js --apply\n`);
}
db.close();
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
reset-system.js — CLI «ЧИСТЫЙ ЗАПУСК» (тонкая обёртка над src/services/systemReset.js).
⚠️ ДЕСТРУКТИВНО. По умолчанию DRY-RUN. Выполнение — только с --apply --confirm=RESET.
Перед сбросом сделайте бэкап (control-panel «Бэкап БД» делает автоматически).
Та же логика доступна в админ-веб-панели (POST /api/admin/reset-system).
Запуск:
node backend/scripts/reset-system.js # план
node backend/scripts/reset-system.js --apply --confirm=RESET # выполнить
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const reset = require('../src/services/systemReset');
const APPLY = process.argv.includes('--apply');
const CONFIRM = process.argv.includes('--confirm=RESET');
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const keptAdmin = reset.pickKeptAdmin(db);
if (!keptAdmin) {
console.error('✗ В системе нет ни одного админа — сброс отменён (иначе залочитесь). Создайте админа сначала.');
db.close(); process.exit(1);
}
const plan = reset.classify(db);
console.log(`\n=== reset-system «ЧИСТЫЙ ЗАПУСК» (${APPLY ? (CONFIRM ? 'APPLY' : 'нужен --confirm=RESET') : 'DRY-RUN'}) ===`);
console.log(`Сохраняемый админ: id=${keptAdmin.id} ${keptAdmin.email} «${keptAdmin.name}»`);
console.log(`Пользователей: ${plan.totalUsers} → останется 1, удалится ${plan.totalUsers - 1}\n`);
console.log('REASSIGN (контент → админу):');
plan.reassign.forEach(r => console.log(` ${r.table.padEnd(22)} ${r.col.padEnd(12)} строк: ${r.rows}`));
console.log('\nWIPE (полная очистка):');
plan.wipe.forEach(w => console.log(` ${w.table.padEnd(28)} строк: ${w.rows}`));
console.log(` — всего к удалению (без каскада users): ~${plan.wipeRows}`);
console.log(`\nKEEP (контент/конфиг): ${plan.keepCount} таблиц.`);
if (plan.unknown.length) console.log(`\n⚠️ НЕИЗВЕСТНЫЕ таблицы (НЕ трогаем): ${plan.unknown.join(', ')}`);
if (!APPLY) {
console.log('\nDRY-RUN: ничего не изменено. Выполнить: node backend/scripts/reset-system.js --apply --confirm=RESET\n');
db.close(); process.exit(0);
}
if (!CONFIRM) {
console.error('\n✗ Нужен флаг --confirm=RESET (защита от случайного запуска). Отмена.');
db.close(); process.exit(1);
}
try {
const res = reset.runReset(db, keptAdmin.id);
console.log(`\n✓ ЧИСТЫЙ ЗАПУСК выполнен. Удалено пользователей: ${res.deletedUsers}, осталось: ${res.remainingUsers}.`);
console.log(`✓ Контент сохранён: учебники ${res.kept.textbooks}, вопросы ${res.kept.questions}, тесты ${res.kept.tests}, курсы ${res.kept.courses}, exam-prep ${res.kept.exam_tasks}.`);
if (res.fkDangling) console.log(`⚠️ foreign_key_check: ${res.fkDangling} висячих ссылок — проверьте.`);
console.log(`\nВойдите под ${keptAdmin.email}. Перезапустите сервер.\n`);
} catch (e) {
console.error('\n✗ Ошибка — откат, изменений нет:', e.message);
db.close(); process.exit(1);
}
db.close();
+352
View File
@@ -0,0 +1,352 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_ct2011_v1.js
Чистый вариант-пробник для трека exam-prep `ctmath`.
Источник: Централизованное тестирование (ЦТ) по математике, 2011, Вариант 1.
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2011\ЦТ 2011 В1-В10.pdf
(несмотря на имя «В1-В10», тест полный: А1–А18 + В1–В12; ответы — «Ответы 2011.pdf», столбец 1).
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
совпадение, включая B2=150, B8=16, B10=10, B12=26. variant=121. Прогнан через
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
Уточнения по таблице (скан неоднозначен по степеням/индексам):
• А6: степень $3x+4$ → $2^{3x+4}-2^{3x}=15\cdot2^{3x}$;
А9: $3^{-12}\cdot(3^{-2})^{-5}=3^{-2}=\tfrac19$;
• А7: корень уравнения с радикалом = $-3$ (корень линейного множителя вне ОДЗ отброшен);
• А10: осевое сечение $=10$ → боковая $=10\pi$.
Реконструкции «с-картинкой» (смысл/ответ сохранены, авто-проверка):
• А1 (tg не определена) → точки в тексте, ровно одна вида $\tfrac{\pi}{2}+\pi k$ ($-\tfrac{5\pi}{2}$);
• А2 (параллелограмм на сетке) → основание/высота числами ($5\times4=20$);
• B6 (парабола+прямая) → парабола $y=x^2-6x+9$ и прямая $y=1{,}25$ заданы явно ($4x_1x_2=31$).
Без авторских ссылок (политика «все учебники наши»).
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
Запуск:
node backend/scripts/seed_ctmath_ct2011_v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_ct2011_v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 121;
const N_TASKS = 30;
const PROV = 'ЦТ–2011, Вариант 1';
const R = String.raw;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'trigonometry', subtopic: 'trig-circle', diff: 1,
text: R`Функция $y=\operatorname{tg}x$ не определена в точке:`,
opts: mc('$2\pi$', '$-\dfrac{5\pi}{2}$', '$\dfrac{2\pi}{5}$', '$\dfrac{\pi}{4}$', '$-3\pi$'),
answer: 'б',
sol: R`$\operatorname{tg}x$ не определён при $x=\dfrac{\pi}{2}+\pi k$. Из перечисленных таково $-\dfrac{5\pi}{2}=\dfrac{\pi}{2}-3\pi$.` },
{ idx: 2, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 1,
text: R`Параллелограмм изображён на клетчатой бумаге с клетками $1\times1$ см: его основание равно $5$ см, а высота, проведённая к этому основанию, равна $4$ см. Найдите площадь параллелограмма (в квадратных сантиметрах).`,
opts: mc('$10$', '$25$', '$15$', '$20$', '$18$'),
answer: 'г',
sol: R`Площадь параллелограмма $=$ основание $\times$ высоту $=5\cdot4=20$ см².` },
{ idx: 3, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Если $7\tfrac29:x=4\tfrac13:3\tfrac35$ — верная пропорция, то число $x$ равно:`,
opts: mc('$\dfrac23$', '$6$', '$\dfrac54$', '$\dfrac49$', '$1{,}5$'),
answer: 'б',
sol: R`$x=\dfrac{7\tfrac29\cdot3\tfrac35}{4\tfrac13}=\dfrac{\tfrac{65}{9}\cdot\tfrac{18}{5}}{\tfrac{13}{3}}=\dfrac{26}{\tfrac{13}{3}}=6$.` },
{ idx: 4, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 1,
text: R`Если $15\%$ некоторого числа равны $33$, то $20\%$ этого числа равны:`,
opts: mc('$44$', '$46$', '$55$', '$56$', '$66$'),
answer: 'а',
sol: R`Число $=\dfrac{33}{0{,}15}=220$, тогда $20\%=0{,}2\cdot220=44$.` },
{ idx: 5, type: 'mc', topic: 'equations', subtopic: 'eq-linear', diff: 1,
text: R`Если $9x-24=0$, то $18x-31$ равно:`,
opts: mc('$13$', '$-17$', '$17$', '$21$', '$-19$'),
answer: 'в',
sol: R`$x=\dfrac{24}{9}=\dfrac83$, поэтому $18x=48$ и $18x-31=17$.` },
{ idx: 6, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Для любого числа $x$ выражение $2^{3x+4}-2^{3x}$ равно:`,
opts: mc('$15\cdot2^{3x}$', '$16$', '$2^{6x+1}$', '$\dfrac23\cdot2^{3x}$', '$2^{3x}$'),
answer: 'а',
sol: R`$2^{3x+4}-2^{3x}=2^{3x}\left(2^{4}-1\right)=15\cdot2^{3x}$.` },
{ idx: 7, type: 'mc', topic: 'equations', subtopic: 'eq-irrational', diff: 2,
text: R`Сумма корней (корень, если он один) уравнения $(x+5)\sqrt{x+3}=0$ равна:`,
opts: mc('$-1$', '$3$', '$1$', '$-3$', '$-2$'),
answer: 'г',
sol: R`ОДЗ: $x\ge-3$. Корень $x=-5$ не входит в ОДЗ, остаётся $x=-3$ (из $\sqrt{x+3}=0$). Единственный корень $-3$.` },
{ idx: 8, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
text: R`От листа жести, имеющего форму квадрата, отрезали прямоугольную полосу шириной $7$ дм, после чего площадь оставшейся части листа оказалась равной $30$ дм². Длина стороны квадратного листа (в дециметрах) была равна:`,
opts: mc('$11$', '$12$', '$3$', '$9$', '$10$'),
answer: 'д',
sol: R`Если сторона квадрата $a$, то $a(a-7)=30$, $a^{2}-7a-30=0$, $a=10$ (второй корень $-3$ отброшен).` },
{ idx: 9, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Значение выражения $3^{-12}\cdot\left(3^{-2}\right)^{-5}$ равно:`,
opts: mc('$81$', '$3^{-22}$', '$9$', '$3^{-12}$', '$\dfrac19$'),
answer: 'д',
sol: R`$\left(3^{-2}\right)^{-5}=3^{10}$, поэтому $3^{-12}\cdot3^{10}=3^{-2}=\dfrac19$.` },
{ idx: 10, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 2,
text: R`Площадь осевого сечения цилиндра равна $10$. Площадь его боковой поверхности равна:`,
opts: mc('$5\pi$', '$10\pi$', '$20\pi$', '$100\pi$', '$10$'),
answer: 'б',
sol: R`Осевое сечение — прямоугольник $2r\times h$ площадью $2rh=10$. Боковая поверхность $=2\pi rh=\pi\cdot2rh=10\pi$.` },
{ idx: 11, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Найдите значение выражения $230\cdot\dfrac29-\left(\dfrac29+\dfrac1{10}\right):\dfrac1{230}$.`,
opts: mc('$0{,}1$', '$43\tfrac49$', '$-0{,}1$', '$-23$', '$23$'),
answer: 'г',
sol: R`$230\cdot\dfrac29=\dfrac{460}{9}$; $\left(\dfrac{29}{90}\right)\cdot230=\dfrac{667}{9}$. Разность $\dfrac{460-667}{9}=-\dfrac{207}{9}=-23$.` },
{ idx: 12, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
text: R`Упростите выражение $\dfrac{x^{2}-22x+121}{x^{2}-11x}:\dfrac{x^{2}-121}{x^{3}}$.`,
opts: mc('$\dfrac{x}{x+11}$', '$\dfrac{(x-11)^{2}}{x^{4}}$', '$\dfrac{x-11}{x+11}$', '$\dfrac{x^{2}}{x-11}$', '$\dfrac{x^{2}}{x+11}$'),
answer: 'д',
sol: R`$\dfrac{(x-11)^{2}}{x(x-11)}\cdot\dfrac{x^{3}}{(x-11)(x+11)}=\dfrac{x^{2}}{x+11}$.` },
{ idx: 13, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
text: R`Параллельно стороне треугольника, равной $5$, проведена прямая. Длина отрезка этой прямой, заключённого между сторонами треугольника, равна $2$. Найдите отношение площади полученной трапеции к площади исходного треугольника.`,
opts: mc('$\dfrac25$', '$0{,}6$', '$\dfrac{21}{25}$', '$\dfrac{4}{25}$', '$\dfrac{3}{25}$'),
answer: 'в',
sol: R`Отсечённый треугольник подобен исходному с коэффициентом $\dfrac25$, его площадь составляет $\dfrac{4}{25}$. Трапеция: $1-\dfrac{4}{25}=\dfrac{21}{25}$.` },
{ idx: 14, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Сумма координат точки пересечения прямых, заданных уравнениями $2x+5y=11$ и $x+y=2(5-y)$, равна:`,
opts: mc('$8$', '$-8$', '$10$', '$-10$', '$6$'),
answer: 'б',
sol: R`Второе уравнение: $x+3y=10$. Из системы $y=9$, $x=-17$. Сумма координат $-17+9=-8$.` },
{ idx: 15, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 3,
text: R`Количество целых решений неравенства $\dfrac{(x+3)^{2}-6x-18}{(x-5)^{2}}>0$ на промежутке $[-4;5]$ равно:`,
opts: mc('$2$', '$7$', '$4$', '$5$', '$3$'),
answer: 'а',
sol: R`Числитель $(x+3)^{2}-6x-18=x^{2}-9$. При $x\ne5$ знаменатель положителен, поэтому неравенство равносильно $x^{2}-9>0$, то есть $x<-3$ или $x>3$. На $[-4;5]$ это $x=-4$ и $x=4$$2$ решения.` },
{ idx: 16, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 3,
text: R`В ромб площадью $18\sqrt5$ вписан круг площадью $5\pi$. Сторона ромба равна:`,
opts: mc('$8$', '$18$', '$\dfrac{9\sqrt5}{5}$', '$\dfrac{18\sqrt5}{5}$', '$9$'),
answer: 'д',
sol: R`Радиус вписанного круга: $\pi r^{2}=5\pi$, $r=\sqrt5$; высота ромба $h=2r=2\sqrt5$. Площадь $=a\cdot h$: $18\sqrt5=a\cdot2\sqrt5$, $a=9$.` },
{ idx: 17, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 2,
text: R`Расположите числа $\sqrt[12]{80}$; $\sqrt[3]{3}$; $\sqrt[4]{4}$ в порядке возрастания.`,
opts: mc('$\sqrt[4]{4};\ \sqrt[3]{3};\ \sqrt[12]{80}$', '$\sqrt[3]{3};\ \sqrt[4]{4};\ \sqrt[12]{80}$', '$\sqrt[3]{3};\ \sqrt[12]{80};\ \sqrt[4]{4}$', '$\sqrt[4]{4};\ \sqrt[12]{80};\ \sqrt[3]{3}$', '$\sqrt[12]{80};\ \sqrt[3]{3};\ \sqrt[4]{4}$'),
answer: 'г',
sol: R`Возведём в $12$-ю степень: $\left(\sqrt[12]{80}\right)^{12}=80$, $\left(\sqrt[3]{3}\right)^{12}=81$, $\left(\sqrt[4]{4}\right)^{12}=64$. Так как $64<80<81$, порядок: $\sqrt[4]{4};\ \sqrt[12]{80};\ \sqrt[3]{3}$.` },
{ idx: 18, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 3,
text: R`Найдите наименьший положительный корень уравнения $4\sin^{2}x+12\cos x-9=0$.`,
opts: mc('$\dfrac{2\pi}{3}$', '$\arccos\dfrac52$', '$\dfrac{\pi}{3}$', '$\dfrac{\pi}{6}$', '$\pi-\arccos\dfrac52$'),
answer: 'в',
sol: R`$4(1-\cos^{2}x)+12\cos x-9=0$, то есть $4\cos^{2}x-12\cos x+5=0$, $\cos x=\dfrac12$ (второй корень $\dfrac52$ невозможен). Наименьший положительный корень $\dfrac{\pi}{3}$.` },
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
{ idx: 19, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 3,
text: R`Найдите произведение корней уравнения $\dfrac{3}{x+1}+1=\dfrac{10}{x^{2}+2x+1}$.`,
answer: '-6',
sol: R`Пусть $u=x+1$: $\dfrac3u+1=\dfrac{10}{u^{2}}$, $u^{2}+3u-10=0$, $u=2$ или $u=-5$. Тогда $x=1$ или $x=-6$, произведение $-6$.` },
{ idx: 20, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 4,
text: R`Диагонали трапеции равны $15$ и $20$. Найдите площадь трапеции, если её средняя линия равна $12{,}5$.`,
answer: '150',
sol: R`Площадь трапеции равна площади треугольника со сторонами, равными диагоналям ($15$ и $20$), и основанием, равным сумме оснований $=2\cdot12{,}5=25$. Так как $15^{2}+20^{2}=25^{2}$, треугольник прямоугольный: площадь $=\tfrac12\cdot15\cdot20=150$.` },
{ idx: 21, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
text: R`Найдите сумму корней (или корень, если он один) уравнения $2\cdot6^{\log_7 x}=108-x^{\log_7 6}$.`,
answer: '49',
sol: R`Так как $x^{\log_7 6}=6^{\log_7 x}$, обозначим $t=6^{\log_7 x}$: $2t=108-t$, $t=36=6^{2}$. Тогда $\log_7 x=2$, $x=49$ — единственный корень.` },
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Найдите сумму целых решений неравенства $2^{3x+4}-10\cdot4^{x}+2^{x}\le0$.`,
answer: '-6',
sol: R`Пусть $u=2^{x}>0$: $16u^{3}-10u^{2}+u\le0$, $u(16u^{2}-10u+1)\le0$, $\dfrac18\le u\le\dfrac12$. Значит $-3\le x\le-1$; сумма целых $-3-2-1=-6$.` },
{ idx: 23, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 4,
text: R`По двум перпендикулярным прямым, которые пересекаются в точке $O$, движутся две точки $M_1$ и $M_2$ по направлению к точке $O$ со скоростями $1$ м/с и $2$ м/с соответственно. Достигнув точки $O$, они продолжают своё движение. В первоначальный момент времени $M_1O=5$ м, $M_2O=20$ м. Через сколько секунд расстояние между точками $M_1$ и $M_2$ будет минимальным?`,
answer: '9',
sol: R`Расстояние: $d^{2}=(5-t)^{2}+(20-2t)^{2}=5t^{2}-90t+425$. Минимум при $t=\dfrac{90}{10}=9$ с.` },
{ idx: 24, type: 'open', topic: 'functions', subtopic: 'fn-graphs', diff: 4,
text: R`Парабола $y=x^{2}-6x+9$ и горизонтальная прямая $y=1{,}25$ пересекаются в точках с абсциссами $x_1$ и $x_2$. Найдите значение выражения $4x_1\cdot x_2$.`,
answer: '31',
sol: R`$x^{2}-6x+9=1{,}25$, то есть $x^{2}-6x+7{,}75=0$. По теореме Виета $x_1 x_2=7{,}75$, поэтому $4x_1 x_2=31$.` },
{ idx: 25, type: 'open', topic: 'planimetry', subtopic: 'plan-circle', diff: 4,
text: R`Четырёхугольник $ABCD$ вписан в окружность. Если $\angle BAC=40^\circ$ и $\angle ABD=75^\circ$, то градусная мера угла между прямыми $AB$ и $CD$ равна … .`,
answer: '35',
sol: R`$\angle BAC=40^\circ$ опирается на дугу $BC=80^\circ$, $\angle ABD=75^\circ$ — на дугу $AD=150^\circ$. Угол между прямыми $AB$ и $CD$ равен полуразности дуг: $\dfrac{150^\circ-80^\circ}{2}=35^\circ$.` },
{ idx: 26, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
text: R`Найдите значение выражения $\dfrac{\sin^{2}184^\circ}{4\sin^{2}23^\circ\cdot\sin^{2}2^\circ\cdot\sin^{2}44^\circ\cdot\sin^{2}67^\circ}$.`,
answer: '16',
sol: R`$\sin67^\circ=\cos23^\circ$ и $\sin46^\circ=\cos44^\circ$ дают знаменатель $=\dfrac1{16}\sin^{2}4^\circ$. Числитель $\sin^{2}184^\circ=\sin^{2}4^\circ$. Отношение $=16$.` },
{ idx: 27, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
text: R`В арифметической прогрессии $130$ членов, их сумма равна $130$, а сумма членов с чётными номерами на $130$ больше суммы членов с нечётными номерами. Найдите сотый член этой прогрессии.`,
answer: '70',
sol: R`Сумма чётных членов равна $130$, нечётных — $0$. Разность сумм $=65d=130$, поэтому $d=2$. Из общей суммы $a_1=-128$, тогда $a_{100}=a_1+99d=-128+198=70$.` },
{ idx: 28, type: 'open', topic: 'stereometry', subtopic: 'ster-angles-distances', diff: 5,
text: R`В равнобокой трапеции бóльшее основание вдвое больше каждой из остальных сторон и лежит в плоскости $\alpha$. Боковая сторона образует с плоскостью $\alpha$ угол, синус которого равен $\dfrac{5\sqrt3}{18}$. Найдите $36\sin\beta$, где $\beta$ — угол между диагональю трапеции и плоскостью $\alpha$.`,
answer: '10',
sol: R`Пусть боковая сторона $=b$, тогда основания $b$ и $2b$, высота трапеции $\dfrac{b\sqrt3}{2}$. Из условия $\dfrac{\sqrt3}{2}\sin\theta=\dfrac{5\sqrt3}{18}$ получаем $\sin\theta=\dfrac59$. Длина диагонали $=b\sqrt3$, и $\sin\beta=\dfrac{\sin\theta}{2}=\dfrac{5}{18}$. Значит $36\sin\beta=10$.` },
{ idx: 29, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 5,
text: R`Количество целых решений неравенства $2^{x+6}+\log_{0{,}5}(6-x)>13$ равно … .`,
answer: '7',
sol: R`ОДЗ: $x<6$. При $x=-2$ левая часть равна ровно $13$ (не годится), при $x\le-3$ меньше $13$, а при $-1\le x\le5$ — больше $13$. Целые решения: $-1,0,1,2,3,4,5$ — всего $7$.` },
{ idx: 30, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
text: R`Основанием пирамиды $SABCD$ является ромб со стороной $2\sqrt3$ и углом $BAD$, равным $\arccos\dfrac34$. Ребро $SD$ перпендикулярно основанию, а ребро $SB$ образует с основанием угол $60^\circ$. Найдите радиус $R$ сферы, проходящей через точки $A$, $B$, $C$ и середину ребра $SB$. В ответ запишите $R^{2}$.`,
answer: '26',
sol: R`Диагональ $BD=\sqrt{2\cdot12\left(1-\tfrac34\right)}=\sqrt6$, $SD=BD\cdot\operatorname{tg}60^\circ=3\sqrt2$. В координатах с центром ромба: $A(0;\tfrac{\sqrt{42}}2;0)$, $B(\tfrac{\sqrt6}2;0;0)$, $C(0;-\tfrac{\sqrt{42}}2;0)$, середина $SB$ $=(0;0;\tfrac{3\sqrt2}2)$. Центр сферы $\left(-\tfrac{3\sqrt6}2;0;-\sqrt2\right)$, $R^{2}=\tfrac{54}{4}+\tfrac{42}{4}+2=26$.` },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`;
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_ct2011_v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '\n');
console.log('idx | type | subtopic | d | answer');
console.log('----+------+-----------------------+---+----------');
for (const t of TASKS) {
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2011_v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2011».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+348
View File
@@ -0,0 +1,348 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_ct2012_v1.js
Чистый вариант-пробник для трека exam-prep `ctmath`.
Источник: Централизованное тестирование (ЦТ) по математике, 2012, Вариант 1.
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2012\ЦТ 2012.pdf
(ответы — отдельный файл «Ответы 2012.pdf», столбец «Вариант 1»).
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
совпадение, включая B7=9, B10=84, B11=90, B12=-180. variant=120. Прогнан через
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
Реконструкции заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
• А1 (равнобедренный треугольник) → пары углов даны числами (70°,40° → равнобедренный, №3);
• А13 (прямая/плоскость/двугранный угол) → все данные в тексте (площадь 14√3);
• B6 (середины сторон прямоугольника) → расположение M,N,P,Q задано в тексте (площадь 4).
А15 уточнена по таблице: радикал $\sqrt{5^{5}\cdot20}=250$, знаменатель $\sqrt[4]{10}$ → $25\sqrt[4]{10}$.
Без авторских ссылок (политика «все учебники наши»).
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
Запуск:
node backend/scripts/seed_ctmath_ct2012_v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_ct2012_v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 120;
const N_TASKS = 30;
const PROV = 'ЦТ–2012, Вариант 1';
const R = String.raw;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 1,
text: R`У каждого из пяти треугольников на рисунке известны два угла. Укажите номер треугольника, который является равнобедренным: $1)\ 55^\circ$ и $40^\circ$; $\ 2)\ 60^\circ$ и $40^\circ$; $\ 3)\ 70^\circ$ и $40^\circ$; $\ 4)\ 65^\circ$ и $40^\circ$; $\ 5)\ 75^\circ$ и $40^\circ$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`Третий угол равен $180^\circ$ минус два данных. Для пары $70^\circ$ и $40^\circ$ третий угол $=70^\circ$, появляются два равных угла — треугольник равнобедренный (№3).` },
{ idx: 2, type: 'mc', topic: 'expressions', subtopic: 'expr-logarithms', diff: 2,
text: R`Укажите верное равенство:<br>$1)\ 3^{\log_3 3}=5$; $\ 2)\ \log_7 7=7$; $\ 3)\ \log_{31}\dfrac{1}{31}=-1$; $\ 4)\ \log_5 25=5$; $\ 5)\ \log_{23} 23=0$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`$\log_{31}\dfrac{1}{31}=\log_{31}31^{-1}=-1$ — верно (равенство 3). Остальные ложны: $3^{\log_3 3}=3$, $\log_7 7=1$, $\log_5 25=2$, $\log_{23}23=1$.` },
{ idx: 3, type: 'mc', topic: 'numbers', subtopic: 'num-divisibility', diff: 1,
text: R`Сумма всех натуральных делителей числа $28$ равна:`,
opts: mc('$55$', '$11$', '$9$', '$27$', '$56$'),
answer: 'д',
sol: R`Делители $28$: $1,2,4,7,14,28$. Их сумма $=56$.` },
{ idx: 4, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
text: R`Даны квадратные уравнения: $1)\ 4x^{2}-3x-3=0$; $\ 2)\ 5x^{2}+20x+20=0$; $\ 3)\ 2x^{2}+3x+12=0$; $\ 4)\ 7x^{2}-4x-5=0$; $\ 5)\ 4x^{2}+8x+4=0$. Укажите уравнение, которое не имеет корней.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`Корней нет при $D<0$. Для $2x^{2}+3x+12=0$: $D=9-96=-87<0$ (№3). У остальных $D\ge0$.` },
{ idx: 5, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
text: R`Если $10^{2}\cdot\alpha=741{,}63287$, то значение $\alpha$ с точностью до сотых равно:`,
opts: mc('$74{,}16$', '$7{,}42$', '$7{,}41$', '$74\,163{,}29$', '$7416{,}33$'),
answer: 'б',
sol: R`$\alpha=\dfrac{741{,}63287}{100}=7{,}4163287\approx7{,}42$.` },
{ idx: 6, type: 'mc', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 2,
text: R`Число $133$ является членом арифметической прогрессии $4,\ 7,\ 10,\ 13,\ \ldots$ Укажите его номер.`,
opts: mc('$44$', '$42$', '$40$', '$46$', '$48$'),
answer: 'а',
sol: R`$a_n=4+3(n-1)=3n+1$. Из $3n+1=133$ получаем $n=44$.` },
{ idx: 7, type: 'mc', topic: 'equations', subtopic: 'eq-modulus', diff: 2,
text: R`Решите неравенство $|-x|\ge5$.`,
opts: mc('$x\in[5;+\infty)$', '$x\in(-\infty;-5]$', '$x\in[-5;5]$', '$x\in(-\infty;-5]\cup[5;+\infty)$', '$x_1=-5,\ x_2=5$'),
answer: 'г',
sol: R`$|-x|=|x|\ge5$ равносильно $x\le-5$ или $x\ge5$, то есть $x\in(-\infty;-5]\cup[5;+\infty)$.` },
{ idx: 8, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Вычислите $\dfrac{3{,}2+0{,}8:\left(\tfrac16+\tfrac13\right)}{0{,}1}$.`,
opts: mc('$48$', '$0{,}48$', '$4{,}8$', '$80$', '$0{,}8$'),
answer: 'а',
sol: R`$\tfrac16+\tfrac13=\tfrac12$, $0{,}8:\tfrac12=1{,}6$, числитель $=3{,}2+1{,}6=4{,}8$. Делим на $0{,}1$: $48$.` },
{ idx: 9, type: 'mc', topic: 'planimetry', subtopic: 'plan-circles', diff: 1,
text: R`Площадь круга равна $81\pi$. Диаметр этого круга равен:`,
opts: mc('$18$', '$18\pi$', '$9$', '$9\pi$', '$81$'),
answer: 'а',
sol: R`$\pi r^{2}=81\pi$, $r=9$, диаметр $=18$.` },
{ idx: 10, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 2,
text: R`Найдите наименьший положительный корень уравнения $\sin2x=\dfrac12$.`,
opts: mc('$\dfrac{\pi}{6}$', '$\dfrac{\pi}{12}$', '$\dfrac{\pi}{3}$', '$\dfrac{5\pi}{12}$', '$\dfrac{\pi}{8}$'),
answer: 'б',
sol: R`$2x=\dfrac{\pi}{6}+2\pi k$ или $2x=\dfrac{5\pi}{6}+2\pi k$, поэтому $x=\dfrac{\pi}{12}+\pi k$ или $x=\dfrac{5\pi}{12}+\pi k$. Наименьший положительный — $\dfrac{\pi}{12}$.` },
{ idx: 11, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
text: R`Четырёхугольник $MNPK$, в котором $\angle N=128^\circ$, вписан в окружность. Найдите градусную меру угла $K$.`,
opts: mc('$64^\circ$', '$128^\circ$', '$100^\circ$', '$180^\circ$', '$52^\circ$'),
answer: 'д',
sol: R`У вписанного четырёхугольника суммы противоположных углов равны $180^\circ$. Углы $N$ и $K$ противоположны, поэтому $\angle K=180^\circ-128^\circ=52^\circ$.` },
{ idx: 12, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
text: R`На одной чаше уравновешенных весов лежат $3$ яблока и $1$ груша, на другой — $2$ яблока, $2$ груши и гирька весом $20$ г. Каков вес одного яблока (в граммах), если все фрукты вместе весят $780$ г? Считайте все яблоки одинаковыми по весу и все груши одинаковыми по весу.`,
opts: mc('$95$', '$105$', '$100$', '$125$', '$115$'),
answer: 'б',
sol: R`Равновесие: $3a+p=2a+2p+20$, то есть $a-p=20$. Все фрукты: $5a+3p=780$. Отсюда $a=105$, $p=85$.` },
{ idx: 13, type: 'mc', topic: 'stereometry', subtopic: 'ster-lines-planes', diff: 3,
text: R`Прямая $a$, параллельная плоскости $\alpha$, находится от неё на расстоянии $6$. Через прямую $a$ проведена плоскость $\beta$, пересекающая плоскость $\alpha$ по прямой $b$ и образующая с ней угол $60^\circ$. Найдите площадь четырёхугольника $ABCD$, если $A$ и $B$ — точки прямой $a$, причём $AB=4$, а $C$ и $D$ — такие точки прямой $b$, что $CD=3$.`,
opts: mc('$42$', '$42\sqrt3$', '$\dfrac{21\sqrt3}{2}$', '$10{,}5$', '$14\sqrt3$'),
answer: 'д',
sol: R`Прямые $a$ и $b$ параллельны, поэтому $ABCD$ — трапеция с основаниями $AB=4$ и $CD=3$. Её высота (расстояние между $a$ и $b$ в плоскости $\beta$) равна $\dfrac{6}{\sin60^\circ}=4\sqrt3$. Площадь $=\dfrac{4+3}{2}\cdot4\sqrt3=14\sqrt3$.` },
{ idx: 14, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Упростите выражение $\dfrac{125^{x}+25^{x}-12\cdot5^{x}}{5^{x}\left(5^{x}-3\right)}$.`,
opts: mc('$5^{x}$', '$125^{x}-4$', '$5^{x}+4$', '$5^{x}-4$', '$2\cdot5^{x}$'),
answer: 'в',
sol: R`Пусть $u=5^{x}$. Числитель $=u^{3}+u^{2}-12u=u(u+4)(u-3)$, знаменатель $=u(u-3)$. Дробь $=u+4=5^{x}+4$.` },
{ idx: 15, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 3,
text: R`Корень уравнения $\sqrt{10}\cdot x=\dfrac{\sqrt{5^{5}\cdot20}}{\sqrt[4]{10}}$ равен:`,
opts: mc('$25\sqrt[4]{10}$', '$50\sqrt2$', '$25\sqrt[5]{50}$', '$4\sqrt[3]{20}$', '$10\sqrt{10}$'),
answer: 'а',
sol: R`$\sqrt{5^{5}\cdot20}=\sqrt{5^{6}\cdot4}=5^{3}\cdot2=250$, поэтому $x=\dfrac{250}{\sqrt{10}\cdot\sqrt[4]{10}}=\dfrac{250}{10^{3/4}}=25\cdot10^{1/4}=25\sqrt[4]{10}$.` },
{ idx: 16, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 2,
text: R`Какая из прямых $1)\ y=-3$; $\ 2)\ y=-1{,}5$; $\ 3)\ y=0$; $\ 4)\ y=4{,}3$; $\ 5)\ y=2$ пересекает график функции $y=\dfrac14 x^{2}-3x+11$ в двух точках?`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'г',
sol: R`Вершина параболы: $x=6$, $y_{\min}=\dfrac14\cdot36-18+11=2$, ветви вверх. Прямая $y=c$ пересекает график в двух точках при $c>2$. Это $y=4{,}3$ (№4).` },
{ idx: 17, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
text: R`Если $\dfrac{5x}{y}=\dfrac12$, то значение выражения $\dfrac{3y+9x}{13x-y}$ равно:`,
opts: mc('$12$', '$13$', '$\dfrac{11}{7}$', '$\dfrac{93}{129}$', '$\dfrac{1}{13}$'),
answer: 'б',
sol: R`Из $\dfrac{5x}{y}=\dfrac12$ следует $y=10x$. Тогда $\dfrac{3\cdot10x+9x}{13x-10x}=\dfrac{39x}{3x}=13$.` },
{ idx: 18, type: 'mc', topic: 'equations', subtopic: 'eq-logarithmic', diff: 3,
text: R`Наименьшее целое решение неравенства $\lg(x^{2}-2x-8)-\lg(x+2)\le\lg4$ равно:`,
opts: mc('$1$', '$-2$', '$4$', '$5$', '$8$'),
answer: 'г',
sol: R`ОДЗ: $x>4$. На нём $\dfrac{x^{2}-2x-8}{x+2}=x-4$, и неравенство $\lg(x-4)\le\lg4$ даёт $x\le8$. Итого $4<x\le8$; наименьшее целое — $5$.` },
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
{ idx: 19, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
text: R`Если в правильной четырёхугольной пирамиде высота равна $4$, а площадь диагонального сечения равна $12$, то её объём равен … .`,
answer: '24',
sol: R`Диагональное сечение — треугольник с основанием $d$ (диагональ квадрата) и высотой $4$: $\tfrac12 d\cdot4=12$, $d=6$. Сторона основания $a=\dfrac{d}{\sqrt2}=3\sqrt2$, площадь основания $=18$. Объём $=\tfrac13\cdot18\cdot4=24$.` },
{ idx: 20, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Найдите количество всех целых решений неравенства $\dfrac{64x-x^{3}}{5x}>0$.`,
answer: '14',
sol: R`При $x\ne0$ неравенство равносильно $\dfrac{64-x^{2}}{5}>0$, то есть $-8<x<8$. Целые (без $0$): от $-7$ до $7$ — это $14$ чисел.` },
{ idx: 21, type: 'open', topic: 'planimetry', subtopic: 'plan-coordinates', diff: 3,
text: R`Точки $A(1;2)$, $B(5;6)$ и $C(8;6)$ — вершины трапеции $ABCD$ ($AD\parallel BC$). Найдите сумму координат точки $D$, если $BD=4\sqrt2$.`,
answer: '11',
sol: R`$BC$ горизонтальна, значит $AD$ тоже горизонтальна и $D$ имеет ординату $2$. Из $BD^{2}=(d-5)^{2}+16=32$ получаем $d=9$ ($d=1$ даёт $D=A$). Тогда $D(9;2)$, сумма координат $11$.` },
{ idx: 22, type: 'open', topic: 'planimetry', subtopic: 'plan-polygons', diff: 3,
text: R`Найдите периметр правильного шестиугольника, меньшая диагональ которого равна $10\sqrt3$.`,
answer: '60',
sol: R`У правильного шестиугольника меньшая диагональ равна $a\sqrt3$, поэтому $a\sqrt3=10\sqrt3$, $a=10$. Периметр $=6a=60$.` },
{ idx: 23, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Найдите произведение корней уравнения $4^{x^{2}}+128=3^{1-x^{2}}\cdot12^{x^{2}}$.`,
answer: '-3',
sol: R`Пусть $u=x^{2}$. Так как $3^{1-u}\cdot12^{u}=3\cdot4^{u}$, уравнение даёт $4^{u}+128=3\cdot4^{u}$, $4^{u}=64$, $u=3$. Тогда $x=\pm\sqrt3$, произведение корней $-3$.` },
{ idx: 24, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 5,
text: R`Площадь прямоугольника $ABCD$ равна $20$. Точки $M$, $N$, $P$, $Q$ — середины его сторон $AB$, $BC$, $CD$, $DA$ соответственно. Найдите площадь четырёхугольника, заключённого между прямыми $AN$, $BP$, $CQ$ и $DM$.`,
answer: '4',
sol: R`Прямые $AN\parallel CQ$ и $BP\parallel DM$, поэтому внутренний четырёхугольник — параллелограмм. Координатный расчёт показывает, что его площадь составляет $\dfrac15$ площади прямоугольника: $\dfrac{20}{5}=4$.` },
{ idx: 25, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 4,
text: R`Решите уравнение $x^{2}-7x+10=\dfrac{7}{x^{2}-11x+28}$ и найдите сумму его корней.`,
answer: '9',
sol: R`Уравнение приводится к $(x^{2}-9x+21)(x^{2}-9x+13)=0$. Первый множитель действительных корней не имеет ($D<0$), второй даёт корни с суммой $9$.` },
{ idx: 26, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
text: R`Найдите значение выражения $16\sin\left(\alpha-\dfrac{\pi}{4}\right)$, если $\sin2\alpha=\dfrac{23}{32}$ и $2\alpha\in\left(0;\dfrac{\pi}{2}\right)$.`,
answer: '-6',
sol: R`$16\sin\left(\alpha-\tfrac{\pi}{4}\right)=8\sqrt2(\sin\alpha-\cos\alpha)$. Так как $(\sin\alpha-\cos\alpha)^{2}=1-\sin2\alpha=\tfrac{9}{32}$ и при $\alpha<\tfrac{\pi}{4}$ разность отрицательна, $\sin\alpha-\cos\alpha=-\tfrac{3\sqrt2}{8}$. Значение $=8\sqrt2\cdot\left(-\tfrac{3\sqrt2}{8}\right)=-6$.` },
{ idx: 27, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 4,
text: R`Найдите сумму целых значений $x$, принадлежащих области определения функции $y=\log_{2-x}\left(12-x-x^{2}\right)$.`,
answer: '-6',
sol: R`Условия: $2-x>0$, $2-x\ne1$ и $12-x-x^{2}>0$. Получаем $-4<x<2$, $x\ne1$. Целые: $-3,-2,-1,0$; их сумма $-6$.` },
{ idx: 28, type: 'open', topic: 'stereometry', subtopic: 'ster-rotation', diff: 5,
text: R`Прямоугольный треугольник с катетами, равными $6$ и $2\sqrt7$, вращается вокруг оси, содержащей его гипотенузу. Найдите значение выражения $\dfrac{2V}{\pi}$, где $V$ — объём фигуры вращения.`,
answer: '84',
sol: R`Гипотенуза $=\sqrt{36+28}=8$, высота к ней $h=\dfrac{6\cdot2\sqrt7}{8}=\dfrac{3\sqrt7}{2}$. Фигура — два конуса с общим основанием: $V=\tfrac13\pi h^{2}\cdot8=\tfrac13\pi\cdot\tfrac{63}{4}\cdot8=42\pi$. Тогда $\dfrac{2V}{\pi}=84$.` },
{ idx: 29, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 5,
text: R`Из двух растворов с различным процентным содержанием спирта массой $100$ г и $900$ г отлили по одинаковому количеству. Каждый из отлитых растворов долили в остаток другого раствора, после чего процентное содержание спирта в обоих растворах стало одинаковым. Найдите, сколько раствора (в граммах) было отлито из каждого раствора.`,
answer: '90',
sol: R`Пусть отлито по $m$ г. Равенство итоговых концентраций приводит к $(900-10m)(c_1-c_2)=0$. Поскольку концентрации различны, $900-10m=0$, $m=90$.` },
{ idx: 30, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 5,
text: R`Найдите произведение корней уравнения $x-\sqrt{x^{2}-36}=\dfrac{(x-6)^{2}}{2x+12}$.`,
answer: '-180',
sol: R`ОДЗ: $|x|\ge6$. После преобразований и возведения в квадрат получаем $x^{4}-168x^{2}-2160=0$, откуда $x^{2}=180$, то есть $x=\pm6\sqrt5$ (оба корня подходят). Произведение $=-180$.` },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`;
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_ct2012_v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '\n');
console.log('idx | type | subtopic | d | answer');
console.log('----+------+-----------------------+---+----------');
for (const t of TASKS) {
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2012_v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2012».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+348
View File
@@ -0,0 +1,348 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_ct2013_v1.js
Чистый вариант-пробник для трека exam-prep `ctmath`.
Источник: Централизованное тестирование (ЦТ) по математике, 2013, Вариант 1.
Формат: Часть А = А1–А18, Часть В = В1–В12 (все В — числовые). Всего 30 заданий.
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2013\ЦТ2013.pdf
(ответы — отдельный файл «Ответы ЦТ 2013.pdf», столбец «Вариант 1»).
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
совпадение, включая B3=75, B9=40, B10=6, B12=-5. variant=119. Прогнан через
дедуп-гейт (check_variant_dups.js) — без повторов с видимым пулом.
Реконструкции заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
• А2 (образующая цилиндра) → взаимное расположение точек дано в тексте (AD ⟂ основаниям → AD);
• А3 (точка на графике) → прямая задана как $y=13$, точки перечислены (T(-7;13));
• А6 (углы при развёрнутом угле) → порядок лучей задан явно (∠BOC=40°);
• А16 (сечение параллелепипеда) → размеры/угол 60° в тексте (сечение 12×6=72).
Без авторских ссылок (политика «все учебники наши»).
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
Запуск:
node backend/scripts/seed_ctmath_ct2013_v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_ct2013_v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 119;
const N_TASKS = 30;
const PROV = 'ЦТ–2013, Вариант 1';
const R = String.raw;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
text: R`Среди чисел $\sqrt9$; $-9$; $\dfrac19$; $-0{,}9$; $9^{-1}$ выберите число, противоположное числу $9$.`,
opts: mc('$\sqrt9$', '$-9$', '$\dfrac19$', '$-0{,}9$', '$9^{-1}$'),
answer: 'б',
sol: R`Противоположное числу $9$ — это $-9$.` },
{ idx: 2, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 1,
text: R`Прямой круговой цилиндр; $O$ и $O_1$ — центры верхнего и нижнего оснований. Точки $A$ и $B$ лежат на окружности верхнего основания, $C$ и $D$ — на окружности нижнего, причём $A$ находится точно над $D$ (отрезок $AD$ перпендикулярен основаниям). Образующей цилиндра является отрезок:`,
opts: mc('$DB$', '$DC$', '$DO_1$', '$OO_1$', '$AD$'),
answer: 'д',
sol: R`Образующая прямого цилиндра — отрезок поверхности, перпендикулярный основаниям и соединяющий соответствующие точки окружностей. Это отрезок $AD$ ($OO_1$ — ось, а не образующая).` },
{ idx: 3, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 1,
text: R`Среди точек $B(13;0)$, $T(-7;13)$, $C\left(-\sqrt{13};\sqrt{13}\right)$, $O(0;0)$, $L(0;-13)$ выберите ту, которая принадлежит графику функции $y=13$.`,
opts: mc('$B$', '$T$', '$C$', '$O$', '$L$'),
answer: 'б',
sol: R`Графику $y=13$ принадлежат точки с ординатой $13$. Это $T(-7;13)$.` },
{ idx: 4, type: 'mc', topic: 'numbers', subtopic: 'num-fractions', diff: 2,
text: R`Найдите значение выражения $\left(2\tfrac{7}{12}-2\tfrac{17}{36}\right)\cdot2{,}7-0{,}4$.`,
opts: mc('$0{,}1$', '$-0{,}7$', '$-0{,}1$', '$0{,}3$', '$-1{,}5$'),
answer: 'в',
sol: R`$2\tfrac{7}{12}-2\tfrac{17}{36}=\tfrac{93-89}{36}=\tfrac{4}{36}=\tfrac19$. Тогда $\tfrac19\cdot2{,}7-0{,}4=0{,}3-0{,}4=-0{,}1$.` },
{ idx: 5, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
text: R`Одно число меньше другого на $64$, что составляет $16\%$ большего числа. Найдите меньшее число.`,
opts: mc('$800$', '$470$', '$336$', '$464$', '$390$'),
answer: 'в',
sol: R`Большее число $=\dfrac{64}{0{,}16}=400$, меньшее $=400-64=336$.` },
{ idx: 6, type: 'mc', topic: 'planimetry', subtopic: 'plan-angles', diff: 2,
text: R`Угол $AOM$ — развёрнутый ($A$, $O$, $M$ на одной прямой). Лучи $OB$ и $OC$ проведены по одну сторону от прямой $AM$, причём луч $OB$ ближе к лучу $OA$. Известно, что $\angle AOC=107^\circ$, $\angle BOM=113^\circ$. Найдите величину угла $BOC$.`,
opts: mc('$73^\circ$', '$67^\circ$', '$17^\circ$', '$40^\circ$', '$23^\circ$'),
answer: 'г',
sol: R`$\angle AOB=180^\circ-\angle BOM=67^\circ$, поэтому $\angle BOC=\angle AOC-\angle AOB=107^\circ-67^\circ=40^\circ$.` },
{ idx: 7, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 2,
text: R`Образующая конуса равна $26$ и наклонена к плоскости основания под углом $60^\circ$. Найдите площадь боковой поверхности конуса.`,
opts: mc('$338\pi$', '$338\sqrt3\,\pi$', '$169\pi$', '$260\sqrt3\,\pi$', '$676\pi$'),
answer: 'а',
sol: R`Радиус $r=l\cos60^\circ=26\cdot\tfrac12=13$. Боковая поверхность $=\pi r l=\pi\cdot13\cdot26=338\pi$.` },
{ idx: 8, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 2,
text: R`Расположите числа $2{,}44$; $\dfrac{18}{7}$; $2{,}(4)$ в порядке возрастания.`,
opts: mc('$2{,}44;\ \dfrac{18}{7};\ 2{,}(4)$', '$2{,}44;\ 2{,}(4);\ \dfrac{18}{7}$', '$\dfrac{18}{7};\ 2{,}44;\ 2{,}(4)$', '$2{,}(4);\ \dfrac{18}{7};\ 2{,}44$', '$2{,}(4);\ 2{,}44;\ \dfrac{18}{7}$'),
answer: 'б',
sol: R`$2{,}44<2{,}(4)=2{,}444\ldots<\dfrac{18}{7}=2{,}571\ldots$, то есть $2{,}44;\ 2{,}(4);\ \dfrac{18}{7}$.` },
{ idx: 9, type: 'mc', topic: 'equations', subtopic: 'eq-quadratic', diff: 2,
text: R`Одна из сторон прямоугольника на $7$ см длиннее другой, а его площадь равна $78$ см². Уравнение, одним из корней которого является длина меньшей стороны прямоугольника, имеет вид:`,
opts: mc('$x^{2}-78x+7=0$', '$x^{2}-7x-78=0$', '$x^{2}+7x+78=0$', '$x^{2}+7x-78=0$', '$x^{2}+78x-7=0$'),
answer: 'г',
sol: R`Если меньшая сторона $x$, то $x(x+7)=78$, то есть $x^{2}+7x-78=0$.` },
{ idx: 10, type: 'mc', topic: 'planimetry', subtopic: 'plan-coordinates', diff: 2,
text: R`Точки $A(-3;3)$ и $B(4;1)$ — вершины квадрата $ABCD$. Периметр квадрата равен:`,
opts: mc('$4\sqrt{17}$', '$2\sqrt{53}$', '$18$', '$15$', '$4\sqrt{53}$'),
answer: 'д',
sol: R`$AB=\sqrt{(4+3)^{2}+(1-3)^{2}}=\sqrt{49+4}=\sqrt{53}$ — сторона квадрата. Периметр $=4\sqrt{53}$.` },
{ idx: 11, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 3,
text: R`Упростите выражение $\dfrac{11\sqrt{11}+5\sqrt5}{\sqrt{11}+\sqrt5}-\sqrt{55}+\dfrac{12\sqrt5}{\sqrt{11}-\sqrt5}$.`,
opts: mc('$\dfrac{1}{\sqrt{11}+\sqrt5}$', '$\sqrt{55}$', '$16$', '$26$', '$\dfrac{5}{\sqrt{11}-\sqrt5}$'),
answer: 'г',
sol: R`$\dfrac{(\sqrt{11})^{3}+(\sqrt5)^{3}}{\sqrt{11}+\sqrt5}=11-\sqrt{55}+5=16-\sqrt{55}$; $\dfrac{12\sqrt5}{\sqrt{11}-\sqrt5}=2\sqrt5(\sqrt{11}+\sqrt5)=2\sqrt{55}+10$. Сумма: $(16-\sqrt{55})-\sqrt{55}+(2\sqrt{55}+10)=26$.` },
{ idx: 12, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Решением неравенства $\dfrac{26}{3}-\dfrac{7x^{2}+4x}{7}>\dfrac{2-3x^{2}}{3}$ является промежуток:`,
opts: mc('$(14;+\infty)$', '$(-14;+\infty)$', '$\left(-\infty;\dfrac{1}{14}\right)$', '$(-\infty;14)$', '$\left(\dfrac{1}{14};+\infty\right)$'),
answer: 'г',
sol: R`Умножив на $21$: $182-3(7x^{2}+4x)>7(2-3x^{2})$, то есть $182-21x^{2}-12x>14-21x^{2}$, $182-12x>14$, $x<14$.` },
{ idx: 13, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
text: R`Найдите длину средней линии прямоугольной трапеции с острым углом $60^\circ$, у которой бóльшая боковая сторона и бóльшее основание равны $10$.`,
opts: mc('$5\sqrt3$', '$10\sqrt3$', '$15$', '$5$', '$7{,}5$'),
answer: 'д',
sol: R`Проекция наклонной боковой стороны на основание $=10\cos60^\circ=5$, поэтому меньшее основание $=10-5=5$. Средняя линия $=\dfrac{10+5}{2}=7{,}5$.` },
{ idx: 14, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 3,
text: R`Упростите выражение $\left(5+\dfrac{a^{2}+25c^{2}-b^{2}}{2ac}\right):(a+b+5c)\cdot2ac$.`,
opts: mc('$a+5c-b$', '$4a^{2}c^{2}$', '$5$', '$a+5c+b$', '$a-5c-b$'),
answer: 'а',
sol: R`$5+\dfrac{a^{2}+25c^{2}-b^{2}}{2ac}=\dfrac{(a+5c)^{2}-b^{2}}{2ac}=\dfrac{(a+5c-b)(a+5c+b)}{2ac}$. После деления на $(a+b+5c)$ и умножения на $2ac$ получаем $a+5c-b$.` },
{ idx: 15, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Найдите сумму целых решений неравенства $3(x-5)>(x-5)^{2}$.`,
opts: mc('$13$', '$9$', '$-13$', '$26$', '$-9$'),
answer: 'а',
sol: R`Пусть $u=x-5$: $3u>u^{2}$, $u(u-3)<0$, $0<u<3$, значит $5<x<8$. Целые $6$ и $7$, их сумма $13$.` },
{ idx: 16, type: 'mc', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
text: R`$ABCDA_1B_1C_1D_1$ — прямоугольный параллелепипед, $AB=12$, $AD=3$. Через середины рёбер $AA_1$ и $BB_1$ проведена плоскость, составляющая угол $60^\circ$ с плоскостью основания $ABCD$. Найдите площадь сечения параллелепипеда этой плоскостью.`,
opts: mc('$72$', '$36\sqrt3$', '$36$', '$18$', '$36\sqrt2$'),
answer: 'а',
sol: R`Сечение — параллелограмм; одна сторона равна $AB=12$, другая проходит через всю глубину $AD=3$ под углом $60^\circ$: её длина $=\dfrac{3}{\cos60^\circ}=6$. Площадь $=12\cdot6=72$.` },
{ idx: 17, type: 'mc', topic: 'trigonometry', subtopic: 'trig-identities', diff: 3,
text: R`Сумма наибольшего и наименьшего значений функции $y=(3\sin2x+3\cos2x)^{2}$ равна:`,
opts: mc('$8$', '$9$', '$18$', '$36$', '$3$'),
answer: 'в',
sol: R`$y=9(\sin2x+\cos2x)^{2}=9(1+\sin4x)$. Так как $\sin4x\in[-1;1]$, то $y\in[0;18]$. Сумма $0+18=18$.` },
{ idx: 18, type: 'mc', topic: 'equations', subtopic: 'eq-logarithmic', diff: 3,
text: R`Корень уравнения $\log_{1{,}6}\dfrac{9-4x}{3x-11}+\log_{1{,}6}\big((9-4x)(3x-11)\big)=0$ (или их сумма, если корней несколько) принадлежит промежутку:`,
opts: mc('$[0;1)$', '$[1;2)$', '$(2;3]$', '$(3;4]$', '$[-1;0)$'),
answer: 'в',
sol: R`Сумма логарифмов равна $\log_{1{,}6}(9-4x)^{2}=0$, поэтому $(9-4x)^{2}=1$, $x=2$ или $x=2{,}5$. Из условия положительности обоих аргументов остаётся $x=2{,}5\in(2;3]$.` },
// ── Часть B: В1–В12 (все числовые) ───────────────────────────────────────
{ idx: 19, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 3,
text: R`Автомобиль проехал некоторое расстояние, израсходовав $21$ л топлива; расход составил $9$ л на $100$ км пробега. Затем расход топлива вырос до $12$ л на $100$ км. Сколько литров топлива понадобится автомобилю, чтобы проехать такое же расстояние?`,
answer: '28',
sol: R`Расстояние $=\dfrac{21}{9}\cdot100$ км. При расходе $12$ л/$100$ км нужно $\dfrac{21}{9}\cdot12=28$ л.` },
{ idx: 20, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 3,
text: R`Решите уравнение $\sqrt{x-5}-\sqrt{(x-5)(x+2)}=0$. В ответ запишите сумму его корней (корень, если он один).`,
answer: '5',
sol: R`ОДЗ: $x\ge5$. $\sqrt{x-5}\,\big(1-\sqrt{x+2}\big)=0$ даёт $x=5$ (второй множитель при $x\ge5$ не равен нулю). Единственный корень $5$.` },
{ idx: 21, type: 'open', topic: 'planimetry', subtopic: 'plan-triangles', diff: 3,
text: R`Основание остроугольного равнобедренного треугольника равно $10$, а синус противолежащего угла равен $0{,}6$. Найдите площадь треугольника.`,
answer: '75',
sol: R`Острый противолежащий угол $\alpha$: $\sin\alpha=0{,}6$, $\cos\alpha=0{,}8$. По теореме косинусов $10^{2}=2b^{2}(1-\cos\alpha)=0{,}4b^{2}$, $b^{2}=250$. Площадь $=\tfrac12 b^{2}\sin\alpha=\tfrac12\cdot250\cdot0{,}6=75$.` },
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-systems', diff: 4,
text: R`Пусть $(x;y)$ — целочисленное решение системы уравнений $\begin{cases}4y+x=-14,\\ 4y^{2}-4xy+x^{2}=16.\end{cases}$ Найдите сумму $x+y$.`,
answer: '-5',
sol: R`Второе уравнение: $(x-2y)^{2}=16$, $x-2y=\pm4$. С $x=-14-4y$ целое решение даёт $y=-3$, $x=-2$. Тогда $x+y=-5$.` },
{ idx: 23, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Найдите наибольшее целое решение неравенства $2^{3x-32}\cdot11^{x-6}>22^{2x-19}$.`,
answer: '12',
sol: R`$22^{2x-19}=2^{2x-19}\cdot11^{2x-19}$, поэтому неравенство равносильно $\left(\tfrac{2}{11}\right)^{x-13}>1$, то есть $x-13<0$, $x<13$. Наибольшее целое — $12$.` },
{ idx: 24, type: 'open', topic: 'trigonometry', subtopic: 'trig-equations', diff: 4,
text: R`Найдите количество корней уравнения $32\sin2x+8\cos4x=23$ на промежутке $\left[-\pi;\dfrac{3\pi}{4}\right]$.`,
answer: '4',
sol: R`Через $\cos4x=1-2\sin^{2}2x$ получаем $16\sin^{2}2x-32\sin2x+15=0$, откуда $\sin2x=0{,}75$. На указанном промежутке ($2x\in[-2\pi;\tfrac{3\pi}{2}]$) уравнение имеет $4$ корня.` },
{ idx: 25, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
text: R`Геометрическая прогрессия со знаменателем $5$ содержит $10$ членов. Сумма всех членов прогрессии равна $24$. Найдите сумму всех членов прогрессии с чётными номерами.`,
answer: '20',
sol: R`Каждый чётный член в $5$ раз больше предыдущего нечётного, поэтому сумма чётных в $5$ раз больше суммы нечётных. Если сумма нечётных равна $s$, то $s+5s=24$, $s=4$, и сумма членов с чётными номерами равна $5s=20$.` },
{ idx: 26, type: 'open', topic: 'equations', subtopic: 'eq-modulus', diff: 5,
text: R`Найдите сумму корней уравнения $\big|(x-1)(x-6)\big|\cdot\big(|x+2|+|x-8|+|x-3|\big)=11(x-1)(6-x)$.`,
answer: '13',
sol: R`Правая часть неотрицательна лишь при $1\le x\le6$; на этом отрезке $|(x-1)(x-6)|=(x-1)(6-x)$. Уравнение даёт $(x-1)(6-x)\big(S-11\big)=0$, где $S=|x+2|+|x-8|+|x-3|=10+|x-3|$. Корни: $x=1,\ 6$ (множитель $0$) и $|x-3|=1$, то есть $x=2,\ 4$. Сумма $1+2+4+6=13$.` },
{ idx: 27, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 4,
text: R`Из города $A$ в город $B$, расстояние между которыми $100$ км, одновременно выезжают два автомобиля. Скорость первого автомобиля на $10$ км/ч больше скорости второго, но в пути он делает остановку на $50$ мин. Найдите наибольшее значение скорости (в км/ч) первого автомобиля, при движении с которой он прибудет в $B$ не позже второго.`,
answer: '40',
sol: R`Пусть скорость второго $v$. Условие $\dfrac{100}{v+10}+\dfrac56\le\dfrac{100}{v}$ приводит к $\dfrac56\le\dfrac{1000}{v(v+10)}$, то есть $v(v+10)\le1200$, $v\le30$. Наибольшая скорость первого $=30+10=40$.` },
{ idx: 28, type: 'open', topic: 'planimetry', subtopic: 'plan-circles', diff: 5,
text: R`Из точки $A$ проведены к окружности радиуса $\dfrac43$ касательная $AB$ ($B$ — точка касания) и секущая $AC$, проходящая через центр окружности и пересекающая её в точках $D$ и $C$. Найдите площадь $S$ треугольника $ABC$, если длина секущей $AC$ в $3$ раза больше длины касательной. В ответ запишите $5S$.`,
answer: '6',
sol: R`$AB^{2}=AO^{2}-r^{2}$ и $AC=AO+r=3\,AB$ дают $AB=\tfrac{3r}{4}=1$, $AO=\tfrac53$, $AC=3$. В координатах $B=(0{,}6;0{,}8)$, высота из $B$ к $AC$ равна $0{,}8$, площадь $=\tfrac12\cdot3\cdot0{,}8=1{,}2$. Тогда $5S=6$.` },
{ idx: 29, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
text: R`Если $\cos(\alpha+14^\circ)=\dfrac35$ и $0<\alpha+14^\circ<90^\circ$, то значение выражения $15\sqrt2\,\cos(\alpha+59^\circ)$ равно … .`,
answer: '-3',
sol: R`$\cos(\alpha+59^\circ)=\cos\big((\alpha+14^\circ)+45^\circ\big)=\tfrac{\sqrt2}{2}\big(\tfrac35-\tfrac45\big)=-\tfrac{\sqrt2}{10}$ (здесь $\sin(\alpha+14^\circ)=\tfrac45$). Тогда $15\sqrt2\cdot\left(-\tfrac{\sqrt2}{10}\right)=-3$.` },
{ idx: 30, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 5,
text: R`Решите уравнение $\dfrac{30x^{2}}{x^{4}+25}=x^{2}+2\sqrt5\,x+8$. В ответ запишите значение выражения $x\cdot|x|$, где $x$ — корень уравнения.`,
answer: '-5',
sol: R`Левая часть $\le3$ (так как $x^{4}+25\ge10x^{2}$), правая часть $=(x+\sqrt5)^{2}+3\ge3$. Равенство возможно лишь при $x=-\sqrt5$. Тогда $x\cdot|x|=-\sqrt5\cdot\sqrt5=-5$.` },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`;
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_ct2013_v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '\n');
console.log('idx | type | subtopic | d | answer');
console.log('----+------+-----------------------+---+----------');
for (const t of TASKS) {
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2013_v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2013».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+350
View File
@@ -0,0 +1,350 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
seed_ctmath_ct2017_v1.js
Чистый вариант-пробник для трека exam-prep `ctmath`.
Источник: Централизованное тестирование (ЦТ) по математике, 2017, Вариант 1.
Формат: Часть А = А1–А18, Часть В = В1–В12 (В1 — на соответствие). Всего 30 заданий.
Перенабрано вручную в KaTeX по PDF: F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\2017\CT-2017.pdf
(ответы — отдельный файл «Ответы ЦТ 2017.pdf», столбец «Вариант 1»).
⚠️ ВСЕ 30 ответов решены самостоятельно и СВЕРЕНЫ с официальной таблицей — полное
совпадение, включая B6=56, B8=-143, B11=121, B12=115. variant=118 (закрывает пробел
между ЦТ-2016 и ЦТ-2018). Прогнан через дедуп-гейт (check_variant_dups.js) — без повторов.
Реконструкции заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
• А1 (вращение прямоугольников) → размеры сторона-ось/смежная даны числами (квадрат-сечение ⟺ ось=2·смежная → 3,5);
• А3 (график движения) → путь на участке BC задан числами (52 км/ч);
• А9 (треугольник по рисунку) → явно: BM — биссектриса угла B, AM/MC=AB/BC → 13,8;
• А11 (фигура на сетке) → площадь фигуры дана числом ($18$ см² = 28 % трапеции → 64 2/7);
• А14 (выбор параболы) → вершина/точка/направление ветвей в тексте.
Без авторских ссылок (политика «все учебники наши»).
Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
Запуск:
node backend/scripts/seed_ctmath_ct2017_v1.js # DRY-RUN (по умолчанию)
node backend/scripts/seed_ctmath_ct2017_v1.js --apply # запись в БД
⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
─────────────────────────────────────────────────────────────────────────── */
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const APPLY = process.argv.includes('--apply');
const EXAM = 'ctmath';
const VARIANT = 118;
const N_TASKS = 30;
const PROV = 'ЦТ–2017, Вариант 1';
const R = String.raw;
const L = ['а', 'б', 'в', 'г', 'д'];
const mc = (...html) => html.map((h, i) => [L[i], h]);
/* ── 30 заданий ─────────────────────────────────────────────────────────── */
const TASKS = [
// ── Часть A: А1–А18 ──────────────────────────────────────────────────────
{ idx: 1, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 2,
text: R`Прямоугольник вращают вокруг указанной стороны (оси), образуя цилиндр. Осевым сечением цилиндра должен быть квадрат. Укажите номера прямоугольников (ось $\times$ смежная сторона): $1)\ 8\times8$; $\ 2)\ 8\times16$; $\ 3)\ 8\times4$; $\ 4)\ 4\times8$; $\ 5)\ 16\times8$.`,
opts: mc('$2,\ 3$', '$1,\ 5$', '$3,\ 5$', '$2,\ 4$', '$1,\ 3,\ 5$'),
answer: 'в',
sol: R`Осевое сечение — прямоугольник «ось $\times$ диаметр $=$ ось $\times2\cdot$смежная». Это квадрат, когда ось $=2\cdot$смежная: для $8\times4$ ($8=2\cdot4$) и $16\times8$ ($16=2\cdot8$). Значит $3$ и $5$.` },
{ idx: 2, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
text: R`Выразите $737$ см $8$ мм в метрах с точностью до сотых.`,
opts: mc('$0{,}74$ м', '$7{,}37$ м', '$7{,}378$ м', '$7{,}38$ м', '$73{,}78$ м'),
answer: 'г',
sol: R`$737$ см $8$ мм $=7{,}378$ м $\approx7{,}38$ м.` },
{ idx: 3, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 1,
text: R`По графику движения автомобиля на участке $BC$ путь изменился с $52$ км до $104$ км за $1$ ч. Найдите скорость движения на участке $BC$.`,
opts: mc('$26$ км/ч', '$52$ км/ч', '$78$ км/ч', '$104$ км/ч', '$60$ км/ч'),
answer: 'б',
sol: R`$v=\dfrac{104-52}{1}=52$ км/ч.` },
{ idx: 4, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
text: R`Выразите $a$ из равенства $\dfrac{3}{2b+1}=\dfrac{6}{a-b}$.`,
opts: mc('$a=5b+2$', '$a=5b-2$', '$a=15b-6$', '$a=15b+6$', '$a=3b+1$'),
answer: 'а',
sol: R`$3(a-b)=6(2b+1)$, $a-b=4b+2$, $a=5b+2$.` },
{ idx: 5, type: 'mc', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 2,
text: R`Значение выражения $8\sqrt3+\dfrac18\sqrt{192}$ равно:`,
opts: mc('$16\sqrt3$', '$\sqrt{195}$', '$\dfrac{65\sqrt{195}}{8}$', '$\dfrac{6\sqrt3}{8}$', '$9\sqrt3$'),
answer: 'д',
sol: R`$\sqrt{192}=8\sqrt3$, поэтому $\dfrac18\cdot8\sqrt3=\sqrt3$ и $8\sqrt3+\sqrt3=9\sqrt3$.` },
{ idx: 6, type: 'mc', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 1,
text: R`Последовательность $(a_n)$ задана формулой $a_n=3n^{2}-8n+9$. Второй член этой последовательности равен:`,
opts: mc('$12$', '$-16$', '$5$', '$16$', '$6$'),
answer: 'в',
sol: R`$a_2=3\cdot4-16+9=5$.` },
{ idx: 7, type: 'mc', topic: 'trigonometry', subtopic: 'trig-identities', diff: 2,
text: R`Значение выражения $7\cos^{2}34^\circ+10\sin30^\circ+7\sin^{2}34^\circ$ равно:`,
opts: mc('$12$', '$17$', '$24$', '$7+10\sqrt3$', '$14+5\sqrt3$'),
answer: 'а',
sol: R`$7(\cos^{2}34^\circ+\sin^{2}34^\circ)+10\cdot\tfrac12=7+5=12$.` },
{ idx: 8, type: 'mc', topic: 'numbers', subtopic: 'num-divisibility', diff: 1,
text: R`Среди утверждений укажите номер верного.<br>$1)$ число $451$ кратно числу $5$; $\ 2)$ число $9$ кратно числу $35$; $\ 3)$ число $2$ кратно числу $14$; $\ 4)$ число $116$ кратно числу $1$; $\ 5)$ число $43$ кратно числу $0$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'г',
sol: R`Любое целое кратно $1$, поэтому $116$ кратно $1$ — верно (утверждение 4). Остальные неверны.` },
{ idx: 9, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
text: R`В треугольнике $ABC$ отрезок $BM$ — биссектриса угла $B$ ($M$ на $AC$). Известно, что $AC=32$, $AM=12$, $BC=23$. Найдите длину стороны $AB$.`,
opts: mc('$10{,}2$', '$14{,}6$', '$13{,}8$', '$13{,}5$', '$10{,}4$'),
answer: 'в',
sol: R`Биссектриса делит сторону в отношении прилежащих сторон: $\dfrac{AM}{MC}=\dfrac{AB}{BC}$. $MC=32-12=20$, поэтому $AB=BC\cdot\dfrac{AM}{MC}=23\cdot\dfrac{12}{20}=13{,}8$.` },
{ idx: 10, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
text: R`Результат упрощения выражения $\sqrt{(2x-4{,}6)^{2}}+4{,}6$ при $-1<x<1$ имеет вид:`,
opts: mc('$9{,}2-2x$', '$-2x-9{,}2$', '$2x+9{,}2$', '$2x$', '$-2x$'),
answer: 'а',
sol: R`При $-1<x<1$ имеем $2x-4{,}6<0$, поэтому $\sqrt{(2x-4{,}6)^{2}}=4{,}6-2x$, и сумма $=(4{,}6-2x)+4{,}6=9{,}2-2x$.` },
{ idx: 11, type: 'mc', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 2,
text: R`Площадь фигуры на клетчатой бумаге равна $18$ см² и составляет 28 % площади некоторой трапеции. Найдите площадь трапеции (в квадратных сантиметрах).`,
opts: mc('$504$', '$64\tfrac27$', '$35$', '$72\tfrac34$', '$155\tfrac59$'),
answer: 'б',
sol: R`$\dfrac{18}{0{,}28}=\dfrac{1800}{28}=\dfrac{450}{7}=64\tfrac27$ см².` },
{ idx: 12, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 2,
text: R`Определите остроугольный треугольник по длинам его сторон: $\triangle ABC$ ($8;15;17$), $\triangle MNK$ ($4;6;8$), $\triangle BDC$ ($3;4;5$), $\triangle FBC$ ($7;8;9$), $\triangle CDE$ ($5;11;13$).`,
opts: mc('$\triangle ABC$', '$\triangle MNK$', '$\triangle BDC$', '$\triangle FBC$', '$\triangle CDE$'),
answer: 'г',
sol: R`Треугольник остроугольный, если квадрат большей стороны меньше суммы квадратов двух других. Только для $\triangle FBC$: $9^{2}=81<7^{2}+8^{2}=113$. ($ABC$ и $BDC$ прямоугольные, $MNK$ и $CDE$ тупоугольные.)` },
{ idx: 13, type: 'mc', topic: 'word-sequences', subtopic: 'word-problems', diff: 2,
text: R`Купили $m$ ручек по цене $2$ руб $3$ коп за штуку и $178$ тетрадей по цене $a$ коп за штуку. Составьте выражение, определяющее стоимость покупки (в рублях).`,
opts: mc('$2{,}03m+178a$', '$2{,}03m+1{,}78a$', '$2{,}3m+1{,}78a$', '$2{,}3m+17{,}8a$', '$2{,}03m+17{,}8a$'),
answer: 'б',
sol: R`$2$ руб $3$ коп $=2{,}03$ руб, $178$ тетрадей по $a$ коп $=178a$ коп $=1{,}78a$ руб. Итого $2{,}03m+1{,}78a$.` },
{ idx: 14, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 2,
text: R`Парабола проходит через точку $(0;3)$, имеет вершину в точке $(-1;1)$, ветви направлены вверх. Укажите номер её уравнения.<br>$1)\ y=x^{2}+4x+3$; $\ 2)\ y=x^{2}-4x-3$; $\ 3)\ y=2x^{2}+4x+3$; $\ 4)\ y=2x^{2}+4x-3$; $\ 5)\ y=2x^{2}-4x+3$.`,
opts: mc('$1$', '$2$', '$3$', '$4$', '$5$'),
answer: 'в',
sol: R`У $y=2x^{2}+4x+3$ вершина в точке $(-1;1)$ ($x=-\tfrac{4}{4}=-1$, $y=2-4+3=1$), $y(0)=3$, ветви вверх. Это уравнение 3.` },
{ idx: 15, type: 'mc', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
text: R`$ABCDA_1B_1C_1D_1$ — куб. Точки $M$ и $N$ — середины рёбер $AD$ и $DC$, точка $K$ на ребре $A_1D_1$ с $KA_1:KD_1=1:3$. Сечением куба плоскостью, проходящей через точки $M$, $N$ и $K$, является:`,
opts: mc('восьмиугольник', 'треугольник', 'четырёхугольник', 'пятиугольник', 'шестиугольник'),
answer: 'в',
sol: R`Плоскость отсекает ребро $DD_1$ (оба конца по одну сторону) и пересекает четыре ребра: $AD$ (точка $M$), $DC$ ($N$), $A_1D_1$ ($K$) и $D_1C_1$. Четыре точки — сечение является четырёхугольником.` },
{ idx: 16, type: 'mc', topic: 'equations', subtopic: 'eq-rational', diff: 2,
text: R`Найдите сумму наименьшего и наибольшего целых решений двойного неравенства $-448{,}9<2{,}9+9x<23{,}6$.`,
opts: mc('$-52$', '$-47$', '$-49$', '$-48$', '$-53$'),
answer: 'г',
sol: R`$-451{,}8<9x<20{,}7$, то есть $-50{,}2<x<2{,}3$. Целые от $-50$ до $2$; сумма наименьшего и наибольшего $-50+2=-48$.` },
{ idx: 17, type: 'mc', topic: 'stereometry', subtopic: 'ster-rotation', diff: 3,
text: R`Через точку $A$ высоты $SO$ конуса проведена плоскость, параллельная основанию. Определите, во сколько раз площадь основания конуса больше площади полученного сечения, если $SA:AO=2:3$.`,
opts: mc('$6\tfrac14$', '$7\tfrac14$', '$2\tfrac14$', '$1\tfrac12$', '$2\tfrac12$'),
answer: 'а',
sol: R`Сечение подобно основанию с коэффициентом $\dfrac{SA}{SO}=\dfrac{2}{5}$. Отношение площадей $\left(\dfrac{SO}{SA}\right)^{2}=\left(\dfrac52\right)^{2}=6\tfrac14$.` },
{ idx: 18, type: 'mc', topic: 'trigonometry', subtopic: 'trig-equations', diff: 3,
text: R`Укажите (в градусах) наименьший положительный корень уравнения $\cos(6x-72^\circ)=\dfrac{\sqrt3}{2}$.`,
opts: mc('$5^\circ$', '$102^\circ$', '$17^\circ$', '$42^\circ$', '$7^\circ$'),
answer: 'д',
sol: R`$6x-72^\circ=\pm30^\circ+360^\circ k$, поэтому $x=17^\circ+60^\circ k$ или $x=7^\circ+60^\circ k$. Наименьший положительный корень $7^\circ$.` },
// ── Часть B: В1–В12 ──────────────────────────────────────────────────────
{ idx: 19, type: 'long', topic: 'functions', subtopic: 'fn-graphs', diff: 3,
text: R`Для начала каждого из предложений А–В подберите его окончание $1$$6$.<br>А) Окружность с центром $(-8;-2)$ и радиусом $4$ задаётся уравнением …<br>Б) Уравнение прямой, проходящей через точку $(-8;2)$ параллельно прямой $y=\tfrac14 x$, имеет вид …<br>В) График обратной пропорциональности, проходящий через точку $\left(\tfrac12;-\tfrac12\right)$, задаётся уравнением …<br>Окончания: $1)\ xy=2$; $\ 2)\ (x-8)^{2}+(y-2)^{2}=4$; $\ 3)\ -\tfrac14 x+y=4$; $\ 4)\ (x+8)^{2}+(y+2)^{2}=16$; $\ 5)\ 4xy+1=0$; $\ 6)\ \tfrac14 x+y=2$.`,
answer: 'А4Б3В5',
ansShow: 'А4Б3В5',
sol: R`А) $(x+8)^{2}+(y+2)^{2}=16$ (окончание 4). Б) $y-2=\tfrac14(x+8)$, то есть $-\tfrac14 x+y=4$ (окончание 3). В) $y=\tfrac{k}{x}$ через $\left(\tfrac12;-\tfrac12\right)$ даёт $k=-\tfrac14$, то есть $xy=-\tfrac14$, $4xy+1=0$ (окончание 5). Ответ: А4Б3В5.` },
{ idx: 20, type: 'open', topic: 'word-sequences', subtopic: 'word-problems', diff: 3,
text: R`Конфеты в коробке упаковываются рядами, причём количество конфет в каждом ряду на $4$ больше количества рядов. Дизайн коробки изменили: добавили $2$ ряда, а в каждом ряду — по $1$ конфете. В результате количество конфет в коробке увеличилось на $25$. Сколько конфет упаковывалось в коробку первоначально?`,
answer: '45',
sol: R`Пусть рядов $r$, в ряду $r+4$. Тогда $(r+2)(r+5)-r(r+4)=3r+10=25$, $r=5$. Первоначально $5\cdot9=45$ конфет.` },
{ idx: 21, type: 'open', topic: 'expressions', subtopic: 'expr-polynomials', diff: 3,
text: R`Известно, что при $a$, равном $-2$ и $4$, значение выражения $4a^{3}+3a^{2}-ab+c$ равно нулю. Найдите значение выражения $b+c$.`,
answer: '-34',
sol: R`При $a=-2$: $-20+2b+c=0$; при $a=4$: $304-4b+c=0$. Вычитая, $6b=324$, $b=54$, тогда $c=-88$, и $b+c=-34$.` },
{ idx: 22, type: 'open', topic: 'equations', subtopic: 'eq-irrational', diff: 4,
text: R`Найдите произведение корней (корень, если он единственный) уравнения $x^{2}-5x-3=4\sqrt{x^{2}-5x+9}$.`,
answer: '-27',
sol: R`Пусть $u=x^{2}-5x$. Тогда $u-3=4\sqrt{u+9}$ ($u\ge3$); возведя в квадрат, $u^{2}-22u-135=0$, $u=27$. Из $x^{2}-5x-27=0$ произведение корней $-27$.` },
{ idx: 23, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 4,
text: R`В параллелограмме с острым углом $45^\circ$ точка пересечения диагоналей удалена от прямых, содержащих неравные стороны, на расстояния $\dfrac{7\sqrt2}{2}$ и $2$. Найдите площадь параллелограмма.`,
answer: '56',
sol: R`Расстояние от центра до стороны — половина высоты. Высоты $H_1=7\sqrt2$ и $H_2=4$. Из $H=l\sin45^\circ$: стороны $b=14$, $a=4\sqrt2$. Площадь $=b\cdot H_2=14\cdot4=56$.` },
{ idx: 24, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
text: R`Пусть $x_0$ — наибольший корень уравнения $\log_2^{2}\dfrac{x}{32}+4\log_2 x-52=0$. Найдите значение выражения $7\sqrt[3]{x_0}$.`,
answer: '56',
sol: R`Пусть $t=\log_2 x$. Тогда $(t-5)^{2}+4t-52=0$, $t^{2}-6t-27=0$, $t=9$ или $t=-3$. Наибольший корень $x_0=2^{9}=512$, и $7\sqrt[3]{512}=7\cdot8=56$.` },
{ idx: 25, type: 'open', topic: 'equations', subtopic: 'eq-exponential', diff: 4,
text: R`Решите неравенство $\left(\dfrac{1}{5-\sqrt{24}}\right)^{x+6}\ge\left(5-\sqrt{24}\right)^{\frac{4x+25}{x+4}}$. В ответ запишите сумму целых решений, принадлежащих промежутку $[-20;-2]$.`,
answer: '-12',
sol: R`Так как $\dfrac{1}{5-\sqrt{24}}=(5-\sqrt{24})^{-1}$ и $0<5-\sqrt{24}<1$, неравенство равносильно $-(x+6)\le\dfrac{4x+25}{x+4}$, что приводит к $\dfrac{(x+7)^{2}}{x+4}\ge0$. Решение: $x>-4$ или $x=-7$. На $[-20;-2]$ целые $-7,-3,-2$; их сумма $-12$.` },
{ idx: 26, type: 'open', topic: 'functions', subtopic: 'fn-properties', diff: 4,
text: R`Найдите увеличенное в $9$ раз произведение абсцисс точек пересечения прямой $y=12$ и графика нечётной функции, которая определена на $(-\infty;0)\cup(0;+\infty)$ и при $x>0$ задаётся формулой $y=2^{3x-8}-20$.`,
answer: '-143',
sol: R`При $x>0$: $2^{3x-8}-20=12$, $2^{3x-8}=32$, $x=\tfrac{13}{3}$. По нечётности при $x<0$ получаем $x=-\tfrac{11}{3}$. Произведение $\tfrac{13}{3}\cdot\left(-\tfrac{11}{3}\right)=-\tfrac{143}{9}$; увеличенное в $9$ раз — $-143$.` },
{ idx: 27, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 4,
text: R`Найдите площадь полной поверхности прямой треугольной призмы, описанной около шара, если площадь основания призмы равна $7{,}5$.`,
answer: '45',
sol: R`У описанной около шара призмы высота $h=2r$, а в основании вписана окружность радиуса $r$, поэтому площадь основания $S=rp=7{,}5$. Боковая поверхность $=2p\cdot2r=4\cdot rp=30$. Полная $=2\cdot7{,}5+30=45$.` },
{ idx: 28, type: 'open', topic: 'equations', subtopic: 'eq-rational', diff: 4,
text: R`Найдите произведение наибольшего целого решения на количество целых решений неравенства $\dfrac{16}{6+|24-x|}>|24-x|$.`,
answer: '75',
sol: R`Пусть $u=|24-x|\ge0$. Тогда $16>u(6+u)$, $u^{2}+6u-16<0$, $0\le u<2$. Значит $|24-x|<2$, то есть $22<x<26$. Целые $23,24,25$ ($3$ решения); наибольшее $25$. Произведение $25\cdot3=75$.` },
{ idx: 29, type: 'open', topic: 'word-sequences', subtopic: 'seq-progressions', diff: 4,
text: R`Первые члены арифметической и геометрической прогрессий одинаковы и равны $1$, третьи члены также одинаковы, а вторые отличаются на $18$. Найдите шестой член арифметической прогрессии, если все члены обеих прогрессий положительны.`,
answer: '121',
sol: R`$1+2d=q^{2}$ и $\left|\tfrac{q^{2}+1}{2}-q\right|=\tfrac{(q-1)^{2}}{2}=18$, откуда $q=7$ (положительные члены), $d=24$. Тогда $a_6=1+5\cdot24=121$.` },
{ idx: 30, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
text: R`$ABCDA_1B_1C_1D_1$ — прямая четырёхугольная призма, объём которой равен $960$. Основанием призмы является параллелограмм $ABCD$. Точки $M$ и $N$ принадлежат рёбрам $A_1D_1$ и $C_1D_1$ так, что $A_1M:A_1D_1=1:2$, $D_1N:NC_1=1:3$. Отрезки $A_1N$ и $B_1M$ пересекаются в точке $K$. Найдите объём пирамиды $SB_1KNC_1$, если $S\in B_1D$ и $B_1S:SD=3:1$.`,
answer: '115',
sol: R`Координатным методом (положения $K$, $N$ на верхней грани и точки $S$ на диагонали $B_1D$) объём пирамиды составляет $\dfrac{23}{192}$ объёма призмы: $\dfrac{23}{192}\cdot960=115$.` },
];
/* ── Сборка solution_html ────────────────────────────────────────────────── */
function ansShowOf(t) {
if (t.ansShow != null) return t.ansShow;
if (t.type === 'mc') return `${t.answer})`;
return `$${t.answer}$`;
}
function buildSolution(t) {
const ans = ansShowOf(t);
let html = `${t.sol}<div class="sol-ans">Ответ: ${ans}</div>`;
if (t.ref) html += `<div class="sol-ref" style="margin-top:6px;font-size:.85em;opacity:.7">Учебник: ${t.ref}</div>`;
return html;
}
/* ── Самопроверка (повтор логики checkAnswerServer из exam-prep.js) ────────── */
const EPS = 1e-6;
function srvToNumber(s) {
if (s == null) return NaN;
let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.');
const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/);
if (f) { const n = Number(f[1]), d = Number(f[2]); return d === 0 ? NaN : n / d; }
const n = Number(t); return Number.isFinite(n) ? n : NaN;
}
function checkAnswerServer(userInput, canonical) {
if (userInput == null || canonical == null) return false;
const c = String(canonical).trim();
if (/^[а-д]$/.test(c)) return String(userInput).trim().toLowerCase() === c.toLowerCase();
if (/^[^;]+;[^;]+$/.test(c)) return false;
const cn = srvToNumber(c), un = srvToNumber(userInput);
if (Number.isNaN(cn) || Number.isNaN(un)) return false;
return Math.abs(cn - un) < EPS;
}
/* ── Валидация набора ──────────────────────────────────────────────────────── */
const problems = [];
if (TASKS.length !== N_TASKS) problems.push(`Ожидалось ${N_TASKS} заданий, получено ${TASKS.length}`);
const seen = new Set();
for (const t of TASKS) {
if (seen.has(t.idx)) problems.push(`Дубль task_idx=${t.idx}`); seen.add(t.idx);
if (t.idx < 1 || t.idx > N_TASKS) problems.push(`task_idx вне 1..${N_TASKS}: ${t.idx}`);
if (!['mc', 'open', 'long'].includes(t.type)) problems.push(`#${t.idx}: тип ${t.type}`);
if (t.type === 'mc') {
if (!Array.isArray(t.opts) || t.opts.length !== 5) problems.push(`#${t.idx}: mc должен иметь 5 вариантов`);
if (!t.opts.some(o => o[0] === t.answer)) problems.push(`#${t.idx}: answer "${t.answer}" не среди меток`);
}
if (!t.text || !t.sol) problems.push(`#${t.idx}: пустой text/sol`);
if (t.type !== 'long' && !checkAnswerServer(t.answer, t.answer))
problems.push(`#${t.idx}: answer "${t.answer}" не проходит self-check (Unicode-минус? пробел?)`);
if (//.test(String(t.answer))) problems.push(`#${t.idx}: Unicode-минус в answer`);
}
/* ── Экспорт для тестов/тиража (без запуска main при require) ──────────────── */
module.exports = { TASKS, buildSolution, ansShowOf, checkAnswerServer, EXAM, VARIANT, PROV };
if (require.main !== module) return;
/* ── Открытие БД ───────────────────────────────────────────────────────────── */
const DB = path.join(__dirname, '..', 'data', 'learnspace.db');
const db = new DatabaseSync(DB);
const track = db.prepare(`SELECT exam_key, variants_count FROM exam_tracks WHERE exam_key=?`).get(EXAM);
if (!track) { console.error(`✗ Трек '${EXAM}' не найден в exam_tracks. Прерывание.`); process.exit(1); }
/* ── DRY-RUN сводка ────────────────────────────────────────────────────────── */
console.log(`\n=== seed_ctmath_ct2017_v1 (${PROV}) variant=${VARIANT} ===`);
console.log(`Режим: ${APPLY ? 'APPLY (запись)' : 'DRY-RUN (только проверка)'}\n`);
const byType = TASKS.reduce((a, t) => (a[t.type] = (a[t.type] || 0) + 1, a), {});
console.log('Типы:', JSON.stringify(byType), '\n');
console.log('idx | type | subtopic | d | answer');
console.log('----+------+-----------------------+---+----------');
for (const t of TASKS) {
console.log(`${String(t.idx).padStart(3)} | ${t.type.padEnd(4)} | ${String(t.subtopic).padEnd(21)} | ${t.diff} | ${String(t.answer)}`);
}
if (problems.length) {
console.error(`\n✗ ПРОБЛЕМЫ (${problems.length}):`);
problems.forEach(p => console.error(' - ' + p));
console.error('\nЗапись отменена из-за ошибок валидации.');
db.close();
process.exit(1);
}
console.log(`\n✓ Валидация и self-check ответов пройдены (${N_TASKS}/${N_TASKS}).`);
/* ── APPLY: upsert ─────────────────────────────────────────────────────────── */
if (!APPLY) {
console.log('\nDRY-RUN: ничего не записано. Для записи: node backend/scripts/seed_ctmath_ct2017_v1.js --apply\n');
db.close();
process.exit(0);
}
const upsert = db.prepare(`
INSERT INTO exam_tasks
(exam_key, variant, task_idx, task_type, text_html, figure_html,
opts_json, answer, solution_html, topic, subtopic, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(exam_key, variant, task_idx) DO UPDATE SET
task_type = excluded.task_type,
text_html = excluded.text_html,
figure_html = excluded.figure_html,
opts_json = excluded.opts_json,
answer = excluded.answer,
solution_html = excluded.solution_html,
topic = excluded.topic,
subtopic = excluded.subtopic,
difficulty = excluded.difficulty
`);
let n = 0;
db.exec('BEGIN');
try {
for (const t of TASKS) {
upsert.run(
EXAM, VARIANT, t.idx, t.type,
t.text,
t.fig || null,
t.type === 'mc' ? JSON.stringify(t.opts) : null,
t.answer,
buildSolution(t),
t.topic, t.subtopic, t.diff
);
n++;
}
const distinct = db.prepare(`SELECT COUNT(DISTINCT variant) c FROM exam_tasks WHERE exam_key=? AND variant BETWEEN 101 AND 1999`).get(EXAM).c;
db.prepare(`UPDATE exam_tracks SET variants_count=? WHERE exam_key=?`).run(distinct, EXAM);
db.exec('COMMIT');
console.log(`\n✓ Записано/обновлено ${n} заданий (variant=${VARIANT}).`);
console.log(`✓ exam_tracks.variants_count = ${distinct} (различных вариантов).`);
console.log(`\nПробник доступен: /exam-prep/ctmath → «Варианты» → «ЦТ-2017».\n`);
} catch (e) {
db.exec('ROLLBACK');
console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
process.exitCode = 1;
}
db.close();
+58
View File
@@ -0,0 +1,58 @@
'use strict';
/* Авто-здоровье LLM-провайдеров Квантика: периодический пинг каждого провайдера
* (lightweight pingLLM) + авто-понижение активного, если он стабильно не отвечает,
* а есть здоровый запасной. Результат — в app_settings.assistant_health (JSON-карта
* { id: { ok, at, error, ms, fails } }). Авто-переключение пишет тот же
* assistant_failover, что показывает баннер в админке. Период — 15 мин (вкл. по
* умолчанию; app_settings.assistant_health_enabled='0' выключает). */
const db = require('./db/db');
const logger = require('./utils/logger');
function _get(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key=?').get(k); return r && r.value != null ? r.value : null; }
function _set(k, v) { db.prepare('INSERT OR REPLACE INTO app_settings (key,value) VALUES (?,?)').run(k, v); }
function _providers() { try { return JSON.parse(_get('assistant_providers') || '[]') || []; } catch (e) { return []; } }
function _enabled() { return _get('assistant_health_enabled') !== '0'; }
function _noKey(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || '') || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); }
async function runHealthCheck() {
if (!_enabled()) return { skipped: true };
const provs = _providers();
if (!provs.length) return { providers: 0 };
const a = require('./controllers/assistantController');
let prev = {}; try { prev = JSON.parse(_get('assistant_health') || '{}') || {}; } catch (e) {}
const health = {};
for (const p of provs) {
// нет ключа и не keyless-шлюз — не пингуем (в FAQ-режиме), помечаем как «нет ключа»
if (!p.key && !_noKey(p.url)) { health[p.id] = { ok: false, at: new Date().toISOString(), error: 'нет ключа', ms: 0, fails: (prev[p.id] && prev[p.id].fails || 0) }; continue; }
const t0 = Date.now();
let r; try { r = await a.pingLLM({ url: p.url, model: p.model, key: p.key }); } catch (e) { r = { ok: false, error: 'сбой' }; }
const ok = !!(r && r.ok);
health[p.id] = {
ok, at: new Date().toISOString(), ms: Date.now() - t0,
error: ok ? null : String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 140),
fails: ok ? 0 : ((prev[p.id] && prev[p.id].fails || 0) + 1),
};
}
_set('assistant_health', JSON.stringify(health));
// авто-понижение активного: 2+ подряд неудач И есть здоровый рабочий запасной
const activeId = _get('assistant_active');
const active = provs.find(p => p.id === activeId);
if (active && health[activeId] && !health[activeId].ok && health[activeId].fails >= 2) {
const healthy = provs.find(p => p.id !== activeId && health[p.id] && health[p.id].ok && (p.key || _noKey(p.url)));
if (healthy) {
_set('assistant_active', healthy.id);
_set('assistant_failover', JSON.stringify({ at: new Date().toISOString(), failedId: activeId, failedName: active.name, servedId: healthy.id, servedName: healthy.name, reason: 'health', auto: true }));
logger.info('assistant-health auto-demote', { from: active.name, to: healthy.name, fails: health[activeId].fails });
}
}
return { providers: provs.length, health };
}
function schedule() {
const run = () => { runHealthCheck().catch(() => {}); };
setTimeout(run, 90_000).unref(); // первый прогон через 1.5 мин после старта
setInterval(run, 15 * 60 * 1000).unref(); // далее каждые 15 минут
}
module.exports = { runHealthCheck, schedule };
+286 -21
View File
@@ -1,7 +1,10 @@
const db = require('../db/db'); const db = require('../db/db');
const fs = require('fs');
const path = require('path');
const { stripTags } = require('../utils/sanitize'); const { stripTags } = require('../utils/sanitize');
const { audit } = require('../utils/audit'); const { audit } = require('../utils/audit');
const { purgeAccessFor } = require('../services/contentAccess'); const { purgeAccessFor } = require('../services/contentAccess');
const sysReset = require('../services/systemReset');
/* ── Prepared statements ──────────────────────────────────────────────── */ /* ── Prepared statements ──────────────────────────────────────────────── */
const stmts = { const stmts = {
@@ -292,13 +295,18 @@ function getUserSessions(req, res) {
/* ── GET /api/admin/sessions ─────────────────────────────────────────── */ /* ── GET /api/admin/sessions ─────────────────────────────────────────── */
function getAllSessions(req, res) { function getAllSessions(req, res) {
const { subject, user_id } = req.query; const { subject, user_id, status } = req.query;
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200)); const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200));
const offset = Math.max(0, Number(req.query.offset) || 0); const offset = Math.max(0, Number(req.query.offset) || 0);
const where = ['ts.status = \'completed\'']; // По умолчанию показываем и завершённые, и НЕзавершённые (in_progress) — иначе зависшие
// сессии не находились в списке (см. алерт «Зависла»). Опционально сужаем по ?status=.
const where = [];
const params = []; const params = [];
if (status && ['completed', 'in_progress', 'abandoned'].includes(status)) {
where.push('ts.status = ?'); params.push(status);
}
if (subject) { where.push('s.slug = ?'); params.push(subject); } if (subject) { where.push('s.slug = ?'); params.push(subject); }
if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); } if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); }
@@ -314,7 +322,7 @@ function getAllSessions(req, res) {
FROM test_sessions ts FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id LEFT JOIN subjects s ON s.id = ts.subject_id
JOIN users u ON u.id = ts.user_id JOIN users u ON u.id = ts.user_id
WHERE ${where.join(' AND ')} ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY ts.started_at DESC ORDER BY ts.started_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`).all(...params); `).all(...params);
@@ -525,7 +533,7 @@ function getFeatures(_req, res) {
function updateFeatures(req, res) { function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection', const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom', 'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
'gamification', 'assistant', 'sim_builder', 'quantik']; 'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap', 'wishes'];
const updates = req.body; const updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"); const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?"); const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
@@ -548,13 +556,14 @@ function updateFeatures(req, res) {
invalidateGamificationCache(); invalidateGamificationCache();
} catch { /* defensive — shouldn't fail */ } } catch { /* defensive — shouldn't fail */ }
} }
_autoReindexSystem(); // снимок «знаний о системе» подстраивается под новые флаги (если уже индексировали)
res.json({ ok: true }); res.json({ ok: true });
} }
/* ── GET /api/admin/free-student-features ────────────────────────────── */ /* ── GET /api/admin/free-student-features ────────────────────────────── */
const FREE_STUDENT_MODULES = [ const FREE_STUDENT_MODULES = [
'gamification', 'hangman', 'crossword', 'pet', 'red_book', 'collection', 'gamification', 'hangman', 'crossword', 'pet', 'red_book', 'collection',
'lab', 'knowledge_map', 'flashcards', 'board', 'biochem', 'live_quiz', 'lab', 'quantik', 'knowledge_map', 'flashcards', 'imggen', 'board', 'biochem', 'live_quiz',
]; ];
function getFreeStudentFeatures(_req, res) { function getFreeStudentFeatures(_req, res) {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get(); const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get();
@@ -586,6 +595,56 @@ function updateFreeStudentFeatures(req, res) {
res.json({ ok: true }); res.json({ ok: true });
} }
/* ── GET /api/admin/reset-system/plan ──────────────────────────────────
План «чистого запуска»: что переназначится / сотрётся / неизвестно. Без изменений. */
function getResetPlan(req, res) {
try {
const plan = sysReset.classify(db);
// Текущий админ остаётся залогиненным — сохраняем именно его, не min-id.
res.json({ ...plan, keptAdmin: { id: req.user.id, email: req.user.email, name: req.user.name } });
} catch (e) {
res.status(500).json({ error: 'Не удалось построить план: ' + e.message });
}
}
/* ── POST /api/admin/reset-system ──────────────────────────────────────
⚠️ ДЕСТРУКТИВНО. Только admin. Требует body.confirm === 'СБРОС' (или 'RESET').
Делает бэкап БД, сохраняет ТЕКУЩЕГО админа (оператор остаётся в системе),
стирает остальных пользователей + активность, переназначает контент. */
function resetSystem(req, res) {
const confirm = (req.body && req.body.confirm) || '';
if (confirm !== 'СБРОС' && confirm !== 'RESET') {
return res.status(400).json({ error: 'Подтверждение не совпало. Введите СБРОС.' });
}
const keptId = req.user.id;
// 1) Бэкап ДО любых изменений (checkpoint WAL → копия основного файла).
let backupName = null;
try {
const dbPath = db._path;
if (!dbPath) throw new Error('путь к БД неизвестен');
const backupsDir = path.join(path.dirname(dbPath), 'backups');
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* не WAL — ок */ }
const d = new Date();
const p2 = n => String(n).padStart(2, '0');
const ts = `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`;
backupName = `learnspace-prereset-${ts}.db`;
fs.copyFileSync(dbPath, path.join(backupsDir, backupName));
} catch (e) {
return res.status(500).json({ error: 'Бэкап не удался — сброс отменён: ' + e.message });
}
// 2) Сброс (бросает при ошибке → откат внутри сервиса, данные целы).
let summary;
try {
summary = sysReset.runReset(db, keptId);
} catch (e) {
return res.status(500).json({ error: 'Сброс не выполнен (откат): ' + e.message, backup: backupName });
}
// 3) Аудит ПОСЛЕ сброса (admin_audit_log очищается сбросом — пишем первой записью).
try { audit(req, 'system.reset', 'system', `keptAdmin=${keptId} backup=${backupName} deleted=${summary.deletedUsers}`); } catch {}
res.json({ ok: true, backup: backupName, ...summary });
}
/* ── GET /api/admin/audit-log ───────────────────────────────────────── */ /* ── GET /api/admin/audit-log ───────────────────────────────────────── */
function getAuditLog(req, res) { function getAuditLog(req, res) {
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100)); const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
@@ -659,8 +718,6 @@ function clearSecurityLog(req, res) {
/* ── GET /api/admin/health ─────────────────────────────────────────── */ /* ── GET /api/admin/health ─────────────────────────────────────────── */
const os = require('os'); const os = require('os');
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const { monitorEventLoopDelay } = require('perf_hooks'); const { monitorEventLoopDelay } = require('perf_hooks');
const sse = require('../sse'); const sse = require('../sse');
@@ -879,29 +936,126 @@ function broadcast(req, res) {
/* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */ /* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */
const ASSISTANT_PRESETS = [ const ASSISTANT_PRESETS = [
{ name: 'Kilo Code (бесплатно)', url: 'https://kilocode.ai/api/openrouter/chat/completions', model: 'nvidia/nemotron-3-ultra-550b-a55b:free' }, { name: 'Kilo Code (бесплатно)', url: 'https://kilocode.ai/api/openrouter/chat/completions', model: 'nvidia/nemotron-3-super-120b-a12b:free' },
{ name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' }, { name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' },
{ name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' }, { name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' },
{ name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' }, { name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' },
{ name: 'HuggingFace Router', url: 'https://router.huggingface.co/v1/chat/completions', model: 'Qwen/Qwen2.5-72B-Instruct' },
{ name: 'Pollinations (без ключа)', url: 'https://text.pollinations.ai/openai', model: 'openai' },
{ name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' }, { name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' },
]; ];
// Проверенные бесплатные модели Kilo (чистый русский) — для выпадающего списка
// Проверенные бесплатные модели шлюза Kilo (отдают чистый русский). Порядок — от мощных к лёгким. // Проверенные бесплатные модели шлюза Kilo (отдают чистый русский). Порядок — от мощных к лёгким.
// ctx — окно контекста, out — макс. токенов в ответе (данные из /api/openrouter/models). Все бесплатные ($0). // ctx — окно контекста, out — макс. токенов в ответе (данные из /api/openrouter/models). Все бесплатные ($0).
// Сверено с live-списком шлюза и протестировано на русский 2026-06-24 (% — доля кириллицы в тест-ответе):
// owl-alpha 95%, nemotron-super 91%, nano-omni 99%, laguna-xs.2 92%, openrouter/free 100% — чисто;
// nemotron-ultra/laguna-m.1 — существуют, но на free-тарифе бывает таймаут; nex-n2-pro удалён со шлюза.
const KILO_MODELS = [ const KILO_MODELS = [
{ id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B — флагман (1M)', ctx: 1000000, out: 65536 }, { id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 120B — баланс, быстрый (262K)', ctx: 262144, out: 262144 },
{ id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 120B — баланс (1M)', ctx: 1000000, out: 262144 }, { id: 'openrouter/owl-alpha', label: 'Owl Alpha — чистый русский (1M)', ctx: 1048576, out: 262144 },
{ id: 'nex-agi/nex-n2-pro:free', label: 'Nex N2 Pro — чистый русский (262K)', ctx: 262144, out: 65536 }, { id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B — флагман, медленный (1M)', ctx: 1000000, out: 65536 },
{ id: 'openrouter/owl-alpha', label: 'Owl Alphaчистый русский (1M)', ctx: 1048756, out: 262144 }, { id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', label: 'Nemotron Nano 30Bбыстрый, мультимодальный (256K)', ctx: 256000, out: 65536 },
{ id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', label: 'Nemotron Nano 30B — быстрая (256K)', ctx: 256000, out: 65536 }, { id: 'poolside/laguna-m.1:free', label: 'Laguna M.1 (262K)', ctx: 262144, out: 32768 },
{ id: 'poolside/laguna-m.1:free', label: 'Laguna M.1 — быстрая (262K)', ctx: 262144, out: 32768 }, { id: 'poolside/laguna-xs.2:free', label: 'Laguna XS.2 — лёгкая, быстрая (262K)', ctx: 262144, out: 32768 },
{ id: 'poolside/laguna-xs.2:free', label: 'Laguna XS — лёгкая (262K)', ctx: 262144, out: 32768 }, { id: 'openrouter/free', label: 'Авто-роутер (бесплатные модели)', ctx: 200000, out: 32768 },
]; ];
function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; }
// Рабочий список бесплатных моделей: обновлённый сканом (app_settings) либо хардкод KILO_MODELS как сид.
function _kiloModels() {
try { const r = _aset('assistant_kilo_models'); if (r) { const a = JSON.parse(r); if (Array.isArray(a) && a.length) return a; } } catch (e) {}
return KILO_MODELS;
}
function _aProviders() { try { return JSON.parse(_aset('assistant_providers') || '[]') || []; } catch (e) { return []; } } function _aProviders() { try { return JSON.parse(_aset('assistant_providers') || '[]') || []; } catch (e) { return []; } }
function _aSetProviders(arr) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_providers', ?)").run(JSON.stringify(arr)); } function _aSetProviders(arr) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_providers', ?)").run(JSON.stringify(arr)); }
function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); } function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); }
// Шлюзы с бесплатным инференсом без ключа (как localhost): ключ не обязателен.
function _aNoKey(u) { return _aIsLocal(u) || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); }
/* ── Индексация системы: снимок модулей/флагов + описание → знания Квантика ── */
// key — имя фича-флага (app_settings.feature_<key>_enabled, по умолч. ВКЛ); null = всегда доступно.
const MODULE_CATALOG = [
{ key: 'textbooks', name: 'Учебники', url: '/textbooks', desc: 'Главы и параграфы с теорией, формулами и задачами; прогресс чтения.' },
{ key: 'exam9', name: 'Подготовка к экзамену', url: '/exam-prep', desc: 'Тесты по темам, режимы экзамена/тренировки/случайный, разбор ошибок (ЦТ/ЦЭ).' },
{ key: 'flashcards', name: 'Флешкарты', url: '/flashcards', desc: 'Карточки с интервальным повторением, картинки и формулы KaTeX, общие колоды.' },
{ key: 'theory', name: 'Теория', url: '/theory', desc: 'Курсы и уроки с теорией и заданиями; быстрый одиночный урок.' },
{ key: 'lab', name: 'Лаборатория', url: '/lab', desc: 'Интерактивные 2D-симуляции по физике/математике/химии прямо в браузере.' },
{ key: 'board', name: 'Доска', url: '/board', desc: 'Интерактивная доска: рисование, фигуры, формулы, линейка/транспортир.' },
{ key: 'classroom', name: 'Онлайн-урок', url: '/classroom', desc: 'Живой урок с доской, чатом и видео; заметки сохраняются в «Мои материалы».' },
{ key: null, name: 'Мои материалы', url: '/my-materials', desc: 'Личное хранилище: вырезки учебника, страницы доски, заметки (с папками и тегами).' },
{ key: null, name: 'Домашние задания', url: '/homework', desc: 'Задания, дедлайны, загрузка выполненной работы.' },
{ key: 'pet', name: 'Питомец Квантик', url: '/pet', desc: 'Виртуальный питомец: растёт от активности, XP/монеты/серии.' },
{ key: 'gamification', name: 'Геймификация', url: '/profile', desc: 'XP, уровни, монеты, достижения, стрики, магазин, лидерборд.' },
{ key: 'collection', name: 'Коллекция', url: '/collection', desc: 'Коллекционирование предметов/карточек.' },
{ key: 'knowledge_map', name: 'Карта знаний', url: '/knowledge-map', desc: 'Граф знаний по темам.' },
{ key: 'red_book', name: 'Красная книга', url: '/red-book', desc: 'Виды, биомы, экосистемы и мини-игры.' },
{ key: 'biochem', name: 'Биохимия', url: '/biochem', desc: 'Интерактивные молекулы, реакции, пути.' },
{ key: 'crossword', name: 'Кроссворд', url: '/crossword', desc: 'Игра-кроссворд по терминам предметов; закрепляет понятия, даёт XP.' },
{ key: 'hangman', name: 'Виселица', url: '/hangman', desc: 'Игра «Виселица» по терминам предметов; закрепляет слова, даёт XP.' },
{ key: 'quantik', name: 'Квантик: Законы Мира', url: '/quantik', desc: 'Физическая игра-головоломка: уровни на 2D-механике, звёзды и прогресс.' },
{ key: 'trainer', name: 'Тренажёр', url: '/trainer', desc: 'ИИ-тренажёр: бесконечные сгенерированные задачи по темам с мгновенной проверкой ответа и прогрессом по навыкам.' },
{ key: 'live_quiz', name: 'Live-викторина', url: '/live-quiz', desc: 'Викторина в реальном времени: учитель запускает, ученики отвечают одновременно.' },
{ key: 'sitemap', name: 'Путеводитель', url: '/sitemap', desc: 'Карта-обзор всех разделов платформы со ссылками.' },
{ key: 'sim_builder', name: 'Конструктор симуляций', url: '/sim-builder', desc: 'Авторинг 2D-симуляций (учитель/админ).' },
{ key: null, name: 'Поиск', url: null, desc: 'Глобальный поиск по платформе (Ctrl+K): уроки, курсы, файлы, вопросы.' },
];
function _featFlags() {
const map = {};
try { db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'").all()
.forEach(r => { map[r.key.replace('feature_', '').replace('_enabled', '')] = (r.value !== '0' && r.value !== 'false'); }); } catch (e) {}
return map;
}
// Собрать фрагменты знаний о системе: статус модулей + сводка + описание админа.
function buildSystemKb() {
const flags = _featFlags();
const en = (k) => (k ? (flags[k] !== false) : true); // нет флага → доступно по умолчанию
const chunks = [];
const on = [], off = [];
MODULE_CATALOG.forEach(m => {
const e = en(m.key);
(e ? on : off).push(m.name);
chunks.push({ title: m.name, text: `Модуль «${m.name}» — ${e ? 'ВКЛЮЧЁН и доступен' : 'ВЫКЛЮЧЕН (не предлагать ученику)'}. ${m.desc}${m.url ? ' Раздел: ' + m.url + '.' : ''}` });
});
chunks.push({ title: 'Доступные модули', text: `Сейчас на платформе ВКЛЮЧЕНЫ разделы: ${on.join(', ')}.` + (off.length ? ` ВЫКЛЮЧЕНЫ (о них не рассказывать и не предлагать): ${off.join(', ')}.` : '') });
// авто-подхват НОВЫХ модулей: любой фича-флаг, которого нет в каталоге (assistant — это сам помощник, пропускаем)
const known = new Set(MODULE_CATALOG.map(m => m.key).filter(Boolean));
Object.keys(flags).forEach(k => {
if (known.has(k) || k === 'assistant') return;
chunks.push({ title: 'Функция: ' + k, text: `Функция платформы «${k}» — ${flags[k] !== false ? 'ВКЛЮЧЕНА' : 'ВЫКЛЮЧЕНА (не предлагать)'}.` });
});
const doc = _aset('assistant_system_doc');
if (doc && doc.trim()) doc.split(/\n{2,}/).map(s => s.trim()).filter(Boolean).forEach(p => chunks.push({ title: 'Описание системы', text: p.slice(0, 1500) }));
return chunks;
}
// Тихая авто-переиндексация (вызывается при смене фича-флагов). Не создаёт KB,
// если админ ещё ни разу не индексировал — только обновляет существующий снимок.
function _autoReindexSystem() {
try {
if (!_aset('assistant_system_kb')) return;
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_system_kb', ?)").run(JSON.stringify(buildSystemKb()));
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_system_kb_at', ?)").run(new Date().toISOString());
} catch (e) {}
}
// Модули с фича-флагом, но без записи в каталоге (нет «красивого» описания) — подсказка админу.
function _undocumentedModules() {
try { const known = new Set(MODULE_CATALOG.map(m => m.key).filter(Boolean)); return Object.keys(_featFlags()).filter(k => !known.has(k) && k !== 'assistant'); }
catch (e) { return []; }
}
/* POST /api/admin/assistant/index-system — пересобрать знания о системе */
function indexSystem(req, res) {
try {
const chunks = buildSystemKb();
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_system_kb', ?)").run(JSON.stringify(chunks));
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_system_kb_at', ?)").run(new Date().toISOString());
audit(req, 'assistant.index_system', 'assistant', chunks.length + ' фрагментов');
res.json({ ok: true, count: chunks.length });
} catch (e) { res.status(500).json({ error: e.message || 'ошибка индексации' }); }
}
function getAssistant(_req, res) { function getAssistant(_req, res) {
// Миграция legacy-настроек в список провайдеров (один раз) // Миграция legacy-настроек в список провайдеров (один раз)
@@ -915,10 +1069,10 @@ function getAssistant(_req, res) {
} }
} }
const list = _aProviders(); const list = _aProviders();
const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) })); const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, noKey: _aNoKey(p.url), ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) }));
const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null; const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null;
const ap = list.find(p => p.id === activeId); const ap = list.find(p => p.id === activeId);
const active = !!(ap && (ap.key || _aIsLocal(ap.url))); const active = !!(ap && (ap.key || _aNoKey(ap.url)));
let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 }; let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 };
try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {} try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {}
@@ -939,8 +1093,15 @@ function getAssistant(_req, res) {
res.json({ res.json({
providers, activeId, active, providers, activeId, active,
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1', rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
memory: _aset('assistant_memory') !== '0', memory: _aset('assistant_memory') !== '0', socratic: _aset('assistant_socratic') === '1',
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: KILO_MODELS, healthEnabled: _aset('assistant_health_enabled') !== '0',
health: (() => { try { return JSON.parse(_aset('assistant_health') || '{}') || {}; } catch (e) { return {}; } })(),
systemDoc: _aset('assistant_system_doc') || '',
systemKbCount: (() => { try { return (JSON.parse(_aset('assistant_system_kb') || '[]') || []).length; } catch (e) { return 0; } })(),
systemKbAt: _aset('assistant_system_kb_at') || null,
systemUndoc: _undocumentedModules(),
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS,
kiloModels: _kiloModels(), kiloModelsCustom: !!_aset('assistant_kilo_models'),
}); });
} }
@@ -951,6 +1112,9 @@ function saveAssistant(req, res) {
if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0'); if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0');
if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0'); if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0');
if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0'); if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0');
if (typeof b.socratic === 'boolean') set('assistant_socratic', b.socratic ? '1' : '0');
if (typeof b.healthEnabled === 'boolean') set('assistant_health_enabled', b.healthEnabled ? '1' : '0');
if (typeof b.systemDoc === 'string') set('assistant_system_doc', b.systemDoc.slice(0, 8000));
if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} } if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
audit(req, 'assistant.config', 'assistant', 'настройки'); audit(req, 'assistant.config', 'assistant', 'настройки');
res.json({ ok: true }); res.json({ ok: true });
@@ -1050,6 +1214,105 @@ async function getProviderModels(req, res) {
res.json({ models: r.models, current }); res.json({ models: r.models, current });
} }
/* ── Сканер бесплатных моделей шлюза (наполняет список KILO_MODELS) ───────── */
// Заведомо не-чат модели (музыка/картинки/эмбеддинги/модерация) — не тестируем.
const _NONCHAT_RE = /(lyria|whisper|tts|embed|rerank|moderation|content-safety|guard|dall-?e|imagen|sora|veo|\bmusic\b)/i;
// Kilo-провайдер (со шлюзом kilocode.ai): по id, иначе активный, иначе первый с ключом.
function _pickKiloProvider(id) {
const arr = _aProviders();
if (id) return arr.find(p => p.id === id) || null;
const active = arr.find(p => p.id === _aset('assistant_active'));
if (active && /kilocode\.ai/.test(active.url || '') && active.key) return active;
return arr.find(p => /kilocode\.ai/.test(p.url || '') && p.key)
|| arr.find(p => /kilocode\.ai/.test(p.url || '')) || null;
}
/* POST /api/admin/assistant/scan { id? } — найти бесплатные модели на шлюзе провайдера.
* Без инференса: список + сверка с текущим рабочим списком (что новое / что исчезло). */
async function scanModels(req, res) {
const prov = _pickKiloProvider(req.body && req.body.id);
if (!prov) return res.json({ error: 'Нет Kilo-провайдера. Добавьте провайдера со шлюзом kilocode.ai с ключом.' });
const r = await _fetchModels(prov.url, prov.key);
if (r.error) return res.json({ error: r.error, status: r.status });
const cur = _kiloModels();
const curIds = new Set(cur.map(m => m.id));
const liveIds = new Set(r.models.map(m => m.id));
const free = r.models
.filter(m => (m.free === true || /:free$/.test(m.id)) && !_NONCHAT_RE.test(m.id))
.map(m => ({ id: m.id, ctx: m.ctx, out: m.out, status: curIds.has(m.id) ? 'current' : 'new' }));
free.sort((a, b) => (a.status === b.status ? (b.ctx || 0) - (a.ctx || 0) : a.status === 'current' ? -1 : 1));
const gone = cur.filter(m => !liveIds.has(m.id)).map(m => ({ id: m.id, label: m.label }));
res.json({ providerId: prov.id, providerName: prov.name, total: r.models.length, models: free, gone, current: cur });
}
/* POST /api/admin/assistant/probe { id?, model } — один тест-запрос на русском. */
async function probeModel(req, res) {
const b = req.body || {};
const prov = _pickKiloProvider(b.id);
if (!prov) return res.json({ ok: false, error: 'нет провайдера' });
const model = String(b.model || '').trim().slice(0, 120);
if (!model) return res.json({ ok: false, error: 'нет модели' });
if (typeof fetch !== 'function') return res.json({ ok: false, error: 'fetch недоступен' });
const PROMPT = 'Ученик 9 класса спрашивает: что такое синус острого угла в прямоугольном треугольнике? Объясни кратко и понятно. Отвечай только на русском языке.';
const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 22000);
const t0 = Date.now();
try {
const r = await fetch(prov.url, {
method: 'POST', signal: ctrl.signal,
headers: Object.assign({ 'Content-Type': 'application/json' }, prov.key ? { Authorization: 'Bearer ' + prov.key } : {}),
body: JSON.stringify({ model, max_tokens: 160, temperature: 0.3, messages: [{ role: 'user', content: PROMPT }] }),
});
const ms = Date.now() - t0;
const txt = await r.text();
if (!r.ok) {
let msg = txt.slice(0, 200);
try { const j = JSON.parse(txt); if (j && j.error) msg = String(j.error.message || JSON.stringify(j.error)).slice(0, 200); } catch (e) {}
return res.json({ ok: false, status: r.status, ms, error: msg });
}
let sample = '';
try { const j = JSON.parse(txt); const m = j.choices && j.choices[0] && j.choices[0].message; sample = String((m && (m.content || m.reasoning)) || '').trim(); } catch (e) {}
const letters = (sample.match(/[A-Za-zА-Яа-яЁё一-鿿]/g) || []).length;
const cyr = (sample.match(/[А-Яа-яЁё]/g) || []).length;
const cjk = (sample.match(/[一-鿿]/g) || []).length;
const ratio = letters ? cyr / letters : 0;
const verdict = !sample ? 'пусто' : cjk > 0 ? 'иероглифы' : ratio > 0.55 ? 'чистый русский' : ratio > 0.2 ? 'смешанный' : 'не русский';
res.json({ ok: true, ms, ratio: Math.round(ratio * 100), cjk, verdict, sample: sample.replace(/\s+/g, ' ').slice(0, 180) });
} catch (e) { res.json({ ok: false, ms: Date.now() - t0, error: e.name === 'AbortError' ? 'таймаут' : 'сеть' }); }
finally { clearTimeout(timer); }
}
/* POST /api/admin/assistant/models/apply { models:[{id,label,ctx,out}] | reset:true } */
function applyModels(req, res) {
const b = req.body || {};
if (b.reset) {
try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_kilo_models'").run(); } catch (e) {}
audit(req, 'assistant.models', 'kilo', 'сброс к встроенному');
return res.json({ ok: true, reset: true });
}
const arr = Array.isArray(b.models) ? b.models : null;
if (!arr) return res.status(400).json({ error: 'models[] обязателен' });
const clean = [];
for (const m of arr.slice(0, 40)) {
const id = String((m && m.id) || '').trim().slice(0, 120);
if (!id) continue;
clean.push({ id, label: String((m && m.label) || id).trim().slice(0, 80), ctx: Number(m && m.ctx) || null, out: Number(m && m.out) || null });
}
if (!clean.length) return res.status(400).json({ error: 'пустой список' });
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_kilo_models', ?)").run(JSON.stringify(clean));
audit(req, 'assistant.models', 'kilo', clean.length + ' моделей');
res.json({ ok: true, count: clean.length });
}
/* POST /api/admin/assistant/health — прогнать проверку здоровья провайдеров сейчас */
async function runHealth(req, res) {
try {
const r = await require('../assistant-health').runHealthCheck();
audit(req, 'assistant.health', 'assistant', 'ручная проверка');
res.json({ ok: true, result: r });
} catch (e) { res.status(500).json({ ok: false, error: e.message || 'ошибка' }); }
}
/* POST /api/admin/assistant/active { id } — выбрать активного провайдера */ /* POST /api/admin/assistant/active { id } — выбрать активного провайдера */
function setActiveProvider(req, res) { function setActiveProvider(req, res) {
const id = String((req.body && req.body.id) || ''); const id = String((req.body && req.body.id) || '');
@@ -1087,7 +1350,7 @@ async function testAssistant(req, res) {
}; };
} }
override.local = _aIsLocal(override.url); override.local = _aIsLocal(override.url);
override.on = !!(override.key || override.local); override.on = !!(override.key || _aNoKey(override.url));
const r = await a.pingLLM(override); const r = await a.pingLLM(override);
// Успешный тест активного провайдера снимает устаревший флаг failover // Успешный тест активного провайдера снимает устаревший флаг failover
try { const activeId = _aset('assistant_active'); if (r && r.ok && (!b.id || b.id === activeId)) a.clearFailover(); } catch (e) {} try { const activeId = _aset('assistant_active'); if (r && r.ok && (!b.id || b.id === activeId)) a.clearFailover(); } catch (e) {}
@@ -1157,9 +1420,11 @@ module.exports = {
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
clearUserSessions, deleteSession, updateUser, banUser, deleteUser, clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures, getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
getResetPlan, resetSystem,
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics, getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
getSecurityLog, clearSecurityLog, getSecurityLog, clearSecurityLog,
getTopics, createTopic, updateTopic, deleteTopic, getTopics, createTopic, updateTopic, deleteTopic,
broadcast, broadcast,
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels, getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels,
scanModels, probeModel, applyModels, runHealth, indexSystem,
}; };
@@ -2,6 +2,7 @@ const db = require('../db/db');
const { pushNotif } = require('../utils/notifications'); const { pushNotif } = require('../utils/notifications');
const { stripTags } = require('../utils/sanitize'); const { stripTags } = require('../utils/sanitize');
const { SESSION_MODES } = require('../constants'); const { SESSION_MODES } = require('../constants');
const AssignmentUtils = require('../../../frontend/js/assignment-utils.js'); // единый источник: тип/«сдано»
const VALID_ASSIGN_MODES = SESSION_MODES; const VALID_ASSIGN_MODES = SESSION_MODES;
@@ -256,9 +257,9 @@ function teacherAssignments(req, res) {
res.json(rows); res.json(rows);
} }
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */ /* Собрать все задания пользователя (классовые + личные) с вычисленным статусом.
function myAssignments(req, res) { Переиспользуется в /assignments/my и в обзоре задолженностей класса. */
const uid = req.user.id; function assignmentRowsForUser(uid) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT * FROM ( SELECT * FROM (
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at, SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
@@ -267,6 +268,7 @@ function myAssignments(req, res) {
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count, tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
tp.paragraphs_read AS textbook_read, tp.paragraphs_read AS textbook_read,
c.name AS class_name, c.id AS class_id, u.name AS teacher_name, c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
a.created_by AS created_by,
latest.session_id, latest.session_id,
ts.score, ts.total, ts.status AS session_status, ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent, ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
@@ -295,6 +297,7 @@ function myAssignments(req, res) {
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count, tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
tp.paragraphs_read AS textbook_read, tp.paragraphs_read AS textbook_read,
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name, 'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
a.created_by AS created_by,
latest.session_id, latest.session_id,
ts.score, ts.total, ts.status AS session_status, ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent, ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
@@ -334,8 +337,78 @@ function myAssignments(req, res) {
// Strip raw paragraphs_read JSON from response (not needed by client) // Strip raw paragraphs_read JSON from response (not needed by client)
delete r.textbook_read; delete r.textbook_read;
} }
return rows;
}
res.json(rows); /* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
function myAssignments(req, res) {
res.json(assignmentRowsForUser(req.user.id));
}
/* ── GET /api/classes/:id/outstanding ── что «висит» у каждого ученика класса ──
Учитель/админ видят по каждому ученику его НЕзакрытые задания (классовые + личные
от этого учителя) со статусом: не начато / в процессе / на доработке / просрочено. */
function classOutstanding(req, res) {
const cid = req.params.id;
const cls = db.prepare('SELECT id, name, teacher_id FROM classes WHERE id = ?').get(cid);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const members = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
`).all(cid);
// Последняя сдача по (задание, ученик) в этом классе — для upload/file done-статуса.
const subRows = db.prepare(`
SELECT s.assignment_id, s.student_id, s.status
FROM submissions s
JOIN (SELECT assignment_id, student_id, MAX(id) AS mid FROM submissions
WHERE class_id = ? GROUP BY assignment_id, student_id) last ON last.mid = s.id
`).all(cid);
const subMap = new Map();
for (const s of subRows) subMap.set(s.assignment_id + '_' + s.student_id, s.status);
const now = Date.now();
const cidNum = Number(cid);
const RANK = { overdue: 0, revision: 1, in_progress: 2, not_started: 3 };
const students = members.map(m => {
// Только задания ЭТОГО класса + личные, созданные учителем этого класса.
const rows = assignmentRowsForUser(m.id).filter(r =>
r.class_id === cidNum || (r.class_id === 0 && r.created_by === cls.teacher_id)
);
const pending = [];
for (const r of rows) {
const t = AssignmentUtils.type(r);
const st = (t === 'upload' || t === 'file') ? subMap.get(r.id + '_' + m.id) : null;
// Учительская семантика: любая сдача не на доработке = не долг (default opts).
if (AssignmentUtils.isDone(r, st ? { status: st } : null)) continue;
const overdue = r.deadline && new Date(r.deadline).getTime() < now;
let status = overdue ? 'overdue' : 'not_started';
if (st === 'revision') status = 'revision'; // вернули на доработку
else if (t === 'test' && r.session_status === 'in_progress') status = 'in_progress';
pending.push({
assignment_id: r.id, title: r.title, type: t, deadline: r.deadline,
status, is_homework: r.is_homework ? 1 : 0,
scope: r.class_id === cidNum ? 'class' : 'direct',
});
}
pending.sort((a, b) => (RANK[a.status] - RANK[b.status]) ||
((a.deadline ? new Date(a.deadline).getTime() : Infinity) -
(b.deadline ? new Date(b.deadline).getTime() : Infinity)));
const counts = { total: pending.length, overdue: 0, in_progress: 0, not_started: 0, revision: 0 };
pending.forEach(p => { counts[p.status]++; });
return { id: m.id, name: m.name, email: m.email, pending, counts };
});
const summary = {
students_total: members.length,
debtors: students.filter(s => s.counts.total > 0).length,
overdue: students.reduce((a, s) => a + s.counts.overdue, 0),
};
res.json({ className: cls.name, summary, students });
} }
/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc. /* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc.
@@ -732,6 +805,7 @@ module.exports = {
deleteAssignment, deleteAssignment,
teacherAssignments, teacherAssignments,
myAssignments, myAssignments,
classOutstanding,
startAssignment, startAssignment,
assignmentResults, assignmentResults,
assignmentQuestionStats, assignmentQuestionStats,
+325 -36
View File
@@ -153,25 +153,54 @@ function weakSubject(uid) {
} }
/* ── Долгая память об ученике ─────────────────────────────────────────── */ /* ── Долгая память об ученике ─────────────────────────────────────────── */
// Темы экзамена хранятся англ. ключами (exam_tasks.topic) — показываем по-русски.
const _EXAM_TOPIC_RU = {
algebra: 'Алгебра', equations: 'Уравнения и неравенства', planimetry: 'Планиметрия',
geometry: 'Геометрия', 'word-sequences': 'Текстовые задачи', numbers: 'Числа и вычисления',
trigonometry: 'Тригонометрия', stereometry: 'Стереометрия', functions: 'Функции',
theory: 'Теория вероятностей', expressions: 'Выражения и преобразования', advanced: 'Повышенной сложности'
};
// Производный профиль (без LLM) — из уже накопленных сигналов. // Производный профиль (без LLM) — из уже накопленных сигналов.
function _studentProfile(uid) { function _studentProfile(uid) {
const out = { weakSubjects: [], weakTopics: [], exam: null, streak: 0 }; const out = { weakSubjects: [], weakTopics: [], exam: null, streak: 0 };
// «Забыть всё» ставит точку отсчёта: производный профиль учитывает только активность ПОСЛЕ неё.
let forget = null;
try { const fr = db.prepare("SELECT value FROM app_settings WHERE key = ?").get('asst_forget_' + uid); forget = (fr && fr.value) || null; } catch (e) {}
try { try {
out.weakSubjects = db.prepare(` out.weakSubjects = db.prepare(`
SELECT s.name AS name, ROUND(AVG(ts.score * 100.0 / ts.total)) AS avg, COUNT(*) AS n SELECT s.name AS name, ROUND(AVG(ts.score * 100.0 / ts.total)) AS avg, COUNT(*) AS n
FROM test_sessions ts JOIN subjects s ON s.id = ts.subject_id FROM test_sessions ts JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ? AND ts.status = 'completed' AND ts.total > 0 WHERE ts.user_id = ? AND ts.status = 'completed' AND ts.total > 0${forget ? ' AND ts.finished_at > ?' : ''}
GROUP BY ts.subject_id HAVING n >= 2 AND avg < 70 ORDER BY avg ASC LIMIT 3 GROUP BY ts.subject_id HAVING n >= 2 AND avg < 70 ORDER BY avg ASC LIMIT 3
`).all(uid).map(r => ({ name: r.name, avg: r.avg })); `).all(...(forget ? [uid, forget] : [uid])).map(r => ({ name: r.name, avg: r.avg }));
} catch (e) {} } catch (e) {}
try { try {
out.weakTopics = db.prepare(` const cand = {}; // трудные темы по ВСЕМ предметам: банк тестов + экзамен
SELECT et.topic AS topic, COUNT(*) AS attempts, SUM(ea.is_correct) AS correct try {
FROM exam_attempts ea JOIN exam_tasks et ON et.id = ea.exam_task_id db.prepare(`
WHERE ea.user_id = ? AND et.topic IS NOT NULL AND et.topic <> '' SELECT t.name AS topic, COUNT(*) AS attempts, SUM(ua.is_correct) AS correct
GROUP BY et.topic HAVING attempts >= 3 AND (correct * 1.0 / attempts) < 0.6 FROM user_answers ua JOIN questions q ON q.id = ua.question_id JOIN topics t ON t.id = q.topic_id
ORDER BY (correct * 1.0 / attempts) ASC LIMIT 3 WHERE ua.session_id IN (SELECT id FROM test_sessions WHERE user_id = ? AND status = 'completed')${forget ? ' AND ua.answered_at > ?' : ''}
`).all(uid).map(r => ({ topic: r.topic, rate: Math.round(r.correct * 100 / r.attempts) })); GROUP BY q.topic_id HAVING attempts >= 3
`).all(...(forget ? [uid, forget] : [uid])).forEach(r => { cand[r.topic] = { topic: r.topic, attempts: r.attempts, correct: r.correct || 0 }; });
} catch (e) {}
try {
db.prepare(`
SELECT et.topic AS topic, COUNT(*) AS attempts, SUM(ea.is_correct) AS correct
FROM exam_attempts ea JOIN exam_tasks et ON et.id = ea.exam_task_id
WHERE ea.user_id = ? AND et.topic IS NOT NULL AND et.topic <> ''${forget ? ' AND ea.created_at > ?' : ''}
GROUP BY et.topic HAVING attempts >= 3
`).all(...(forget ? [uid, forget] : [uid])).forEach(r => {
const topic = _EXAM_TOPIC_RU[r.topic] || r.topic;
const c = cand[topic];
if (c) { c.attempts += r.attempts; c.correct += (r.correct || 0); }
else cand[topic] = { topic: topic, attempts: r.attempts, correct: r.correct || 0 };
});
} catch (e) {}
out.weakTopics = Object.values(cand)
.map(c => ({ topic: c.topic, rate: Math.round(c.correct * 100 / c.attempts) }))
.filter(x => x.rate < 60)
.sort((a, b) => a.rate - b.rate).slice(0, 4);
} catch (e) {} } catch (e) {}
try { try {
const p = db.prepare('SELECT exam_key, exam_date FROM exam_user_plan WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1').get(uid); const p = db.prepare('SELECT exam_key, exam_date FROM exam_user_plan WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1').get(uid);
@@ -190,34 +219,80 @@ function _memoryBlock(uid) {
if (p.weakTopics.length) parts.push('трудные темы: ' + p.weakTopics.map(t => `${t.topic} (${t.rate}%)`).join(', ')); if (p.weakTopics.length) parts.push('трудные темы: ' + p.weakTopics.map(t => `${t.topic} (${t.rate}%)`).join(', '));
if (p.streak >= 3) parts.push(`серия занятий ${p.streak} дн.`); if (p.streak >= 3) parts.push(`серия занятий ${p.streak} дн.`);
try { try {
const notes = db.prepare('SELECT text FROM assistant_memory WHERE user_id = ? ORDER BY weight DESC, updated_at DESC LIMIT 8').all(uid).map(r => r.text); const rows = db.prepare('SELECT kind, text, weight, updated_at FROM assistant_memory WHERE user_id = ?').all(uid);
if (notes.length) parts.push('заметки: ' + notes.join('; ')); rows.forEach(r => { r.eff = _effWeight(r.weight, r.updated_at); });
const top = rows.filter(r => r.eff >= 0.25).sort((a, b) => b.eff - a.eff).slice(0, 8);
const LBL = { difficulty: 'трудности', goal: 'цели', preference: 'предпочтения', strength: 'сильные стороны', personal: 'о себе', note: 'заметки' };
const byKind = {};
top.forEach(r => { (byKind[r.kind] || (byKind[r.kind] = [])).push(r.text); });
Object.keys(byKind).forEach(k => parts.push((LBL[k] || 'заметки') + ': ' + byKind[k].join('; ')));
} catch (e) {} } catch (e) {}
return parts.join('; '); return parts.join('; ');
} }
// Upsert заметки с дедупликацией и лимитом. // Стем-токены для сравнения заметок (русская морфология: «дробях»→«дроб»).
function _memTokens(text) {
const stem = (w) => (w.length >= 7 ? w.slice(0, Math.max(4, w.length - 3)) : w.length >= 5 ? w.slice(0, Math.max(4, w.length - 2)) : w);
return Array.from(new Set(String(text || '').toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).map(stem)));
}
function _jaccard(a, b) {
if (!a.length || !b.length) return 0;
const sb = new Set(b); let inter = 0;
a.forEach(t => { if (sb.has(t)) inter++; });
return inter / (a.length + b.length - inter);
}
// Эффективный вес с затуханием по времени (полураспад ~31 день) — память остаётся свежей.
function _effWeight(weight, updatedAt) {
let days = 0;
try { days = (Date.now() - new Date(String(updatedAt).replace(' ', 'T') + 'Z').getTime()) / 86400000; } catch (e) {}
if (!(days > 0)) days = 0;
return weight * Math.exp(-days / 45);
}
// Upsert заметки: умное слияние похожих (а не накопление дублей) + лимит.
function _memUpsert(uid, kind, text, weight, source) { function _memUpsert(uid, kind, text, weight, source) {
try { try {
const key = text.toLowerCase().slice(0, 24); text = String(text).trim().slice(0, 200);
const ex = db.prepare('SELECT id FROM assistant_memory WHERE user_id = ? AND lower(text) LIKE ?').get(uid, '%' + key + '%'); const toks = _memTokens(text);
if (ex) { db.prepare("UPDATE assistant_memory SET weight = weight + 0.5, updated_at = datetime('now') WHERE id = ?").run(ex.id); return; } if (!toks.length) return;
db.prepare("INSERT INTO assistant_memory (user_id, kind, text, weight, source) VALUES (?, ?, ?, ?, ?)").run(uid, kind, text.slice(0, 200), weight, source); const rows = db.prepare('SELECT id, text FROM assistant_memory WHERE user_id = ?').all(uid);
for (const r of rows) {
if (_jaccard(toks, _memTokens(r.text)) >= 0.5) { // та же мысль — слить, освежить, поднять вес
db.prepare("UPDATE assistant_memory SET weight = weight + 0.5, text = ?, kind = ?, updated_at = datetime('now') WHERE id = ?").run(text, kind, r.id);
return;
}
}
db.prepare("INSERT INTO assistant_memory (user_id, kind, text, weight, source) VALUES (?, ?, ?, ?, ?)").run(uid, kind, text, weight, source);
const cnt = db.prepare('SELECT COUNT(*) AS n FROM assistant_memory WHERE user_id = ?').get(uid).n; const cnt = db.prepare('SELECT COUNT(*) AS n FROM assistant_memory WHERE user_id = ?').get(uid).n;
if (cnt > 15) db.prepare('DELETE FROM assistant_memory WHERE id IN (SELECT id FROM assistant_memory WHERE user_id = ? ORDER BY weight ASC, updated_at ASC LIMIT ?)').run(uid, cnt - 15); if (cnt > 18) db.prepare('DELETE FROM assistant_memory WHERE id IN (SELECT id FROM assistant_memory WHERE user_id = ? ORDER BY weight ASC, updated_at ASC LIMIT ?)').run(uid, cnt - 18);
} catch (e) {} } catch (e) {}
} }
// Экстрактор: 1 устойчивый факт об ученике из реплики+ответа (фоновый, дросселированный). // Экстрактор: 1 устойчивый факт об ученике из реплики+ответа (фоновый, дросселированный).
const _MEM_CATS = new Set(['difficulty', 'preference', 'goal', 'strength', 'personal']);
// Доля кириллицы среди букв — гард, чтобы в память не попадал не-русский текст.
function _cyrShare(s) {
const letters = (String(s).match(/[a-zа-яё]/gi) || []).length;
const cyr = (String(s).match(/[а-яё]/gi) || []).length;
return letters ? cyr / letters : 0;
}
async function _extractMemory(uid, q, answer) { async function _extractMemory(uid, q, answer) {
try { try {
const sys = 'Ты ведёшь короткие заметки о трудностях, предпочтениях и целях ученика для персонализации обучения. ' + const sys = 'Ты ведёшь короткие заметки об ученике для персонализации обучения. ' +
'По вопросу ученика и ответу выдели ОДИН устойчивый факт об ученике (что даётся трудно / что путает / предпочтение / цель). ' + 'По вопросу ученика и ответу выдели ОДИН устойчивый факт: что даётся ТРУДНО (difficulty), ПРЕДПОЧТЕНИЕ в обучении (preference), ЦЕЛЬ (goal), СИЛЬНАЯ сторона (strength) или ЛИЧНОЕ — класс/интересы (personal). ' +
'Ответь короткой фразой по-русски (до 12 слов), без кавычек. Если устойчивого факта нет — ответь ровно NONE.'; 'Верни СТРОГО JSON {"cat":"difficulty|preference|goal|strength|personal","text":"<факт до 12 слов>"} ' +
const r = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: `Вопрос: ${q}\nОтвет: ${String(answer).slice(0, 500)}` }], 40); 'и текст ИСКЛЮЧИТЕЛЬНО НА РУССКОМ ЯЗЫКЕ, без кавычек внутри. Если устойчивого факта нет — верни {"cat":"none"}.';
const note = r && r.text && r.text.trim().replace(/^["'«»]+|["'«»]+$/g, ''); const r = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: `Вопрос: ${q}\nОтвет: ${String(answer).slice(0, 500)}` }], 60);
if (!note || /^none\b/i.test(note) || note.length < 5 || note.length > 120) return; let cat = 'note', note = '';
_memUpsert(uid, 'note', note, 1, 'extractor'); const raw = r && r.text ? r.text.replace(/```(?:json)?/gi, '').trim() : '';
try {
const a = raw.indexOf('{'), b = raw.lastIndexOf('}');
const j = a >= 0 && b > a ? JSON.parse(raw.slice(a, b + 1)) : null;
if (j) { cat = String(j.cat || '').toLowerCase(); note = String(j.text || '').trim().replace(/^["'«»]+|["'«»]+$/g, ''); }
} catch (e) { /* не-JSON */ }
if (/^none\b/i.test(cat) || !note || note.length < 5 || note.length > 140) return;
if (_cyrShare(note) < 0.6) return; // не русский — не запоминаем
_memUpsert(uid, _MEM_CATS.has(cat) ? cat : 'note', note, 1, 'extractor');
} catch (e) {} } catch (e) {}
} }
@@ -255,7 +330,11 @@ function clearMemory(req, res) {
const uid = req.user.id, id = req.params.id ? Number(req.params.id) : null; const uid = req.user.id, id = req.params.id ? Number(req.params.id) : null;
try { try {
if (id) db.prepare('DELETE FROM assistant_memory WHERE id = ? AND user_id = ?').run(id, uid); if (id) db.prepare('DELETE FROM assistant_memory WHERE id = ? AND user_id = ?').run(id, uid);
else db.prepare('DELETE FROM assistant_memory WHERE user_id = ?').run(uid); else {
db.prepare('DELETE FROM assistant_memory WHERE user_id = ?').run(uid);
// «Забыть всё»: сбрасываем и точку отсчёта производного профиля (слабые предметы/темы)
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, datetime('now'))").run('asst_forget_' + uid);
}
} catch (e) {} } catch (e) {}
res.json({ ok: true }); res.json({ ok: true });
} }
@@ -339,6 +418,8 @@ function searchFaq(q, n) {
* на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */ * на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */
function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } } function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } }
function _isLocal(url) { return /\/\/(localhost|127\.0\.0\.1)/.test(url || ''); } function _isLocal(url) { return /\/\/(localhost|127\.0\.0\.1)/.test(url || ''); }
// Шлюзы с бесплатным инференсом БЕЗ ключа (наряду с localhost): ключ не обязателен.
function _noKeyNeeded(url) { return _isLocal(url) || /\/\/[^/]*\bpollinations\.ai\b/i.test(url || ''); }
/* Список провайдеров (несколько ключей/моделей). Хранится JSON в app_settings. /* Список провайдеров (несколько ключей/моделей). Хранится JSON в app_settings.
* Если списка нет — синтезируем из legacy-настроек/ENV, чтобы ничего не сломать. */ * Если списка нет — синтезируем из legacy-настроек/ENV, чтобы ничего не сломать. */
@@ -357,7 +438,7 @@ function _providers() {
/* Конфиги в порядке использования: активный первым, затем остальные с ключом /* Конфиги в порядке использования: активный первым, затем остальные с ключом
* (для авто-перехвата при лимите/ошибке). */ * (для авто-перехвата при лимите/ошибке). */
function providersOrdered() { function providersOrdered() {
const arr = _providers().filter(p => p && (p.key || _isLocal(p.url))); const arr = _providers().filter(p => p && (p.key || _noKeyNeeded(p.url)));
const activeId = _setting('assistant_active'); const activeId = _setting('assistant_active');
const active = arr.filter(p => p.id === activeId); const active = arr.filter(p => p.id === activeId);
const rest = arr.filter(p => p.id !== activeId); const rest = arr.filter(p => p.id !== activeId);
@@ -394,6 +475,22 @@ function ragContext(q) {
} catch (e) { return empty; } } catch (e) { return empty; }
} }
/* Знания о системе (индексируются из админки): статус модулей + описание.
* Поиск по ключевым словам вопроса; добавляется в контекст ответа. */
function _systemKb() { try { const r = _setting('assistant_system_kb'); return r ? (JSON.parse(r) || []) : []; } catch (e) { return []; } }
function systemContext(q) {
const kb = _systemKb(); if (!kb.length) return '';
// стем-префикс (русская морфология): отбрасываем окончание, но не короче 4 симв.
// «флешкартами»→«флешкарт», «лабораторию»→«лаборато» ловят «флешкарты»/«лаборатория».
const stem = (w) => (w.length >= 7 ? w.slice(0, Math.max(4, w.length - 3))
: w.length >= 5 ? w.slice(0, Math.max(4, w.length - 2)) : w);
const words = q.toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).map(stem);
if (!words.length) return '';
const scored = kb.map(c => { const t = ((c.title || '') + ' ' + (c.text || '')).toLowerCase(); return { c, s: words.reduce((a, w) => a + (t.indexOf(w) >= 0 ? 1 : 0), 0) }; })
.filter(x => x.s > 0).sort((a, b) => b.s - a.s).slice(0, 4);
return scored.map(x => x.c.text).join('\n');
}
/* Суточный счётчик использования (для админки). */ /* Суточный счётчик использования (для админки). */
const USAGE_FIELDS = { model_calls: 1, cache_hits: 1, faq: 1 }; const USAGE_FIELDS = { model_calls: 1, cache_hits: 1, faq: 1 };
function bumpUsage(field) { function bumpUsage(field) {
@@ -403,11 +500,11 @@ function bumpUsage(field) {
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */ /* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
/* Возвращает { text, error } — error: 'off'|'rate_limit'|'http'|'timeout'|'network'|'empty'|null. */ /* Возвращает { text, error } — error: 'off'|'rate_limit'|'http'|'timeout'|'network'|'empty'|null. */
async function callLLM(messages, maxTokens, override) { async function callLLM(messages, maxTokens, override, timeoutMs) {
const cfg = override || llmConfig(); const cfg = override || llmConfig();
if (typeof fetch !== 'function' || !cfg.on) return { text: null, error: 'off' }; if (typeof fetch !== 'function' || !cfg.on) return { text: null, error: 'off' };
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000); const timer = setTimeout(() => ctrl.abort(), timeoutMs || 15000);
try { try {
const r = await fetch(cfg.url, { const r = await fetch(cfg.url, {
method: 'POST', method: 'POST',
@@ -433,12 +530,12 @@ function _recordFailover(failed, served, reason) {
} }
function _clearFailover() { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} } function _clearFailover() { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} }
async function callLLMFailover(messages, maxTokens) { async function callLLMFailover(messages, maxTokens, timeoutMs) {
const cfgs = providersOrdered(); const cfgs = providersOrdered();
if (!cfgs.length) return { text: null, error: 'off' }; if (!cfgs.length) return { text: null, error: 'off' };
let last = { text: null, error: 'off' }, firstErr = null; let last = { text: null, error: 'off' }, firstErr = null;
for (let i = 0; i < cfgs.length; i++) { for (let i = 0; i < cfgs.length; i++) {
last = await callLLM(messages, maxTokens, cfgs[i]); last = await callLLM(messages, maxTokens, cfgs[i], timeoutMs);
if (i === 0) firstErr = last.error; if (i === 0) firstErr = last.error;
if (last.text) { if (last.text) {
if (i === 0) _clearFailover(); // активный работает — снимаем флаг if (i === 0) _clearFailover(); // активный работает — снимаем флаг
@@ -451,11 +548,69 @@ async function callLLMFailover(messages, maxTokens) {
return last; return last;
} }
/* Потоковый вызов OpenAI-совместимого chat/completions (stream:true).
* onDelta(piece) — на каждый кусок текста. Возвращает { text, any, error }. */
async function callLLMStream(messages, maxTokens, cfg, onDelta) {
if (typeof fetch !== 'function' || !cfg.on) return { text: null, any: false, error: 'off' };
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 60000); // стриминг длиннее обычного
try {
const r = await fetch(cfg.url, {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}),
body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: maxTokens || 1200, messages, stream: true }),
signal: ctrl.signal,
});
if (!r.ok) return { text: null, any: false, error: r.status === 429 ? 'rate_limit' : 'http', status: r.status };
if (!r.body) return { text: null, any: false, error: 'empty' };
const dec = new TextDecoder();
let buf = '', full = '', any = false;
for await (const chunk of r.body) {
buf += dec.decode(chunk, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') return { text: full || null, any, error: full ? null : 'empty' };
try {
const j = JSON.parse(data);
const d = j.choices && j.choices[0] && j.choices[0].delta;
const piece = d && d.content;
if (piece) { full += piece; any = true; onDelta(piece); }
} catch (e) { /* частичный/служебный кусок — пропускаем */ }
}
}
return { text: full || null, any, error: full ? null : 'empty' };
} catch (e) { return { text: null, any: false, error: e.name === 'AbortError' ? 'timeout' : 'network' }; }
finally { clearTimeout(timer); }
}
/* Стриминг с перебором провайдеров. Failover возможен ТОЛЬКО до первого куска;
* как только клиенту ушёл текст (any) — остаёмся на этом провайдере. */
async function callLLMStreamFailover(messages, maxTokens, onDelta) {
const cfgs = providersOrdered();
if (!cfgs.length) return { text: null, error: 'off' };
let firstErr = null;
for (let i = 0; i < cfgs.length; i++) {
const res = await callLLMStream(messages, maxTokens, cfgs[i], onDelta);
if (i === 0) firstErr = res.error;
if (res.text) {
if (i === 0) _clearFailover(); else _recordFailover(cfgs[0], cfgs[i], firstErr);
return res;
}
if (res.any) return res; // часть уже улетела клиенту — переключиться нельзя
if (!_RETRYABLE[res.error]) break;
}
if (_RETRYABLE[firstErr]) _recordFailover(cfgs[0], null, firstErr);
return { text: null, error: firstErr || 'error' };
}
/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */ /* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */
async function pingLLM(override) { async function pingLLM(override) {
const cfg = override || llmConfig(); const cfg = override || llmConfig();
if (!cfg.url) return { ok: false, error: 'URL не задан' }; if (!cfg.url) return { ok: false, error: 'URL не задан' };
if (!cfg.key && !/\/\/(localhost|127\.0\.0\.1)/.test(cfg.url)) return { ok: false, error: 'Ключ не задан' }; if (!cfg.key && !_noKeyNeeded(cfg.url)) return { ok: false, error: 'Ключ не задан' };
if (typeof fetch !== 'function') return { ok: false, error: 'fetch недоступен' }; if (typeof fetch !== 'function') return { ok: false, error: 'fetch недоступен' };
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000); const timer = setTimeout(() => ctrl.abort(), 15000);
@@ -496,7 +651,12 @@ const META_RE = new RegExp('(' + _SELF + '[\\sа-яёa-z0-9,?!.-]{0,25}' + _TERM
'|на\\s+ч[её]м\\s+ты\\s+(?:работа|сдела|постро|основ)|кто\\s+тебя\\s+(?:сделал|создал|обуч|разработ|написал)|систем[а-яё]*\\s+промпт|what\\s+model\\s+are\\s+you|which\\s+(?:ai\\s+)?model|your\\s+system\\s+prompt)', 'i'); '|на\\s+ч[её]м\\s+ты\\s+(?:работа|сдела|постро|основ)|кто\\s+тебя\\s+(?:сделал|создал|обуч|разработ|написал)|систем[а-яё]*\\s+промпт|what\\s+model\\s+are\\s+you|which\\s+(?:ai\\s+)?model|your\\s+system\\s+prompt)', 'i');
const META_ANSWER = 'Я — Квантик, помощник LearnSpace. Помогаю с учёбой и навигацией по платформе. Давай вернёмся к делу — что объяснить или подсказать?'; const META_ANSWER = 'Я — Квантик, помощник LearnSpace. Помогаю с учёбой и навигацией по платформе. Давай вернёмся к делу — что объяснить или подсказать?';
async function askModel(q, hits, context, history, role, mode, mem) { // Анти-чит: явная просьба «сделай за меня» (а не «помоги разобраться»).
const _CHEAT_RE = /за\s+меня|вместо\s+меня|do\s+my\s+homework|(сделай|реши|выполни|напиши)\s+([а-яёА-ЯЁ]+\s+)?(дз|домашк|контрольн)/i;
function _socraticOn() { return _setting('assistant_socratic') === '1'; }
// Сборка messages+cap для модели — общая для обычного и стримингового ответа.
function buildAskMessages(q, hits, context, history, role, mode, mem, socratic) {
const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)'; const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)';
const user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') + const user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') +
`Справка по платформе:\n${ref}\n\nВопрос: ${q}`; `Справка по платформе:\n${ref}\n\nВопрос: ${q}`;
@@ -510,15 +670,33 @@ async function askModel(q, hits, context, history, role, mode, mem) {
sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.'; sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.';
} else if (mode === 'check') { } else if (mode === 'check') {
sys += ' РЕЖИМ ПРОВЕРКИ: ученик прислал своё решение. Скажи, верно оно или нет, и укажи КОНКРЕТНО, где ошибка (если есть). Не выдавай сразу полный правильный ответ — дай шанс исправить.'; sys += ' РЕЖИМ ПРОВЕРКИ: ученик прислал своё решение. Скажи, верно оно или нет, и укажи КОНКРЕТНО, где ошибка (если есть). Не выдавай сразу полный правильный ответ — дай шанс исправить.';
} else if (socratic) {
// Сократический режим (для учеников): теория — полно, но задачи не решаем «под ключ».
sys += ' СОКРАТИЧЕСКИЙ РЕЖИМ: понятия, определения и теорию объясняй полно и по существу. ' +
'Но если просят РЕШИТЬ конкретную задачу/пример/уравнение или «сделать» задание — НЕ выдавай готовое решение и итоговый ответ. ' +
'Вместо этого назови нужный метод/формулу, разбери первый шаг и задай наводящий вопрос, предложи ученику продолжить самому. ' +
'Если ученик пришлёт свой шаг или ответ — проверь и мягко направь дальше. Будь доброжелателен, подбадривай.';
} }
const msgs = [{ role: 'system', content: sys }]; const msgs = [{ role: 'system', content: sys }];
(history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); }); (history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); });
msgs.push({ role: 'user', content: user }); msgs.push({ role: 'user', content: user });
// подсказка короткая; ответ/проверка — длиннее, чтобы пошаговое решение с формулами не обрезалось на середине // подсказка короткая; ответ/проверка — длиннее, чтобы пошаговое решение с формулами не обрезалось на середине
const cap = mode === 'hint' ? 320 : (mode === 'check' ? 900 : 1200); const cap = mode === 'hint' ? 320 : (mode === 'check' ? 900 : 1200);
return { msgs, cap };
}
async function askModel(q, hits, context, history, role, mode, mem, socratic) {
const { msgs, cap } = buildAskMessages(q, hits, context, history, role, mode, mem, socratic);
return callLLMFailover(msgs, cap); return callLLMFailover(msgs, cap);
} }
// Сократический режим включается для УЧЕНИКА: если включён тумблер ИЛИ явная просьба «сделай за меня».
function _socraticFor(role, mode, q) {
if (role && role !== 'student') return false; // учителям/админам не ограничиваем
if (mode !== 'answer') return false; // hint/check уже наводящие
return _socraticOn() || _CHEAT_RE.test(q || '');
}
/* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─ /* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─
* Грунтуем ответ топ-FAQ (+ опц. контекст страницы + история диалога). Если * Грунтуем ответ топ-FAQ (+ опц. контекст страницы + история диалога). Если
* LLM настроена — её ответ (source:'model'), иначе FAQ (source:'faq'). */ * LLM настроена — её ответ (source:'model'), иначе FAQ (source:'faq'). */
@@ -529,7 +707,7 @@ async function ask(req, res) {
const pageCtx = String((req.body && req.body.context) || '').slice(0, 4000); const pageCtx = String((req.body && req.body.context) || '').slice(0, 4000);
const mode = ['hint', 'check'].includes(req.body && req.body.mode) ? req.body.mode : 'answer'; const mode = ['hint', 'check'].includes(req.body && req.body.mode) ? req.body.mode : 'answer';
let history = (req.body && req.body.history); let history = (req.body && req.body.history);
history = Array.isArray(history) ? history.slice(-6) : []; history = Array.isArray(history) ? history.slice(-14) : [];
const hits = searchFaq(q, 3); const hits = searchFaq(q, 3);
const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null })); const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null }));
@@ -550,9 +728,12 @@ async function ask(req, res) {
let context = pageCtx; let context = pageCtx;
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text; if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
const sysCtx = systemContext(q);
if (sysCtx) context = (context ? context + '\n\n' : '') + 'Состояние платформы (актуально, опирайся на это о модулях):\n' + sysCtx;
const socratic = _socraticFor(req.user && req.user.role, mode, q);
let r = { text: null, error: 'network' }; let r = { text: null, error: 'network' };
try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode, mem); } catch (e) { r = { text: null, error: 'network' }; } try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode, mem, socratic); } catch (e) { r = { text: null, error: 'network' }; }
const answer = r && r.text; const answer = r && r.text;
if (answer) { if (answer) {
@@ -572,6 +753,68 @@ async function ask(req, res) {
res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] });
} }
/* ── POST /api/assistant/ask/stream ── то же, что ask, но ответ модели стримится
* по SSE (event: meta|delta|done). Быстрые пути (FAQ/кэш/мета) отдаются одним done. */
async function askStream(req, res) {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // не буферизовать за прокси
if (res.flushHeaders) res.flushHeaders();
const sse = (event, data) => { try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch (e) {} };
const q = String((req.body && req.body.q) || '').trim().slice(0, 500);
if (!q || q.length < 2) { sse('done', { source: 'faq', answer: null, answers: [] }); return res.end(); }
if (META_RE.test(q)) { sse('delta', { t: META_ANSWER }); sse('done', { source: 'model', answers: [], sources: [] }); return res.end(); }
const pageCtx = String((req.body && req.body.context) || '').slice(0, 4000);
const mode = ['hint', 'check'].includes(req.body && req.body.mode) ? req.body.mode : 'answer';
let history = (req.body && req.body.history);
history = Array.isArray(history) ? history.slice(-14) : [];
const hits = searchFaq(q, 3);
const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null }));
sse('meta', { answers: faqJson });
if (!providersOrdered().length) { bumpUsage('faq'); sse('done', { source: 'faq', answer: null, answers: faqJson, sources: [] }); return res.end(); }
const rag = ragContext(q);
const mem = _memoryBlock(req.user.id);
const cacheable = mode === 'answer' && !pageCtx && !history.length && !mem;
const qhash = q.toLowerCase().replace(/\s+/g, ' ').trim();
if (cacheable) {
try {
const c = db.prepare("SELECT answer FROM assistant_cache WHERE qhash = ? AND created_at > datetime('now','-7 days')").get(qhash);
if (c) { bumpUsage('cache_hits'); sse('delta', { t: c.answer }); sse('done', { source: 'model', answers: faqJson, sources: rag.sources, cached: true }); return res.end(); }
} catch (e) {}
}
if (rag.sources && rag.sources.length) sse('meta', { sources: rag.sources });
let context = pageCtx;
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
const sysCtx = systemContext(q);
if (sysCtx) context = (context ? context + '\n\n' : '') + 'Состояние платформы (актуально, опирайся на это о модулях):\n' + sysCtx;
const socratic = _socraticFor(req.user && req.user.role, mode, q);
const { msgs, cap } = buildAskMessages(q, hits, context, history, req.user && req.user.role, mode, mem, socratic);
let full = '';
let r = { text: null, error: 'network' };
try { r = await callLLMStreamFailover(msgs, cap, (piece) => { full += piece; sse('delta', { t: piece }); }); }
catch (e) { r = { text: null, error: 'network' }; }
const answer = (r && r.text) || full;
if (answer) {
bumpUsage('model_calls');
if (cacheable) { try { db.prepare("INSERT OR REPLACE INTO assistant_cache (qhash, answer, created_at) VALUES (?, ?, datetime('now'))").run(qhash, answer); } catch (e) {} }
if (_setting('assistant_memory') !== '0' && (mode === 'check' || history.length >= 4)) _extractMemory(req.user.id, q, answer);
sse('done', { source: 'model', answers: faqJson, sources: rag.sources });
return res.end();
}
bumpUsage('faq');
if (r && r.error === 'rate_limit') sse('done', { source: 'limit', answer: 'Сейчас слишком много запросов к ИИ за короткое время — подожди минутку и спроси снова. Память диалога не потеряется.', answers: faqJson, sources: [] });
else if (r && (r.error === 'timeout' || r.error === 'network' || r.error === 'http')) sse('done', { source: 'error', answer: 'Не получилось обратиться к ИИ. Попробуй ещё раз чуть позже.', answers: faqJson, sources: [] });
else sse('done', { source: 'faq', answer: null, answers: faqJson, sources: [] });
res.end();
}
/* ── POST /api/assistant/feedback { rating, q? } ── лайк/дизлайк ответа ── */ /* ── POST /api/assistant/feedback { rating, q? } ── лайк/дизлайк ответа ── */
function feedback(req, res) { function feedback(req, res) {
const rating = (req.body && req.body.rating) === 1 ? 1 : ((req.body && req.body.rating) === -1 ? -1 : 0); const rating = (req.body && req.body.rating) === 1 ? 1 : ((req.body && req.body.rating) === -1 ? -1 : 0);
@@ -596,7 +839,7 @@ async function flashcardsFromText(req, res) {
'Верни СТРОГО JSON-массив из ' + count + ' объектов вида {"front":"...","back":"..."} без markdown и пояснений. ' + 'Верни СТРОГО JSON-массив из ' + count + ' объектов вида {"front":"...","back":"..."} без markdown и пояснений. ' +
'front — короткий вопрос, back — краткий ответ (1–2 предложения). По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.'; 'front — короткий вопрос, back — краткий ответ (1–2 предложения). По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.';
let rr; let rr;
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1600); } try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1600, 40000); }
catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); } catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); }
const raw = rr && rr.text; const raw = rr && rr.text;
let cards = []; let cards = [];
@@ -621,4 +864,50 @@ async function flashcardsFromText(req, res) {
res.json({ title, cards }); res.json({ title, cards });
} }
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, feedback, getMemory, clearMemory, getStudentProfile, llmConfig, pingLLM, clearFailover: _clearFailover, callLLMFailover }; /* ── POST /api/assistant/questions { text, count? } ── учитель: сгенерировать
* тестовые вопросы (single-choice) из темы/текста для банка вопросов. */
async function questionsFromText(req, res) {
if (!providersOrdered().length) return res.status(503).json({ error: 'LLM не настроена' });
const text = String((req.body && req.body.text) || '').trim().slice(0, 6000);
let count = Number(req.body && req.body.count);
count = Number.isFinite(count) ? Math.max(3, Math.min(10, Math.round(count))) : 5;
if (text.length < 3) return res.status(400).json({ error: 'Введите тему или текст' });
const sys = 'Ты составляешь тестовые вопросы с выбором одного верного ответа для школьников. ' +
'Если дан учебный текст/параграф — делай вопросы СТРОГО по нему; если дана короткая тема — раскрой её по школьной программе. ' +
'Верни СТРОГО JSON-массив из ' + count + ' объектов вида ' +
'{"q":"текст вопроса","options":["вариант1","вариант2","вариант3","вариант4"],"correct":0,"explanation":"кратко, почему верен"}. ' +
'РОВНО 4 варианта; correct — индекс правильного (0..3); ровно один правильный. ' +
'По-русски, формулы в LaTeX между $...$. Никакого текста вне JSON, без markdown.';
let rr;
try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 2200, 45000); }
catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); }
const raw = rr && rr.text;
let questions = [];
if (raw) {
let s = raw.replace(/```(?:json)?/gi, '').trim();
const a = s.indexOf('[');
if (a >= 0) {
const b = s.lastIndexOf(']');
if (b > a) s = s.slice(a, b + 1);
else { const last = s.lastIndexOf('}'); s = last > a ? s.slice(a, last + 1) + ']' : ''; }
}
try {
const arr = JSON.parse(s);
if (Array.isArray(arr)) {
questions = arr
.filter(x => x && x.q && Array.isArray(x.options) && x.options.length >= 2)
.slice(0, count + 2)
.map(x => {
const opts = x.options.slice(0, 6).map(o => String(o).slice(0, 300)).filter(Boolean);
let correct = Number(x.correct); if (!Number.isInteger(correct) || correct < 0 || correct >= opts.length) correct = 0;
return { q: String(x.q).slice(0, 1000), options: opts, correct, explanation: String(x.explanation || '').slice(0, 600) };
})
.filter(x => x.options.length >= 2);
}
} catch (e) { /* не-JSON */ }
}
if (!questions.length) return res.status(502).json({ error: 'Не удалось сгенерировать вопросы' });
res.json({ questions });
}
module.exports = { getContext, markSeen, dismiss, setSettings, ask, askStream, flashcardsFromText, questionsFromText, feedback, getMemory, clearMemory, getStudentProfile, llmConfig, pingLLM, clearFailover: _clearFailover, callLLMFailover };
@@ -2,6 +2,7 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const db = require('../db/db'); const db = require('../db/db');
const { audit } = require('../utils/audit'); const { audit } = require('../utils/audit');
const { checkMagicBytes } = require('../utils/magic');
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars'); const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
@@ -9,6 +10,13 @@ const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
function requestAvatar(req, res) { function requestAvatar(req, res) {
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' }); if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
// Содержимое должно соответствовать заявленному MIME (client mimetype не доверяем).
const filePath = path.join(AVATARS_DIR, req.file.filename);
if (!checkMagicBytes(filePath, req.file.mimetype)) {
try { fs.unlinkSync(filePath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не является изображением' });
}
// Cancel any previous pending request from this user (replace it) // Cancel any previous pending request from this user (replace it)
const prev = db.prepare( const prev = db.prepare(
"SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'" "SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'"
@@ -4,6 +4,7 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { emitToUser } = require('../../ws-server'); const { emitToUser } = require('../../ws-server');
const { emitToSession, hasAccess } = require('./_shared'); const { emitToSession, hasAccess } = require('./_shared');
const { checkMagicBytes } = require('../../utils/magic');
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../../uploads/chat'); const CHAT_UPLOADS_DIR = path.join(__dirname, '../../../uploads/chat');
if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true }); if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true });
@@ -118,6 +119,12 @@ function reactToMessage(req, res) {
function uploadChatAttachment(req, res) { function uploadChatAttachment(req, res) {
if (!req.file) return res.status(400).json({ error: 'Файл не получен' }); if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
// Содержимое должно соответствовать заявленному MIME (client mimetype не доверяем).
const filePath = path.join(CHAT_UPLOADS_DIR, req.file.filename);
if (!checkMagicBytes(filePath, req.file.mimetype)) {
try { fs.unlinkSync(filePath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не является изображением' });
}
const url = `/uploads/chat/${req.file.filename}`; const url = `/uploads/chat/${req.file.filename}`;
const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file'; const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file';
res.json({ url, type, name: req.file.originalname }); res.json({ url, type, name: req.file.originalname });
@@ -41,6 +41,15 @@ function createSession(req, res) {
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId); const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
emitToSession(sessionId, { type: 'classroom_started', sessionId, title, classId: class_id || null, teacherName: teacher.name }); emitToSession(sessionId, { type: 'classroom_started', sessionId, title, classId: class_id || null, teacherName: teacher.name });
// Баннер «идёт онлайн-урок» на дашбордах — через SSE-канал (доска работает по WS,
// дашборд по SSE, поэтому нужен отдельный сигнал ученикам класса / приглашённым / учителю).
try {
const sse = require('../../sse');
const payload = { type: 'classroom_live', state: 'started', sessionId, title, classId: class_id || null };
if (class_id) sse.emitToClass(class_id, payload);
else if (user_ids) for (const uid of user_ids) sse.emit(uid, payload);
sse.emit(teacher.id, payload);
} catch { /* SSE недоступен — не критично */ }
res.json(session); res.json(session);
} }
@@ -74,6 +83,17 @@ function endSession(req, res) {
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId); db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId); db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
emitToSession(sessionId, { type: 'classroom_ended', sessionId }); emitToSession(sessionId, { type: 'classroom_ended', sessionId });
// Снять баннер «идёт онлайн-урок» с дашбордов (SSE-канал).
try {
const sse = require('../../sse');
const payload = { type: 'classroom_live', state: 'ended', sessionId };
if (session.class_id) sse.emitToClass(session.class_id, payload);
else {
const invited = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
for (const r of invited) sse.emit(r.user_id, payload);
}
sse.emit(session.teacher_id, payload);
} catch { /* SSE недоступен — не критично */ }
res.json({ ok: true }); res.json({ ok: true });
} }
@@ -0,0 +1,29 @@
'use strict';
/* clientErrorController — приём ошибок из браузера пользователя.
Пишем в общий error_log с level='client', чтобы они появились в админ-вкладке «Ошибки».
Запись не должна ронять запрос — любые сбои глушим. */
const db = require('../db/db');
const MAX_MSG = 1000, MAX_STACK = 4000, MAX_ROUTE = 400;
const clamp = (v, n) => (v == null ? null : String(v).slice(0, n));
function report(req, res) {
const b = req.body || {};
const message = (clamp(b.message, MAX_MSG) || '').trim();
if (!message) return res.status(400).json({ error: 'message required' });
const kind = b.kind === 'unhandledrejection' ? 'rejection' : 'error';
const route = clamp(b.url || b.route, MAX_ROUTE);
let stack = clamp(b.stack, MAX_STACK);
// если стека нет — собираем источник:строка:колонка
if (!stack && (b.source || b.line)) stack = `${b.source || ''}:${b.line || ''}:${b.col || ''}`;
try {
db.prepare(
'INSERT INTO error_log (level, message, stack, route, method, user_id) VALUES (?, ?, ?, ?, ?, ?)'
).run('client', message, stack, route, kind, req.user.id);
} catch { /* лог не должен ломать ответ */ }
res.json({ ok: true });
}
module.exports = { report };
@@ -0,0 +1,154 @@
'use strict';
/* Пользовательские генераторы тренажёра (конструктор, P13).
*
* Спек генератора — ДАННЫЕ; на клиенте его исполняет безопасный SimExpr (⛔ без
* eval). Сервер НЕ исполняет — только валидирует структуру/лимиты и хранит.
* Текст НЕ экранируется на сервере: клиент рендерит безопасно (textContent / esc),
* а выражения проходят через SimExpr. Стиль — customSimController/studentMaterials:
* read auth-only (own + published), мутации — requireRole + per-row ownership.
*/
const db = require('../db/db');
const KINDS = { solve: 1, compute: 1, roots: 1, simplify: 1, inequality: 1 };
const MAX_SPEC = 20000;
function clip(v, n) { return (typeof v === 'string') ? (v.length > n ? v.slice(0, n) : v) : ''; }
function expr(v, n) { return (typeof v === 'string') ? clip(v.trim(), n || 200) : ''; }
const NAME = /^[a-zA-Z][a-zA-Z0-9]{0,12}$/;
/* Валидация спека БЕЗ исполнения: типы/лимиты. Возврат { ok, clean?, error? }. */
function validateGenSpec(spec) {
if (!spec || typeof spec !== 'object') return { ok: false, error: 'спек отсутствует' };
if (JSON.stringify(spec).length > MAX_SPEC) return { ok: false, error: 'спек слишком большой' };
const title = clip(String(spec.title || '').trim(), 120);
if (!title) return { ok: false, error: 'нужен заголовок' };
const topic = clip(String(spec.topic || 'custom').trim(), 60) || 'custom';
const kind = (typeof spec.kind === 'string' && KINDS[spec.kind]) ? spec.kind : 'solve';
// pick: имя → [min,max] целые
const pick = {};
if (spec.pick && typeof spec.pick === 'object') {
for (const k of Object.keys(spec.pick).slice(0, 20)) {
const r = spec.pick[k];
if (NAME.test(k) && Array.isArray(r) && r.length === 2 && Number.isInteger(r[0]) && Number.isInteger(r[1])) {
pick[k] = [r[0], r[1]];
}
}
}
// derive: имя → формула (строка)
const derive = {};
if (spec.derive && typeof spec.derive === 'object') {
for (const k of Object.keys(spec.derive).slice(0, 30)) {
if (NAME.test(k) && typeof spec.derive[k] === 'string') derive[k] = expr(spec.derive[k]);
}
}
// solution: [{ note, tex }]
let solution = [];
if (Array.isArray(spec.solution)) {
solution = spec.solution.slice(0, 12).map(st => ({
note: clip(String((st && st.note) || ''), 300),
tex: expr(st && st.tex)
}));
}
// answers: массив выражений (kind roots)
let answers;
if (Array.isArray(spec.answers)) answers = spec.answers.slice(0, 6).map(a => expr(a)).filter(Boolean);
const clean = {
title, topic, kind,
pick,
derive: Object.keys(derive).length ? derive : undefined,
constraint: expr(spec.constraint) || undefined,
require: expr(spec.require) || undefined,
lhs: expr(spec.lhs) || 'x',
rhs: expr(spec.rhs) || 'x',
display: (typeof spec.display === 'string' && spec.display.trim()) ? clip(spec.display, 200) : undefined,
srcExpr: expr(spec.srcExpr) || undefined,
answerExpr: expr(spec.answerExpr) || undefined,
dispOp: ['<', '>', '<=', '>='].indexOf(spec.dispOp) !== -1 ? spec.dispOp : undefined,
relOp: ['<', '>', '<=', '>='].indexOf(spec.relOp) !== -1 ? spec.relOp : undefined,
bound: expr(spec.bound) || undefined,
answer: expr(spec.answer) || undefined,
answers: (answers && answers.length) ? answers : undefined,
answerVar: /^[a-z]$/.test(spec.answerVar) ? spec.answerVar : 'x',
integerAnswer: !!spec.integerAnswer,
solution
};
Object.keys(clean).forEach(k => clean[k] === undefined && delete clean[k]);
return { ok: true, clean };
}
/* Строка БД → объект-генератор для клиента (готов к TE.instantiate). */
function toClientGen(row) {
let spec = {};
try { spec = JSON.parse(row.spec_json) || {}; } catch (e) { spec = {}; }
spec.id = 'cg' + row.id; // ключ навыка/прогресса
spec.title = row.title;
spec.topic = row.topic || 'custom';
spec.dbid = row.id;
spec.owner_id = row.owner_id;
spec.status = row.status;
spec._custom = true;
return spec;
}
/* GET /api/practice/generators — свои + опубликованные. */
function genList(req, res) {
const uid = req.user.id;
const rows = db.prepare(
"SELECT * FROM custom_generators WHERE owner_id = ? OR status = 'published' ORDER BY updated_at DESC, id DESC"
).all(uid);
res.json({ generators: rows.map(toClientGen) });
}
/* GET /api/practice/generators/:id — свой или опубликованный. */
// @public-by-design: auth-only; видимость own+published проверяется в хендлере.
function genGet(req, res) {
const uid = req.user.id, role = req.user.role;
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
if (!row) return res.status(404).json({ error: 'не найдено' });
if (row.owner_id !== uid && row.status !== 'published' && role !== 'admin') return res.status(403).json({ error: 'нет доступа' });
res.json({ generator: toClientGen(row) });
}
function genCreate(req, res) {
const v = validateGenSpec(req.body && req.body.spec);
if (!v.ok) return res.status(400).json({ error: v.error });
const status = (req.body && req.body.status === 'published') ? 'published' : 'draft';
const info = db.prepare(
'INSERT INTO custom_generators (owner_id, title, topic, spec_json, status) VALUES (?, ?, ?, ?, ?)'
).run(req.user.id, v.clean.title, v.clean.topic, JSON.stringify(v.clean), status);
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(info.lastInsertRowid);
res.json({ ok: true, generator: toClientGen(row) });
}
function genUpdate(req, res) {
const uid = req.user.id, role = req.user.role;
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
if (!row) return res.status(404).json({ error: 'не найдено' });
if (row.owner_id !== uid && role !== 'admin') return res.status(403).json({ error: 'не ваш генератор' });
let title = row.title, topic = row.topic, specJson = row.spec_json;
if (req.body && req.body.spec) {
const v = validateGenSpec(req.body.spec);
if (!v.ok) return res.status(400).json({ error: v.error });
title = v.clean.title; topic = v.clean.topic; specJson = JSON.stringify(v.clean);
}
const status = (req.body && (req.body.status === 'published' || req.body.status === 'draft')) ? req.body.status : row.status;
db.prepare("UPDATE custom_generators SET title = ?, topic = ?, spec_json = ?, status = ?, updated_at = datetime('now') WHERE id = ?")
.run(title, topic, specJson, status, row.id);
const upd = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(row.id);
res.json({ ok: true, generator: toClientGen(upd) });
}
function genDelete(req, res) {
const uid = req.user.id, role = req.user.role;
const row = db.prepare('SELECT * FROM custom_generators WHERE id = ?').get(parseInt(req.params.id, 10));
if (!row) return res.status(404).json({ error: 'не найдено' });
if (row.owner_id !== uid && role !== 'admin') return res.status(403).json({ error: 'не ваш генератор' });
db.prepare('DELETE FROM custom_generators WHERE id = ?').run(row.id);
res.json({ ok: true });
}
module.exports = { validateGenSpec, genList, genGet, genCreate, genUpdate, genDelete };
@@ -298,6 +298,9 @@ function unassignFile(req, res) {
function getFolderAccess(req, res) { function getFolderAccess(req, res) {
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id); const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' }); if (!fo) return res.status(404).json({ error: 'Folder not found' });
// Список раздачи (с именами/email учеников) — только владельцу папки или админу.
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const rows = db.prepare(` const rows = db.prepare(`
SELECT fa.id, fa.type, fa.target_id, SELECT fa.id, fa.type, fa.target_id,
@@ -1,7 +1,12 @@
const path = require('path');
const fs = require('fs');
const db = require('../db/db'); const db = require('../db/db');
const { stripTags } = require('../utils/sanitize'); const { stripTags } = require('../utils/sanitize');
const { checkMagicBytes } = require('../utils/magic');
const prepTracks = require('../services/prepTracks'); const prepTracks = require('../services/prepTracks');
const _fcUploadsDir = path.join(__dirname, '../../uploads/flashcards');
/* ── валидация URL картинки ──────────────────────────────────────────────── /* ── валидация URL картинки ────────────────────────────────────────────────
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) — Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
защита от javascript:/data:/внешних URL в src. Всё прочее → пустая строка. */ защита от javascript:/data:/внешних URL в src. Всё прочее → пустая строка. */
@@ -498,6 +503,12 @@ function getRandom(req, res) {
back_image. Сам файл уже на диске (multer); БД здесь не трогаем. */ back_image. Сам файл уже на диске (multer); БД здесь не трогаем. */
function uploadImage(req, res) { function uploadImage(req, res) {
if (!req.file) return res.status(400).json({ error: 'Файл не получен (только изображения до 5 МБ)' }); if (!req.file) return res.status(400).json({ error: 'Файл не получен (только изображения до 5 МБ)' });
// Содержимое должно соответствовать заявленному MIME (client mimetype не доверяем).
const filePath = path.join(_fcUploadsDir, req.file.filename);
if (!checkMagicBytes(filePath, req.file.mimetype)) {
try { fs.unlinkSync(filePath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не является изображением' });
}
res.json({ url: `/uploads/flashcards/${req.file.filename}` }); res.json({ url: `/uploads/flashcards/${req.file.filename}` });
} }
@@ -542,6 +542,7 @@ function onClassJoined(userId) {
} }
function onLabExperiment(userId, reactionsDiscovered) { function onLabExperiment(userId, reactionsDiscovered) {
if (!isGamificationEnabled()) return; // master kill-switch
stmts.incrLabExp.run(userId); stmts.incrLabExp.run(userId);
if (reactionsDiscovered > 0) stmts.incrLabReact.run(reactionsDiscovered, userId); if (reactionsDiscovered > 0) stmts.incrLabReact.run(reactionsDiscovered, userId);
awardXP(userId, 15, 'lab_experiment'); awardXP(userId, 15, 'lab_experiment');
@@ -650,6 +651,7 @@ function ensureChallenges(userId) {
} }
function updateChallenges(userId, score, total, subjectSlug, topicId) { function updateChallenges(userId, score, total, subjectSlug, topicId) {
if (!isGamificationEnabled()) return; // master kill-switch
const week = _currentWeek(); const week = _currentWeek();
const pct = total > 0 ? Math.round(score / total * 100) : 0; const pct = total > 0 ? Math.round(score / total * 100) : 0;
const challenges = stmts.getOpenChallenges.all(userId, week); const challenges = stmts.getOpenChallenges.all(userId, week);
@@ -0,0 +1,246 @@
'use strict';
/* Practice progress (ИИ-тренажёр, Фаза 0).
*
* Прогресс ученика по навыкам тренажёра. Навык = skill генератора; задачи
* генерируются и проверяются на клиенте (детерминированно, подстановкой), а
* сервер хранит только агрегаты. На каждую попытку клиент шлёт { skill, correct };
* сервер делает upsert: solved/attempts, текущая и лучшая серия, флаг mastered.
*
* Стиль следует gameController / customSimController: node:sqlite db.prepare,
* auth-only (роутер ставит authMiddleware), валидация входа без исполнения,
* статусы 400. Прогресс всегда принадлежит req.user — проверка владения не нужна.
*/
const db = require('../db/db');
const MAX_SKILL = 120; // длина skill (TEXT)
const MASTERY_STREAK = 5; // серия верных подряд для «освоено»
// Интервалы повторения (дни) по уровню Leitner-коробки box 0..5.
const INTERVAL_DAYS = [0, 1, 3, 7, 16, 30];
/* GET /api/practice/progress — прогресс текущего ученика по всем навыкам.
* `due` (0/1) — навык пора повторить (срок прошёл или не назначен). */
function listProgress(req, res) {
const uid = req.user.id;
const rows = db.prepare(`
SELECT skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at,
CASE WHEN due_at IS NULL OR due_at <= datetime('now') THEN 1 ELSE 0 END AS due,
updated_at
FROM practice_progress
WHERE user_id = ?
ORDER BY updated_at DESC, id DESC
`).all(uid);
res.json({ progress: rows, masteryStreak: MASTERY_STREAK });
}
/* POST /api/practice/attempt body: { skill, correct }
* Upsert агрегата попытки. Валидация: skill строка ≤120; correct — boolean.
* НИЧЕГО не исполняет (skill — лишь ключ). */
function submitAttempt(req, res) {
const uid = req.user.id;
const b = req.body || {};
const skill = typeof b.skill === 'string' ? b.skill.trim() : '';
if (!skill) return res.status(400).json({ error: 'skill обязателен' });
if (skill.length > MAX_SKILL) return res.status(400).json({ error: `skill длиннее ${MAX_SKILL} символов` });
if (typeof b.correct !== 'boolean') return res.status(400).json({ error: 'correct должно быть boolean' });
const correct = b.correct;
const existing = db.prepare(
'SELECT id, solved, attempts, cur_streak, best_streak, mastered, box FROM practice_progress WHERE user_id = ? AND skill = ?'
).get(uid, skill);
// Leitner: верно → box+1 (до 5), неверно → 0. Срок = сейчас + интервал(box).
const prevBox = existing ? (existing.box || 0) : 0;
const box = correct ? Math.min(prevBox + 1, 5) : 0;
const dueMod = '+' + INTERVAL_DAYS[box] + ' days';
if (!existing) {
const curStreak = correct ? 1 : 0;
db.prepare(`
INSERT INTO practice_progress (user_id, skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, datetime('now', ?), datetime('now'))
`).run(uid, skill, correct ? 1 : 0, curStreak, curStreak, curStreak >= MASTERY_STREAK ? 1 : 0, box, dueMod);
} else {
const curStreak = correct ? (existing.cur_streak + 1) : 0;
const bestStreak = Math.max(existing.best_streak || 0, curStreak);
const mastered = (existing.mastered || (curStreak >= MASTERY_STREAK)) ? 1 : 0;
db.prepare(`
UPDATE practice_progress
SET solved = solved + ?, attempts = attempts + 1,
cur_streak = ?, best_streak = ?, mastered = ?, box = ?, due_at = datetime('now', ?),
updated_at = datetime('now')
WHERE id = ?
`).run(correct ? 1 : 0, curStreak, bestStreak, mastered, box, dueMod, existing.id);
}
const row = db.prepare(`
SELECT skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at,
CASE WHEN due_at IS NULL OR due_at <= datetime('now') THEN 1 ELSE 0 END AS due,
updated_at
FROM practice_progress WHERE user_id = ? AND skill = ?
`).get(uid, skill);
res.json({ ok: true, progress: row, masteryStreak: MASTERY_STREAK });
}
/* ── Пул текстовых задач (Уровень 1, LLM + проверка) ── */
const genService = require('../services/practiceGenService');
const explainService = require('../services/practiceExplainService');
const { pushNotif } = require('../utils/notifications');
const POOL_TOPICS = { 'word-linear': 1, 'word-proportion': 1, 'word-percent': 1 };
function toClientProblem(r) {
let solution = [];
try { solution = r.solution_json ? JSON.parse(r.solution_json) : []; } catch (e) { solution = []; }
return {
id: r.id, kind: 'word', topic: r.topic, skill: r.skill,
story: r.story, lhsExpr: r.lhs, rhsExpr: r.rhs,
answerVar: r.answer_var, answer: r.answer, solution: solution
};
}
/* GET /api/practice/pool?skill=&limit= — одобренные задачи пула (ученикам). */
function listPool(req, res) {
const skill = (req.query && typeof req.query.skill === 'string') ? req.query.skill.trim().slice(0, MAX_SKILL) : '';
const limit = Math.min(parseInt((req.query && req.query.limit), 10) || 20, 50);
const rows = skill
? db.prepare("SELECT * FROM practice_problems WHERE status='approved' AND (skill = ? OR topic = ?) ORDER BY id DESC LIMIT ?").all(skill, skill, limit)
: db.prepare("SELECT * FROM practice_problems WHERE status='approved' ORDER BY id DESC LIMIT ?").all(limit);
res.json({ problems: rows.map(toClientProblem) });
}
/* POST /api/practice/generate { topic } — учитель/админ генерирует задачу в пул.
* Сервис проверяет корректность подстановкой; не прошло — в БД НЕ пишем. */
async function generateProblem(req, res) {
const topic = (req.body && typeof req.body.topic === 'string') ? req.body.topic.trim() : 'word-linear';
if (!POOL_TOPICS[topic]) return res.status(400).json({ error: 'unknown topic' });
let result;
try { result = await genService.generate(topic, { maxRetries: 3 }); }
catch (e) { return res.status(500).json({ error: 'generation failed' }); }
if (!result.ok) {
const code = (result.error === 'off') ? 503 : 422; // нет провайдера → 503; не проверилось → 422
return res.status(code).json({ error: result.error, reason: result.reason || null, attempts: result.attempts });
}
const p = result.problem;
const info = db.prepare(`
INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', ?)
`).run(topic, topic, 1, p.story, p.lhs, p.rhs, p.answerVar, p.answer, JSON.stringify(p.solution || []), req.user.id);
const row = db.prepare('SELECT * FROM practice_problems WHERE id = ?').get(info.lastInsertRowid);
res.json({ ok: true, problem: toClientProblem(row), attempts: result.attempts });
}
/* POST /api/practice/author — учитель пишет задачу ВРУЧНУЮ (без LLM).
* Та же проверка подстановкой (validateAndVerify): не сходится → 422, в пул не пишем. */
function authorProblem(req, res) {
const b = req.body || {};
const topic = (typeof b.topic === 'string') ? b.topic.trim() : 'word-linear';
if (!POOL_TOPICS[topic]) return res.status(400).json({ error: 'unknown topic' });
const v = genService.validateAndVerify({
story: b.story, lhs: b.lhs, rhs: b.rhs, answer: b.answer, answerVar: b.answerVar, solution: b.solution
});
if (!v.ok) return res.status(422).json({ error: 'verify', reason: v.reason });
const p = v.problem;
const info = db.prepare(`
INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status, created_by)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, 'approved', ?)
`).run(topic, topic, p.story, p.lhs, p.rhs, p.answerVar, p.answer, JSON.stringify(p.solution || []), req.user.id);
const row = db.prepare('SELECT * FROM practice_problems WHERE id = ?').get(info.lastInsertRowid);
res.json({ ok: true, problem: toClientProblem(row) });
}
/* POST /api/practice/assign { class_id, topic, title } — выдать тему классу.
* Адресное durable-уведомление каждому ученику (pushNotif → таблица + SSE), ссылка /trainer.
* Доступ: владелец класса или админ. */
function assignToClass(req, res) {
const uid = req.user.id, role = req.user.role;
const b = req.body || {};
const classId = parseInt(b.class_id, 10);
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
const title = (typeof b.title === 'string' ? b.title.trim() : '').slice(0, 200);
if (role !== 'admin') {
const own = db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, uid);
if (!own) return res.status(403).json({ error: 'не ваш класс' });
}
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId);
const msg = 'Тренажёр: ' + (title || 'новое задание для практики');
members.forEach(m => pushNotif(m.user_id, 'practice', msg, '/trainer'));
res.json({ ok: true, notified: members.length });
}
/* GET /api/practice/class-stats?class_id= — аналитика класса для учителя.
* Возвращает агрегаты по навыкам (кто застрял) + матрицу ученик×навык для
* тепловой карты. Доступ: владелец класса (teacher_id) или админ. */
function classStats(req, res) {
const uid = req.user.id, role = req.user.role;
const classId = parseInt((req.query && req.query.class_id), 10);
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
if (role !== 'admin') {
const own = db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, uid);
if (!own) return res.status(403).json({ error: 'не ваш класс' });
}
const students = db.prepare(
'SELECT u.id, u.name FROM class_members cm JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name'
).all(classId);
if (!students.length) return res.json({ students: [], skills: [], perSkill: [] });
const ids = students.map(s => s.id);
const ph = ids.map(() => '?').join(',');
const rows = db.prepare(
`SELECT user_id, skill, solved, attempts, mastered FROM practice_progress WHERE user_id IN (${ph})`
).all(...ids);
const bySkill = {}, byStudent = {};
for (const r of rows) {
const s = bySkill[r.skill] || (bySkill[r.skill] = { skill: r.skill, attempted: 0, solved: 0, attempts: 0, mastered: 0 });
s.attempted++; s.solved += r.solved; s.attempts += r.attempts; if (r.mastered) s.mastered++;
const st = byStudent[r.user_id] || (byStudent[r.user_id] = {});
st[r.skill] = { solved: r.solved, attempts: r.attempts, mastered: r.mastered ? 1 : 0,
accuracy: r.attempts ? Math.round(100 * r.solved / r.attempts) : 0 };
}
const skills = Object.keys(bySkill).sort();
const perSkill = skills.map(k => {
const s = bySkill[k];
return { skill: k, attempted: s.attempted, mastered: s.mastered,
accuracy: s.attempts ? Math.round(100 * s.solved / s.attempts) : 0 };
});
const studentRows = students.map(s => ({ id: s.id, name: s.name, perSkill: byStudent[s.id] || {} }));
res.json({ students: studentRows, skills, perSkill });
}
/* POST /api/practice/explain { display, answer, steps, studentAnswer, mode } — ИИ-репетитор.
* Объясняет ошибку (mode 'mistake') или даёт наводящую подсказку ('hint'), ОПИРАЯСЬ на уже
* известный правильный ответ и шаги (grounding — модель не считает). Доступ: любой
* авторизованный (тренируются ученики). Нет/выключен LLM → 503; клиент мягко падает на
* детерминированное решение. */
async function explainProblem(req, res) {
const b = req.body || {};
const mode = (b.mode === 'hint') ? 'hint' : 'mistake';
const display = (typeof b.display === 'string') ? b.display : '';
if (!display.trim()) return res.status(400).json({ error: 'no problem' });
const answer = (typeof b.answer === 'string') ? b.answer : String(b.answer == null ? '' : b.answer);
const steps = Array.isArray(b.steps)
? b.steps.slice(0, 8).map(s => ({ note: (s && s.note) || '', tex: (s && s.tex) || '' }))
: [];
const studentAnswer = (typeof b.studentAnswer === 'string') ? b.studentAnswer : String(b.studentAnswer == null ? '' : b.studentAnswer);
let result;
try { result = await explainService.explain({ problem: { display, answer, solution: steps }, studentAnswer, mode }); }
catch (e) { return res.status(500).json({ error: 'explain failed' }); }
if (!result.ok) {
const code = (result.error === 'off' || result.error === 'ask-threw') ? 503 : 422;
return res.status(code).json({ error: result.error });
}
res.json({ ok: true, text: result.text, mode: result.mode });
}
module.exports = { listProgress, submitAttempt, listPool, generateProblem, authorProblem, assignToClass, classStats, explainProblem };
+51 -13
View File
@@ -1,5 +1,17 @@
const db = require('../db/db'); const db = require('../db/db');
/* Вопросы теста без зафиксированного правильного ответа (нет верного варианта И
* нет correct_text). matching исключаем (там ответ — пары match_pair).
* Такой вопрос нельзя оценить → не пускаем тест к ученикам. */
function unanswerableInTest(testId) {
return db.prepare(`
SELECT tq.question_id AS id FROM test_questions tq JOIN questions q ON q.id = tq.question_id
WHERE tq.test_id = ? AND q.type <> 'matching'
AND (q.correct_text IS NULL OR TRIM(q.correct_text) = '')
AND NOT EXISTS (SELECT 1 FROM options o WHERE o.question_id = q.id AND o.is_correct = 1)
`).all(testId).map(r => r.id);
}
/* ── GET /api/tests ─────────────────────────────────────────────────────── */ /* ── GET /api/tests ─────────────────────────────────────────────────────── */
function list(req, res) { function list(req, res) {
const { subject } = req.query; const { subject } = req.query;
@@ -7,13 +19,16 @@ function list(req, res) {
const args = []; const args = [];
let where = '1=1'; let where = '1=1';
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); } if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); } const isStudent = role === 'student' || role === 'free_student';
// Ученик видит каталог тестов, помеченных доступными; учитель — только свои; админ — все.
if (isStudent) { where += ' AND t.available_to_students = 1'; }
else if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js), // Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
// не показываем их во вкладке «Тесты (шаблоны)» админки. // не показываем их во вкладке «Тесты (шаблоны)» админки.
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)'; where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
const rows = db.prepare(` let rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, t.available_to_students,
u.name AS creator_name, u.name AS creator_name,
COUNT(tq.question_id) AS question_count COUNT(tq.question_id) AS question_count
FROM tests t FROM tests t
@@ -22,18 +37,19 @@ function list(req, res) {
WHERE ${where} WHERE ${where}
GROUP BY t.id ORDER BY t.created_at DESC GROUP BY t.id ORDER BY t.created_at DESC
`).all(...args); `).all(...args);
if (isStudent) rows = rows.filter(r => r.question_count > 0); // пустые тесты ученику не показываем
res.json(rows); res.json(rows);
} }
/* ── POST /api/tests ─────────────────────────────────────────────────────── */ /* ── POST /api/tests ─────────────────────────────────────────────────────── */
function create(req, res) { function create(req, res) {
const { title, subject_slug, description, show_answers = 1, time_limit } = req.body; const { title, subject_slug, description, show_answers = 1, time_limit, available_to_students = 0 } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' }); if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' }); if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null; const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
const r = db.prepare( const r = db.prepare(
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, available_to_students, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, req.user.id); ).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, available_to_students ? 1 : 0, req.user.id);
res.status(201).json({ id: r.lastInsertRowid }); res.status(201).json({ id: r.lastInsertRowid });
} }
@@ -45,6 +61,18 @@ function getOne(req, res) {
`).get(req.params.id); `).get(req.params.id);
if (!t) return res.status(404).json({ error: 'Not found' }); if (!t) return res.status(404).json({ error: 'Not found' });
// Доступ как в list(): ученик видит только помеченные доступными и не служебные
// экзамен-варианты; учитель — только свои; админ — все. Иначе по id можно было бы
// прочитать тексты заданий из черновиков/вариантов.
const { role, id: uid } = req.user;
const isStudent = role === 'student' || role === 'free_student';
if (isStudent) {
const isVariant = db.prepare('SELECT 1 FROM exam9_variant_tests WHERE test_id = ?').get(t.id);
if (!t.available_to_students || isVariant) return res.status(404).json({ error: 'Not found' });
} else if (role !== 'admin' && t.created_by !== uid) {
return res.status(404).json({ error: 'Not found' });
}
const questions = db.prepare(` const questions = db.prepare(`
SELECT q.id, q.text, q.type, q.difficulty, q.explanation, SELECT q.id, q.text, q.type, q.difficulty, q.explanation,
tp.name AS topic, s.name AS subject_name, tp.name AS topic, s.name AS subject_name,
@@ -76,13 +104,23 @@ function getOne(req, res) {
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */ /* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
function update(req, res) { function update(req, res) {
const { title, subject_slug, description, show_answers, time_limit } = req.body; const b = req.body;
const t = req.resource; // ownership verified by requireOwnership middleware const t = req.resource; // ownership verified by requireOwnership middleware
const tl = time_limit !== undefined ? (time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null) : undefined; // Частичный апдейт: НЕ переданные поля сохраняем из текущей строки (иначе toggleTstAvail,
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ? WHERE id = ?') // присылающий только available_to_students, обнулил бы title/subject и т.п.).
.run(title?.trim(), subject_slug, description?.trim() || null, show_answers === undefined ? 1 : (show_answers ? 1 : 0), const title = b.title !== undefined ? (b.title?.trim() || t.title) : t.title;
tl !== undefined ? tl : t.time_limit, const subject_slug = b.subject_slug !== undefined ? b.subject_slug : t.subject_slug;
t.id); const description = b.description !== undefined ? (b.description?.trim() || null) : t.description;
const show_answers = b.show_answers !== undefined ? (b.show_answers ? 1 : 0) : t.show_answers;
const time_limit = b.time_limit !== undefined ? (b.time_limit ? Math.max(1, Math.min(600, Number(b.time_limit))) : null) : t.time_limit;
const available = b.available_to_students !== undefined ? (b.available_to_students ? 1 : 0) : t.available_to_students;
// Гард целостности: нельзя публиковать тест ученикам с вопросами без правильного ответа.
if (available === 1) {
const broken = unanswerableInTest(t.id);
if (broken.length) return res.status(400).json({ error: `Нельзя опубликовать: ${broken.length} вопрос(ов) без правильного ответа. Исправьте их в банке.`, brokenQuestions: broken });
}
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ?, available_to_students = ? WHERE id = ?')
.run(title, subject_slug, description, show_answers, time_limit, available, t.id);
res.json({ ok: true }); res.json({ ok: true });
} }
+104
View File
@@ -0,0 +1,104 @@
'use strict';
const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
const { pushNotif } = require('../utils/notifications');
const CATEGORIES = ['ui', 'content', 'feature', 'bug', 'other'];
const STATUSES = ['new', 'planned', 'in_progress', 'done', 'declined'];
const STATUS_LABEL = {
new: 'Новое', planned: 'Запланировано', in_progress: 'В работе',
done: 'Готово', declined: 'Отклонено',
};
function clampStr(v, max) {
return stripTags(String(v == null ? '' : v)).slice(0, max).trim();
}
/* ── GET /api/wishes ── список: админ видит все (с фильтрами), остальные — свои ── */
function list(req, res) {
const isAdmin = req.user.role === 'admin';
const { status, category } = req.query;
const where = [];
const args = [];
if (!isAdmin) { where.push('w.user_id = ?'); args.push(req.user.id); }
if (status && STATUSES.includes(status)) { where.push('w.status = ?'); args.push(status); }
if (category && CATEGORIES.includes(category)) { where.push('w.category = ?'); args.push(category); }
const sql = `
SELECT w.id, w.user_id, w.title, w.body, w.category, w.status, w.admin_note,
w.created_at, w.updated_at,
${isAdmin ? 'u.name AS author_name, u.email AS author_email,' : ''}
0 AS _pad
FROM wishes w
${isAdmin ? 'JOIN users u ON u.id = w.user_id' : ''}
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY CASE w.status WHEN 'new' THEN 0 WHEN 'planned' THEN 1 WHEN 'in_progress' THEN 2 ELSE 3 END,
w.updated_at DESC`;
const rows = db.prepare(sql).all(...args);
let counts = null;
if (isAdmin) {
counts = {};
for (const r of db.prepare('SELECT status, COUNT(*) c FROM wishes GROUP BY status').all()) counts[r.status] = r.c;
}
res.json({ wishes: rows, counts, isAdmin });
}
/* ── POST /api/wishes ── создать (любой авторизованный) ── */
function create(req, res) {
const title = clampStr(req.body?.title, 200);
if (!title) return res.status(400).json({ error: 'Заголовок обязателен' });
const body = clampStr(req.body?.body, 4000);
let category = String(req.body?.category || 'other');
if (!CATEGORIES.includes(category)) category = 'other';
const info = db.prepare(
`INSERT INTO wishes (user_id, title, body, category) VALUES (?,?,?,?)`
).run(req.user.id, title, body || null, category);
const row = db.prepare('SELECT * FROM wishes WHERE id = ?').get(Number(info.lastInsertRowid));
res.status(201).json(row);
}
/* ── PATCH /api/wishes/:id ── триаж: статус + ответ (только админ) ── */
function update(req, res) {
const wish = db.prepare('SELECT * FROM wishes WHERE id = ?').get(req.params.id);
if (!wish) return res.status(404).json({ error: 'Не найдено' });
const fields = [];
const args = [];
let newStatus = null;
if (req.body?.status !== undefined) {
if (!STATUSES.includes(req.body.status)) return res.status(400).json({ error: 'Неверный статус' });
if (req.body.status !== wish.status) newStatus = req.body.status;
fields.push('status = ?'); args.push(req.body.status);
}
if (req.body?.admin_note !== undefined) {
fields.push('admin_note = ?'); args.push(clampStr(req.body.admin_note, 2000) || null);
}
if (!fields.length) return res.status(400).json({ error: 'Нет изменений' });
fields.push("updated_at = datetime('now')");
db.prepare(`UPDATE wishes SET ${fields.join(', ')} WHERE id = ?`).run(...args, wish.id);
// Уведомить автора при смене статуса (durable + SSE).
if (newStatus && wish.user_id !== req.user.id) {
try {
pushNotif(wish.user_id, 'wish_update',
`Ваше пожелание «${wish.title}»: ${STATUS_LABEL[newStatus] || newStatus}`, '/wishes');
} catch { /* notif не критичен */ }
}
res.json(db.prepare('SELECT * FROM wishes WHERE id = ?').get(wish.id));
}
/* ── DELETE /api/wishes/:id ── автор (пока «новое») или админ ── */
function remove(req, res) {
const wish = db.prepare('SELECT id, user_id, status FROM wishes WHERE id = ?').get(req.params.id);
if (!wish) return res.status(404).json({ error: 'Не найдено' });
const isAdmin = req.user.role === 'admin';
const isOwner = wish.user_id === req.user.id;
if (!isAdmin && !(isOwner && wish.status === 'new'))
return res.status(403).json({ error: 'Удалять можно только своё необработанное пожелание' });
db.prepare('DELETE FROM wishes WHERE id = ?').run(wish.id);
res.json({ ok: true });
}
module.exports = { list, create, update, remove, CATEGORIES, STATUSES };
+1
View File
@@ -48,4 +48,5 @@ db.transaction = function transaction(fn) {
}; };
}; };
db._path = dbPath; // абсолютный путь к файлу БД (нужен бэкапу при сбросе системы)
module.exports = db; module.exports = db;
@@ -0,0 +1,4 @@
-- Витрина тестов для ученика: флаг «тест доступен ученикам».
-- Учитель/админ помечает свой тест доступным → он появляется в каталоге у учеников
-- (дашборд, виджет «Тесты»). По умолчанию 0 — тест виден только автору в конструкторе.
ALTER TABLE tests ADD COLUMN available_to_students INTEGER NOT NULL DEFAULT 0;
+15
View File
@@ -0,0 +1,15 @@
-- 080_wishes.sql — трекер пожеланий по улучшению системы.
-- Любой пользователь подаёт пожелание; видит только свои. Админ видит все и ведёт по статусам.
CREATE TABLE IF NOT EXISTS wishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT,
category TEXT NOT NULL DEFAULT 'other', -- ui | content | feature | bug | other
status TEXT NOT NULL DEFAULT 'new', -- new | planned | in_progress | done | declined
admin_note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_wishes_user ON wishes(user_id);
CREATE INDEX IF NOT EXISTS idx_wishes_status ON wishes(status);
@@ -0,0 +1,31 @@
-- ═══════════════════════════════════════════════════════════════
-- 081: Practice progress (ИИ-тренажёр, Фаза 0).
--
-- Прогресс ученика по НАВЫКАМ тренажёра. Навык = skill генератора
-- (напр. 'linear-basic'); задачи генерируются на клиенте детерминированно
-- и проверяются подстановкой — сервер хранит лишь агрегаты результата.
--
-- На каждую попытку клиент шлёт { skill, correct }. Сервер делает upsert:
-- solved — всего верных ответов
-- attempts — всего попыток (верных и нет)
-- cur_streak — текущая серия верных подряд (обнуляется ошибкой)
-- best_streak — лучшая серия
-- mastered — 1, как только cur_streak достиг порога (липкое)
-- UNIQUE(user_id, skill) — одна строка на пару ученик-навык.
-- user_id ON DELETE CASCADE — прогресс удаляется вместе с учеником.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS practice_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
skill TEXT NOT NULL, -- идентификатор навыка генератора
solved INTEGER NOT NULL DEFAULT 0, -- всего верных ответов
attempts INTEGER NOT NULL DEFAULT 0, -- всего попыток
cur_streak INTEGER NOT NULL DEFAULT 0, -- текущая серия верных подряд
best_streak INTEGER NOT NULL DEFAULT 0, -- лучшая серия
mastered INTEGER NOT NULL DEFAULT 0, -- 1, когда серия достигала порога
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE (user_id, skill)
);
CREATE INDEX IF NOT EXISTS idx_practice_progress_user ON practice_progress (user_id);
@@ -0,0 +1,13 @@
-- ═══════════════════════════════════════════════════════════════
-- 082: SR-поля тренажёра (интервальное повторение по навыкам, Фаза 2).
--
-- К practice_progress добавляем Leitner-«коробку» и срок следующего показа:
-- box — уровень 0..5 (выше = увереннее освоено, реже повторяем).
-- due_at — когда навык снова стоит показать (datetime). NULL = «как можно скорее».
-- На верный ответ box растёт и срок отодвигается; на ошибку box сбрасывается в 0
-- и срок = сейчас (навык всплывёт первым при следующем заходе). Адаптивный
-- подборщик на клиенте показывает «просроченные» навыки (due_at <= now) раньше.
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE practice_progress ADD COLUMN box INTEGER NOT NULL DEFAULT 0;
ALTER TABLE practice_progress ADD COLUMN due_at TEXT;
@@ -0,0 +1,28 @@
-- ═══════════════════════════════════════════════════════════════
-- 083: Пул текстовых задач тренажёра (Уровень 1, Фаза 3).
--
-- Кэш сгенерированных LLM и ПРОВЕРЕННЫХ задач: модель предлагает условие +
-- уравнение (lhs/rhs) + корень, сервер подтверждает подстановкой (practiceVerify)
-- и только тогда пишет сюда. Ученик берёт готовые задачи из пула (не платим за
-- генерацию на каждый показ). story и заметки решения уже санитизированы.
-- status: approved (видна ученикам) | draft (на ревью учителю).
-- created_by ON DELETE SET NULL — задача переживает удаление автора.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS practice_problems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL, -- word-linear | word-proportion | word-percent
skill TEXT NOT NULL, -- ключ навыка (для прогресса)
difficulty INTEGER NOT NULL DEFAULT 1,
story TEXT NOT NULL, -- условие словами (экранировано)
lhs TEXT NOT NULL, -- левая часть уравнения (выражение от x)
rhs TEXT NOT NULL, -- правая часть
answer_var TEXT NOT NULL DEFAULT 'x',
answer REAL NOT NULL, -- проверенный корень
solution_json TEXT, -- шаги [{note,tex}] (JSON)
status TEXT NOT NULL DEFAULT 'approved', -- approved | draft
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_practice_problems_skill ON practice_problems (skill, status);
@@ -0,0 +1,24 @@
-- ═══════════════════════════════════════════════════════════════
-- 084: Пользовательские генераторы тренажёра (конструктор, Roadmap P13).
--
-- Учитель создаёт ПАРАМЕТРИЧЕСКИЙ генератор задач — это ДАННЫЕ (spec_json):
-- диапазоны pick, формулы derive, шаблоны lhs/rhs, ответ, шаги решения. На
-- клиенте спек исполняет БЕЗОПАСНЫЙ SimExpr (⛔ без eval), на сервере он только
-- хранится и валидируется по структуре/лимитам (НЕ исполняется). Прогресс по
-- такому навыку ключуется как 'cg<id>'.
-- status: draft (видит только автор) | published (видят и ученики).
-- owner_id ON DELETE CASCADE — генераторы удаляются вместе с автором.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS custom_generators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
topic TEXT NOT NULL DEFAULT 'custom',
spec_json TEXT NOT NULL, -- полный спек генератора (данные)
status TEXT NOT NULL DEFAULT 'draft', -- draft | published
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_custom_generators_owner ON custom_generators (owner_id, status);
+17 -1
View File
@@ -119,6 +119,22 @@ function parentAuth(req, res, next) {
} }
} }
/**
* requirePermissionForStudents(key) — применяет проверку права ТОЛЬКО к ролям
* ученика (student/free_student); учитель и админ проходят всегда.
* Нужно для роутов, которыми пользуются и учителя, и ученики (ассистент,
* материалы, игры, флеш-карты, exam-prep): ученическое право не должно ломать
* доступ учителю (у учителя нет записи по ключу → isEnabled вернул бы false).
*/
function requirePermissionForStudents(key) {
const guard = requirePermission(key);
return (req, res, next) => {
const r = req.user?.role;
if (r === 'student' || r === 'free_student') return guard(req, res, next);
return next();
};
}
/* Alias: requireAuth = authMiddleware */ /* Alias: requireAuth = authMiddleware */
const requireAuth = authMiddleware; const requireAuth = authMiddleware;
@@ -151,4 +167,4 @@ function optionalAuth(req, res, next) {
next(); next();
} }
module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, perm, parentAuth, effectiveRoles }; module.exports = { authMiddleware, requireAuth, optionalAuth, requireRole, requirePermission, requirePermissionForStudents, perm, parentAuth, effectiveRoles };
+14 -1
View File
@@ -29,6 +29,19 @@
* if (feats.exam9 === false) { ... } * if (feats.exam9 === false) { ... }
*/ */
const db = require('../db/db'); const db = require('../db/db');
const jwt = require('jsonwebtoken');
// Админ-оверрайд: requireFeature идёт ДО authMiddleware (req.user ещё нет),
// поэтому декодируем Bearer-токен сами — админ открывает и отключённые модули
// (зеркалит фронтовый _isAdminUser, см. project_gamification_killswitch).
function _isAdminReq(req) {
try {
const h = req.headers.authorization || '';
if (!h.startsWith('Bearer ')) return false;
const p = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
return !!(p && p.role === 'admin');
} catch (e) { return false; }
}
const _stmtSingle = db.prepare("SELECT value FROM app_settings WHERE key = ?"); const _stmtSingle = db.prepare("SELECT value FROM app_settings WHERE key = ?");
const _stmtGlobalFeats = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'"); const _stmtGlobalFeats = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'");
@@ -41,7 +54,7 @@ function requireFeature(name) {
const settingKey = `feature_${name}_enabled`; const settingKey = `feature_${name}_enabled`;
return (req, res, next) => { return (req, res, next) => {
const row = _stmtSingle.get(settingKey); const row = _stmtSingle.get(settingKey);
if (row && row.value === '0') { if (row && row.value === '0' && !_isAdminReq(req)) { // админ проходит к API даже выключенного модуля
return res.status(404).json({ error: 'Feature disabled' }); return res.status(404).json({ error: 'Feature disabled' });
} }
next(); next();
+59
View File
@@ -115,6 +115,28 @@ const PERMISSIONS = {
label: 'Управление геймификацией', label: 'Управление геймификацией',
desc: 'Начислять XP/монеты ученикам, управлять достижениями', desc: 'Начислять XP/монеты ученикам, управлять достижениями',
}, },
'classroom.host': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Вести онлайн-уроки',
desc: 'Запускать синхронные онлайн-уроки (classroom) с доской для класса',
requireConfirmOff: true,
},
'livequiz.host': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Запускать живые викторины',
desc: 'Создавать и проводить синхронные викторины в реальном времени',
},
'simbuilder.use': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Конструктор симуляций',
desc: 'Создавать и редактировать собственные интерактивные симуляции',
requireConfirmOff: true,
},
'flashcards.manage': {
role: 'teacher', roles: ['teacher'], default: 1,
label: 'Общие колоды флеш-карт',
desc: 'Создавать и раздавать общие колоды флеш-карт классам',
},
/* ── Student (also applies to free_student — same keys, same defaults) ── */ /* ── Student (also applies to free_student — same keys, same defaults) ── */
'tests.free': { 'tests.free': {
@@ -160,6 +182,38 @@ const PERMISSIONS = {
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)', desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
requires: ['simulations.access'], requires: ['simulations.access'],
}, },
'homework.submit': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Сдавать домашние задания',
desc: 'Загружать работы и пересдавать домашние задания',
},
'materials.save': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Сохранять материалы',
desc: 'Сохранять доску/заметки/рисунки в раздел «Мои материалы»',
},
'assistant.use': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'ИИ-ассистент',
desc: 'Задавать вопросы ИИ-ассистенту «Квантик»',
},
'flashcards.access': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Раздел флеш-карт доступен роли',
desc: 'Включает раздел флеш-карт для роли. Какие именно колоды видны — настраивается по классам в «Доступ · контент»',
requireConfirmOff: true,
},
'exam.access': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Подготовка к экзаменам доступна роли',
desc: 'Включает разделы подготовки к экзаменам/ЦТ для роли. Какие именно модули видны — настраивается в «Доступ · контент»',
requireConfirmOff: true,
},
'games.play': {
role: 'student', roles: ['student', 'free_student'], default: 1,
label: 'Учебные игры',
desc: 'Играть в учебные мини-игры (Виселица, Кроссворд)',
},
}; };
/* Группы для секций в админ-UI (один источник; byRole проставляет group). */ /* Группы для секций в админ-UI (один источник; byRole проставляет group). */
@@ -169,15 +223,20 @@ const GROUP = {
'students.invite': 'Класс и ученики', 'sessions.reset': 'Класс и ученики', 'students.invite': 'Класс и ученики', 'sessions.reset': 'Класс и ученики',
'results.export': 'Класс и ученики', 'classes.manage': 'Класс и ученики', 'results.export': 'Класс и ученики', 'classes.manage': 'Класс и ученики',
'schedule.manage': 'Класс и ученики', 'announcements.send': 'Класс и ученики', 'schedule.manage': 'Класс и ученики', 'announcements.send': 'Класс и ученики',
'classroom.host': 'Класс и ученики', 'livequiz.host': 'Класс и ученики',
'library.upload': 'Библиотека', 'library.folders': 'Библиотека', 'library.upload': 'Библиотека', 'library.folders': 'Библиотека',
'templates.manage': 'Курсы и шаблоны', 'templates.public': 'Курсы и шаблоны', 'templates.manage': 'Курсы и шаблоны', 'templates.public': 'Курсы и шаблоны',
'courses.manage': 'Курсы и шаблоны', 'courses.interactive': 'Курсы и шаблоны', 'courses.manage': 'Курсы и шаблоны', 'courses.interactive': 'Курсы и шаблоны',
'simbuilder.use': 'Курсы и шаблоны', 'flashcards.manage': 'Курсы и шаблоны',
'shop.manage': 'Геймификация', 'gamification.manage': 'Геймификация', 'shop.manage': 'Геймификация', 'gamification.manage': 'Геймификация',
// student // student
'tests.free': 'Тесты и активность', 'board.post': 'Тесты и активность', 'tests.free': 'Тесты и активность', 'board.post': 'Тесты и активность',
'homework.submit': 'Тесты и активность', 'materials.save': 'Тесты и активность',
'assistant.use': 'Тесты и активность', 'games.play': 'Тесты и активность',
'profile.edit': 'Профиль', 'profile.edit': 'Профиль',
'shop.purchase': 'Геймификация', 'gamification.challenges': 'Геймификация', 'shop.purchase': 'Геймификация', 'gamification.challenges': 'Геймификация',
'theory.access': 'Контент', 'simulations.access': 'Контент', 'simulations.quiz': 'Контент', 'theory.access': 'Контент', 'simulations.access': 'Контент', 'simulations.quiz': 'Контент',
'flashcards.access': 'Контент', 'exam.access': 'Контент',
}; };
/** /**
+9
View File
@@ -13,11 +13,20 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF
/* Everything below is admin-only */ /* Everything below is admin-only */
router.use(requireRole('admin')); router.use(requireRole('admin'));
/* ⚠️ Сброс системы «чистый запуск» — деструктивно, только admin */
router.get('/reset-system/plan', requireRole('admin'), ctrl.getResetPlan);
router.post('/reset-system', requireRole('admin'), ctrl.resetSystem);
router.get('/assistant', ctrl.getAssistant); router.get('/assistant', ctrl.getAssistant);
router.put('/assistant', ctrl.saveAssistant); router.put('/assistant', ctrl.saveAssistant);
router.post('/assistant/test', ctrl.testAssistant); router.post('/assistant/test', ctrl.testAssistant);
router.post('/assistant/reindex', ctrl.reindexTextbooks); router.post('/assistant/reindex', ctrl.reindexTextbooks);
router.get('/assistant/models', ctrl.getProviderModels); router.get('/assistant/models', ctrl.getProviderModels);
router.post('/assistant/scan', ctrl.scanModels);
router.post('/assistant/probe', ctrl.probeModel);
router.post('/assistant/models/apply', ctrl.applyModels);
router.post('/assistant/health', ctrl.runHealth);
router.post('/assistant/index-system', ctrl.indexSystem);
router.get('/imggen', ctrl.getImggen); router.get('/imggen', ctrl.getImggen);
router.put('/imggen', ctrl.saveImggen); router.put('/imggen', ctrl.saveImggen);
router.post('/imggen/test', ctrl.testImggen); router.post('/imggen/test', ctrl.testImggen);
+5 -3
View File
@@ -2,7 +2,7 @@
/* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт /* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт
* 'pet' навешивается при монтировании в server.js. */ * 'pet' навешивается при монтировании в server.js. */
const router = require('express').Router(); const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit'); const rateLimit = require('../middleware/rateLimit');
const ctrl = require('../controllers/assistantController'); const ctrl = require('../controllers/assistantController');
@@ -16,8 +16,10 @@ router.get('/context', ctrl.getContext);
router.post('/seen', ctrl.markSeen); router.post('/seen', ctrl.markSeen);
router.post('/dismiss', ctrl.dismiss); router.post('/dismiss', ctrl.dismiss);
router.patch('/settings', ctrl.setSettings); router.patch('/settings', ctrl.setSettings);
router.post('/ask', askLimiter, ctrl.ask); router.post('/ask', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.ask);
router.post('/flashcards', fcLimiter, ctrl.flashcardsFromText); router.post('/ask/stream', requirePermissionForStudents('assistant.use'), askLimiter, ctrl.askStream);
router.post('/flashcards', requirePermissionForStudents('assistant.use'), fcLimiter, ctrl.flashcardsFromText);
router.post('/questions', requireRole('teacher', 'admin'), fcLimiter, ctrl.questionsFromText);
router.post('/feedback', ctrl.feedback); router.post('/feedback', ctrl.feedback);
router.get('/memory', ctrl.getMemory); router.get('/memory', ctrl.getMemory);
router.delete('/memory', ctrl.clearMemory); router.delete('/memory', ctrl.clearMemory);
+4 -1
View File
@@ -4,6 +4,7 @@ const multer = require('multer');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole } = require('../middleware/auth');
const { safeExt } = require('../utils/magic');
const ctrl = require('../controllers/avatarController'); const ctrl = require('../controllers/avatarController');
/* ── multer: avatars only, 2 MB ────────────────────────────────────────── */ /* ── multer: avatars only, 2 MB ────────────────────────────────────────── */
@@ -13,7 +14,9 @@ const AVATAR_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: AVATARS_DIR, destination: AVATARS_DIR,
filename: (_req, file, cb) => { filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase(); // Расширение — из проверенного MIME (fileFilter уже сузил до image/*),
// НЕ из client-controlled originalname (иначе .html/.svg → stored-XSS).
const ext = safeExt(file.mimetype, '.png');
const name = crypto.randomBytes(16).toString('hex') + ext; const name = crypto.randomBytes(16).toString('hex') + ext;
cb(null, name); cb(null, name);
}, },
+1
View File
@@ -37,6 +37,7 @@ router.delete('/:id', requireRole('teacher','admin'), requirePermission('
router.post('/:id/new-code', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.regenerateCode); router.post('/:id/new-code', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.regenerateCode);
router.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal); router.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal);
router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv); router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv);
router.get('/:id/outstanding', requireRole('teacher','admin'), assignCtrl.classOutstanding);
router.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember); router.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember);
router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember); router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember);
router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment); router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment);
+6 -4
View File
@@ -2,7 +2,8 @@ const router = require('express').Router();
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const { safeExt } = require('../utils/magic');
const rateLimit = require('../middleware/rateLimit'); const rateLimit = require('../middleware/rateLimit');
const c = require('../controllers/classroomController'); const c = require('../controllers/classroomController');
@@ -11,8 +12,9 @@ const _chatUploadsDir = path.join(__dirname, '../../uploads/chat');
const _chatStorage = multer.diskStorage({ const _chatStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, _chatUploadsDir), destination: (req, file, cb) => cb(null, _chatUploadsDir),
filename: (req, file, cb) => { filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, ''); // Расширение из проверенного MIME, НЕ из originalname (иначе .html/.svg → stored-XSS,
cb(null, crypto.randomBytes(14).toString('hex') + ext); // если каталог chat начнут раздавать статикой).
cb(null, crypto.randomBytes(14).toString('hex') + safeExt(file.mimetype, '.png'));
}, },
}); });
const chatUpload = multer({ const chatUpload = multer({
@@ -47,7 +49,7 @@ router.get('/my/history', ...auth, c.getMyHistory);
router.get('/class/:classId/history', ...auth, c.getClassHistory); router.get('/class/:classId/history', ...auth, c.getClassHistory);
// Session lifecycle // Session lifecycle
router.post('/', ...teacher, c.createSession); router.post('/', ...teacher, requirePermission('classroom.host'), c.createSession);
router.get('/online-students', ...teacher, c.getOnlineStudents); router.get('/online-students', ...teacher, c.getOnlineStudents);
router.get('/my/session', ...auth, c.getMySession); router.get('/my/session', ...auth, c.getMySession);
router.get('/class/:classId/active', ...auth, c.getActiveSession); router.get('/class/:classId/active', ...auth, c.getActiveSession);
+11
View File
@@ -0,0 +1,11 @@
'use strict';
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const rateLimit = require('../middleware/rateLimit');
const ctrl = require('../controllers/clientErrorController');
router.use(authMiddleware);
// Не больше 20 отчётов в минуту с пользователя — защита от флуда циклящихся ошибок.
router.post('/', rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много отчётов об ошибках' }), ctrl.report);
module.exports = router;
+3 -3
View File
@@ -5,7 +5,7 @@
* НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */ * НЕ blanket requireRole на роутере: список/чтение доступны и ученику (published). */
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const { requireFeature } = require('../middleware/features'); const { requireFeature } = require('../middleware/features');
const c = require('../controllers/customSimController'); const c = require('../controllers/customSimController');
@@ -22,9 +22,9 @@ router.get('/:id', c.get);
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler // @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
router.get('/:id/related', c.related); router.get('/:id/related', c.related);
router.post('/', gate, requireRole('teacher', 'admin'), c.create); router.post('/', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.create);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.put('/:id', gate, requireRole('teacher', 'admin'), c.update); router.put('/:id', gate, requireRole('teacher', 'admin'), requirePermission('simbuilder.use'), c.update);
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove); router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
+8 -1
View File
@@ -1,10 +1,13 @@
'use strict'; 'use strict';
const router = require('express').Router(); const router = require('express').Router();
const db = require('../db/db'); const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
const access = require('../services/contentAccess'); const access = require('../services/contentAccess');
router.use(authMiddleware); router.use(authMiddleware);
// Ролевой доступ к подготовке к экзаменам: ученик без права exam.access закрыт;
// учитель/админ проходят всегда. Видимость конкретных модулей — в «Доступ · контент».
router.use(requirePermissionForStudents('exam.access'));
/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist. /* Гейт доступа: любой маршрут с :examKey проверяется по allowlist.
Админ/учитель проходят всегда; ученик — только при наличии правила. */ Админ/учитель проходят всегда; ученик — только при наличии правила. */
@@ -57,6 +60,10 @@ const VARIANT_LABEL = {
115: 'ЦТ-2019', 115: 'ЦТ-2019',
116: 'ЦТ-2020', 116: 'ЦТ-2020',
117: 'ЦТ-2021', 117: 'ЦТ-2021',
118: 'ЦТ-2017',
119: 'ЦТ-2013',
120: 'ЦТ-2012',
121: 'ЦТ-2011',
}, },
}; };
const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`; const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`;
+9 -5
View File
@@ -5,8 +5,9 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const fc = require('../controllers/flashcardController'); const fc = require('../controllers/flashcardController');
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole, requirePermission, requirePermissionForStudents } = require('../middleware/auth');
const { requireOwnership } = require('../middleware/ownership'); const { requireOwnership } = require('../middleware/ownership');
const { safeExt } = require('../utils/magic');
/* ── multer для картинок карточек ─────────────────────────────────────── /* ── multer для картинок карточек ───────────────────────────────────────
Файлы складываем в backend/uploads/flashcards, отдаём статикой через Файлы складываем в backend/uploads/flashcards, отдаём статикой через
@@ -18,8 +19,8 @@ if (!fs.existsSync(_fcUploadsDir)) fs.mkdirSync(_fcUploadsDir, { recursive: true
const _fcStorage = multer.diskStorage({ const _fcStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, _fcUploadsDir), destination: (req, file, cb) => cb(null, _fcUploadsDir),
filename: (req, file, cb) => { filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, ''); // Расширение из проверенного MIME, НЕ из originalname (иначе .html/.svg → stored-XSS).
cb(null, crypto.randomBytes(14).toString('hex') + (ext || '.png')); cb(null, crypto.randomBytes(14).toString('hex') + safeExt(file.mimetype, '.png'));
}, },
}); });
const fcUpload = multer({ const fcUpload = multer({
@@ -30,6 +31,9 @@ const fcUpload = multer({
}); });
router.use(authMiddleware); router.use(authMiddleware);
// Ролевой доступ к разделу флеш-карт: ученик без права flashcards.access закрыт;
// учитель/админ проходят всегда (создают и раздают колоды).
router.use(requirePermissionForStudents('flashcards.access'));
router.post ('/upload', fcUpload.single('file'), fc.uploadImage); router.post ('/upload', fcUpload.single('file'), fc.uploadImage);
@@ -45,8 +49,8 @@ router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards); router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
// Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере). // Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере).
router.get ('/decks/:id/shares', fc.listShares); router.get ('/decks/:id/shares', fc.listShares);
router.post ('/decks/:id/share', requireRole('teacher','admin'), fc.addShare); router.post ('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.addShare);
router.delete('/decks/:id/share', requireRole('teacher','admin'), fc.removeShare); router.delete('/decks/:id/share', requireRole('teacher','admin'), requirePermission('flashcards.manage'), fc.removeShare);
router.get ('/decks/:id/study', fc.getStudySession); router.get ('/decks/:id/study', fc.getStudySession);
router.put ('/cards/:id', fc.updateCard); router.put ('/cards/:id', fc.updateCard);
router.delete('/cards/:id', fc.deleteCard); router.delete('/cards/:id', fc.deleteCard);
+7 -5
View File
@@ -1,14 +1,16 @@
const router = require('express').Router(); const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware, requirePermissionForStudents } = require('../middleware/auth');
const { requireFeature } = require('../middleware/features'); const { requireFeature } = require('../middleware/features');
const c = require('../controllers/gamesController'); const c = require('../controllers/gamesController');
const hangman = requireFeature('hangman'); const hangman = requireFeature('hangman');
const crossword = requireFeature('crossword'); const crossword = requireFeature('crossword');
// Ролевой доступ к учебным играм: ученик без права games.play закрыт, учитель/админ — нет.
const playable = requirePermissionForStudents('games.play');
router.get('/hangman/word', hangman, authMiddleware, c.hangmanWord); router.get('/hangman/word', hangman, authMiddleware, playable, c.hangmanWord);
router.post('/hangman/complete', hangman, authMiddleware, c.hangmanComplete); router.post('/hangman/complete', hangman, authMiddleware, playable, c.hangmanComplete);
router.get('/crossword/generate', crossword, authMiddleware, c.crosswordGenerate); router.get('/crossword/generate', crossword, authMiddleware, playable, c.crosswordGenerate);
router.post('/crossword/complete', crossword, authMiddleware, c.crosswordComplete); router.post('/crossword/complete', crossword, authMiddleware, playable, c.crosswordComplete);
module.exports = router; module.exports = router;
+2 -2
View File
@@ -1,10 +1,10 @@
const router = require('express').Router(); const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const c = require('../controllers/liveController'); const c = require('../controllers/liveController');
const teacher = [authMiddleware, requireRole('teacher', 'admin')]; const teacher = [authMiddleware, requireRole('teacher', 'admin')];
router.post('/', ...teacher, c.create); router.post('/', ...teacher, requirePermission('livequiz.host'), c.create);
router.get('/:id', ...teacher, c.getSession); router.get('/:id', ...teacher, c.getSession);
router.put('/:id/question', ...teacher, c.setQuestion); router.put('/:id/question', ...teacher, c.setQuestion);
router.get('/:id/results', ...teacher, c.results); router.get('/:id/results', ...teacher, c.results);
+3 -2
View File
@@ -1,7 +1,7 @@
'use strict'; 'use strict';
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth');
const c = require('../controllers/studentMaterialsController'); const c = require('../controllers/studentMaterialsController');
router.use(authMiddleware); router.use(authMiddleware);
@@ -10,7 +10,8 @@ router.use(authMiddleware);
router.post('/:id/share', requireRole('teacher', 'admin'), c.share); router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
router.get('/', c.list); router.get('/', c.list);
router.post('/', c.create); // Сохранение в «Мои материалы»: ученик без права materials.save закрыт, учитель/админ проходят.
router.post('/', requirePermissionForStudents('materials.save'), c.create);
// Collections (folders) — literal '/collections' prefix before '/:id' // Collections (folders) — literal '/collections' prefix before '/:id'
router.post('/collections', c.createCollection); router.post('/collections', c.createCollection);
+35
View File
@@ -0,0 +1,35 @@
'use strict';
/* /api/practice — прогресс ученика в ИИ-тренажёре (Фаза 0).
* Все роуты — auth-only (тренируются ученики). router.use(authMiddleware)
* → lint:routes baseline 0. Прогресс всегда принадлежит req.user — нет
* межпользовательских роутов, проверка владения не требуется. */
const express = require('express');
const router = express.Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const c = require('../controllers/practiceController');
router.use(authMiddleware);
router.get('/progress', c.listProgress);
router.post('/attempt', c.submitAttempt);
router.post('/explain', c.explainProblem); // ИИ-репетитор: разбор ошибки / подсказка (ученикам)
// Текстовые задачи (Уровень 1): пул читают все; генерирует/авторит учитель/админ.
router.get('/pool', c.listPool);
router.post('/generate', requireRole('teacher', 'admin'), c.generateProblem);
router.post('/author', requireRole('teacher', 'admin'), c.authorProblem);
router.post('/assign', requireRole('teacher', 'admin'), c.assignToClass);
// Аналитика класса — только учитель/админ (владение проверяется в хендлере).
router.get('/class-stats', requireRole('teacher', 'admin'), c.classStats);
// Конструктор генераторов (P13): чтение — own+published (ученики видят published);
// СОЗДАНИЕ/правка — ТОЛЬКО админ (конструктор — админский инструмент).
const cg = require('../controllers/customGeneratorController');
router.get('/generators', cg.genList);
router.post('/generators', requireRole('admin'), cg.genCreate);
router.get('/generators/:id', cg.genGet); // @public-by-design: own/published в хендлере
router.put('/generators/:id', requireRole('admin'), cg.genUpdate);
router.delete('/generators/:id', requireRole('admin'), cg.genDelete);
module.exports = router;
+3 -1
View File
@@ -11,7 +11,9 @@ router.get('/', (_req, res) => {
router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => { router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => {
const { default_mode, default_count, default_test_id } = req.body; const { default_mode, default_count, default_test_id } = req.body;
const valid_modes = ['exam', 'practice', 'topic', 'random']; // Старт сессии (POST /api/sessions) поддерживает только exam/practice — раньше тут
// допускались topic/random, но клик по такому предмету на дашборде падал с 400.
const valid_modes = ['exam', 'practice'];
if (default_mode && !valid_modes.includes(default_mode)) if (default_mode && !valid_modes.includes(default_mode))
return res.status(400).json({ error: 'Invalid mode' }); return res.status(400).json({ error: 'Invalid mode' });
+3 -3
View File
@@ -1,7 +1,7 @@
const router = require('express').Router(); const router = require('express').Router();
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const { authMiddleware, requireRole } = require('../middleware/auth'); const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
const ctrl = require('../controllers/submissionsController'); const ctrl = require('../controllers/submissionsController');
const { fixUtf8Name } = require('../utils/fixUtf8'); const { fixUtf8Name } = require('../utils/fixUtf8');
@@ -47,7 +47,7 @@ const upload = multer({
/* ── routes ─────────────────────────────────────────────────────────── */ /* ── routes ─────────────────────────────────────────────────────────── */
router.use(authMiddleware); router.use(authMiddleware);
router.post('/', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.submit); router.post('/', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.submit);
router.get('/my', requireRole('student', 'free_student'), ctrl.getMySubmissions); router.get('/my', requireRole('student', 'free_student'), ctrl.getMySubmissions);
router.get('/log', requireRole('admin'), ctrl.getSubmissionLog); router.get('/log', requireRole('admin'), ctrl.getSubmissionLog);
router.delete('/log', requireRole('admin'), ctrl.clearSubmissionLog); router.delete('/log', requireRole('admin'), ctrl.clearSubmissionLog);
@@ -55,6 +55,6 @@ router.get('/', requireRole('teacher', 'admin'), ctrl.getClassSubm
router.patch('/:id', requireRole('teacher', 'admin'), ctrl.reviewSubmission); router.patch('/:id', requireRole('teacher', 'admin'), ctrl.reviewSubmission);
router.get('/:id/download', ctrl.downloadSubmission); router.get('/:id/download', ctrl.downloadSubmission);
router.delete('/:id', ctrl.deleteSubmission); router.delete('/:id', ctrl.deleteSubmission);
router.post('/:id/resubmit', requireRole('student', 'free_student'), upload.single('file'), fixUtf8Name, ctrl.resubmit); router.post('/:id/resubmit', requireRole('student', 'free_student'), requirePermission('homework.submit'), upload.single('file'), fixUtf8Name, ctrl.resubmit);
module.exports = router; module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
'use strict';
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const ctrl = require('../controllers/wishController');
router.use(authMiddleware);
router.get('/', ctrl.list); // admin → все, остальные → свои (фильтрация в контроллере)
router.post('/', ctrl.create); // любой авторизованный
// @public-by-design: PATCH — только админ; DELETE — автор(своё «новое») или админ (проверка в хендлере)
router.patch('/:id', requireRole('admin'), ctrl.update);
router.delete('/:id', ctrl.remove);
module.exports = router;
+6
View File
@@ -198,6 +198,9 @@ app.use('/api/lab', labRoutes);
app.use('/api/materials', require('./routes/materials')); app.use('/api/materials', require('./routes/materials'));
app.use('/api/custom-sims', require('./routes/customSims')); app.use('/api/custom-sims', require('./routes/customSims'));
app.use('/api/game', require('./routes/game')); app.use('/api/game', require('./routes/game'));
app.use('/api/practice', requireFeature('trainer'), require('./routes/practice'));
app.use('/api/wishes', require('./routes/wishes'));
app.use('/api/client-errors', require('./routes/clientErrors'));
app.use('/api/prep', require('./routes/prep')); app.use('/api/prep', require('./routes/prep'));
app.use('/api/dashboard', require('./routes/dashboard')); app.use('/api/dashboard', require('./routes/dashboard'));
@@ -533,6 +536,9 @@ require('./ws-server').attach(server);
/* ── Ретеншн данных доски: чистка штрихов/картинок старых завершённых сессий ── */ /* ── Ретеншн данных доски: чистка штрихов/картинок старых завершённых сессий ── */
try { require('./classroom-cleanup').schedule(); } catch (e) { logger.error('classroom-cleanup schedule error', { err: e.message }); } try { require('./classroom-cleanup').schedule(); } catch (e) { logger.error('classroom-cleanup schedule error', { err: e.message }); }
/* ── Авто-здоровье LLM-провайдеров Квантика: пинг + авто-понижение упавшего активного ── */
try { require('./assistant-health').schedule(); } catch (e) { logger.error('assistant-health schedule error', { err: e.message }); }
/* ── Graceful shutdown ── */ /* ── Graceful shutdown ── */
function shutdown(signal) { function shutdown(signal) {
logger.info(`${signal} received — shutting down gracefully`); logger.info(`${signal} received — shutting down gracefully`);
@@ -0,0 +1,85 @@
'use strict';
/* ИИ-репетитор: объяснение ОШИБКИ и наводящие ПОДСКАЗКИ (направление A).
*
* Безопасность через grounding: модели ДАЮТСЯ задача, правильный ответ и готовые
* шаги решения (всё вычислено детерминированно движком). Модель НЕ считает —
* только ОБЪЯСНЯЕТ простым языком. Поэтому даже слабая модель не выдаст неверную
* математику: правильный ответ ей известен. Текст ответа модели обрезается и
* экранируется (рендерится как текст на клиенте). LLM-вызов инъектируется
* (opts.ask) — тесты подают фейковую модель; реальный берёт callLLMFailover лениво.
*
* mode:
* 'mistake' — объяснить, в чём ошибка ученика, и как исправить (можно назвать ответ).
* 'hint' — одна наводящая подсказка, БЕЗ раскрытия итогового ответа.
*/
const MAX_DISPLAY = 400, MAX_ANSWER = 80, MAX_STEP = 300, MAX_STEPS = 8, MAX_OUT = 700;
function clip(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) : s; }
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function stepsText(steps) {
if (!Array.isArray(steps)) return '';
return steps.slice(0, MAX_STEPS).map(function (st, i) {
st = st || {};
var note = clip(st.note, MAX_STEP), tex = clip(st.tex, MAX_STEP);
return (i + 1) + ') ' + [note, tex].filter(Boolean).join(' ');
}).join('\n');
}
function buildMessages(problem, studentAnswer, mode) {
problem = problem || {};
var display = clip(problem.display, MAX_DISPLAY);
var answer = clip(problem.answer, MAX_ANSWER);
var steps = stepsText(problem.solution || problem.steps);
var sys = (mode === 'hint')
? ('Ты — дружелюбный и терпеливый репетитор по математике для школьника. ' +
'Тебе дают задачу, ПРАВИЛЬНЫЙ ответ и шаги решения. Дай ОДНУ короткую наводящую ' +
'подсказку (наводящий вопрос или первый шаг) на русском, 1–2 предложения, простым языком. ' +
'НЕ называй итоговый ответ и не приводи всё решение — только направь мысль. ' +
'Опирайся на данные шаги, не выдумывай.')
: ('Ты — дружелюбный и терпеливый репетитор по математике для школьника. ' +
'Тебе дают задачу, ПРАВИЛЬНЫЙ ответ, шаги решения и ОТВЕТ УЧЕНИКА (неверный). ' +
'Коротко (2–4 предложения, по-русски, простым языком, без укоров) объясни, в чём именно ' +
'ошибка ученика и как её исправить. Опирайся на данные шаги — не выдумывай математику. ' +
'В конце можешь назвать правильный ответ.');
var user = 'Задача: ' + display + '\nПравильный ответ: ' + answer +
(steps ? ('\nШаги решения:\n' + steps) : '');
if (mode !== 'hint') user += '\nОтвет ученика: ' + clip(studentAnswer, MAX_ANSWER) + '\nОбъясни ошибку.';
else user += '\nДай подсказку, не раскрывая ответ.';
return [{ role: 'system', content: sys }, { role: 'user', content: user }];
}
/* Чистим ответ модели: убираем markdown-обёртки, обрезаем, экранируем. */
function cleanText(text) {
var s = String(text == null ? '' : text).trim();
s = s.replace(/```[a-z]*\n?/gi, '').replace(/```/g, '').trim(); // снять кодовые блоки
return esc(clip(s, MAX_OUT));
}
function _defaultAsk(messages, maxTokens) {
const { callLLMFailover } = require('../controllers/assistantController');
return callLLMFailover(messages, maxTokens, 20000);
}
/* Вернёт { ok, text } или { ok:false, error }. */
async function explain(opts) {
opts = opts || {};
var mode = (opts.mode === 'hint') ? 'hint' : 'mistake';
var ask = opts.ask || _defaultAsk;
var messages = buildMessages(opts.problem, opts.studentAnswer, mode);
var res;
try { res = await ask(messages, 360); }
catch (e) { return { ok: false, error: 'ask-threw' }; }
if (!res || !res.text) return { ok: false, error: (res && res.error) || 'off' };
var text = cleanText(res.text);
if (!text) return { ok: false, error: 'empty' };
return { ok: true, text: text, mode: mode };
}
module.exports = { explain, buildMessages, cleanText };
+121
View File
@@ -0,0 +1,121 @@
'use strict';
/* Генерация ТЕКСТОВЫХ задач (Уровень 1) через LLM с ОБЯЗАТЕЛЬНОЙ проверкой.
*
* LLM предлагает { story, lhs, rhs, answer, solution }; сервер компилирует
* выражения через SimExpr и ПОДСТАВЛЯЕТ корень (practiceVerify). Не сходится —
* авторетрай с фидбэком об ошибке; не починилось за N попыток — задача
* отбрасывается и ученику НЕ попадает (инвариант корректности). Текст условия и
* заметки решения экранируются; выражения идут только в SimExpr (без eval).
*
* LLM-вызов инъектируется (opts.ask) — тесты подают фейковую модель, реальный
* вызов берёт провайдеров ассистента (callLLMFailover) лениво.
*/
const { verifyRoot, compileOk } = require('../utils/practiceVerify');
const MAX_STORY = 600, MAX_EXPR = 200, MAX_STEPS = 8, MAX_NOTE = 300;
function clip(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) : s; }
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function sanitizeText(s, n) { return esc(clip(s, n)); }
const TOPIC_HINTS = {
'word-linear': 'линейное уравнение вида a·x + b = c (одна неизвестная x); задачи на возраст, числа, покупки',
'word-proportion': 'пропорцию a/b = c/x (задачи на части, рецепты, скорость)',
'word-percent': 'нахождение процента от числа или числа по проценту'
};
function buildMessages(topic, opts) {
opts = opts || {};
const hint = TOPIC_HINTS[topic] || TOPIC_HINTS['word-linear'];
const sys =
'Ты генератор школьных задач по математике (7 класс). Возвращай СТРОГО один JSON-объект, ' +
'без markdown и пояснений. Формат: ' +
'{"story":"<условие словами на русском>","lhs":"<левая часть уравнения как выражение от x>",' +
'"rhs":"<правая часть>","answer":<целое число>,"answerVar":"x",' +
'"solution":[{"note":"<пояснение шага словами>","tex":"<один шаг как равенство, выражение>"}]}. ' +
'Уравнение должно соответствовать условию и иметь целый корень. В lhs/rhs/tex — ТОЛЬКО ' +
'математические выражения (символы + - * / ( ) и x), без слов.';
let user = 'Составь текстовую задачу на ' + hint + '. Корень — целое число. Верни только JSON.';
if (opts.feedback) user += ' Предыдущая попытка отклонена. ' + opts.feedback + ' Верни исправленный JSON.';
return [{ role: 'system', content: sys }, { role: 'user', content: user }];
}
/* Достаём первый JSON-объект из ответа модели (терпимо к обёрткам/markdown). */
function parseProblem(text) {
if (!text) return null;
const m = String(text).match(/\{[\s\S]*\}/);
if (!m) return null;
try { return JSON.parse(m[0]); } catch (e) { return null; }
}
/* Валидация структуры + КОРРЕКТНОСТЬ (подстановка) + санитизация. */
function validateAndVerify(obj) {
if (!obj || typeof obj !== 'object') return { ok: false, reason: 'no-json' };
const story = obj.story, lhs = obj.lhs, rhs = obj.rhs;
const answerVar = (typeof obj.answerVar === 'string' && /^[a-z]$/.test(obj.answerVar)) ? obj.answerVar : 'x';
const answer = Number(obj.answer);
if (typeof story !== 'string' || !story.trim()) return { ok: false, reason: 'no-story' };
if (typeof lhs !== 'string' || typeof rhs !== 'string') return { ok: false, reason: 'no-expr' };
if (lhs.length > MAX_EXPR || rhs.length > MAX_EXPR) return { ok: false, reason: 'expr-too-long' };
if (!Number.isFinite(answer)) return { ok: false, reason: 'bad-answer' };
if (!compileOk(lhs) || !compileOk(rhs)) return { ok: false, reason: 'expr-parse' };
const v = verifyRoot(lhs, rhs, answerVar, answer);
if (!v.ok) return { ok: false, reason: 'verify-failed' + (v.residual != null ? ' (residual ' + v.residual.toFixed(4) + ')' : '') };
let solution = [];
if (Array.isArray(obj.solution)) {
solution = obj.solution.slice(0, MAX_STEPS).map(function (st) {
st = st || {};
const out = { note: sanitizeText(st.note, MAX_NOTE) };
if (typeof st.tex === 'string' && st.tex.length <= MAX_EXPR && compileOk(st.tex)) out.tex = clip(st.tex, MAX_EXPR);
else out.tex = '';
return out;
});
}
return {
ok: true,
problem: {
story: sanitizeText(story, MAX_STORY),
lhs: clip(lhs, MAX_EXPR), rhs: clip(rhs, MAX_EXPR),
answerVar: answerVar, answer: answer, solution: solution
}
};
}
function _defaultAsk(messages, maxTokens) {
// лениво, чтобы не тянуть assistantController (и провайдеров) в юнит-тестах
const { callLLMFailover } = require('../controllers/assistantController');
return callLLMFailover(messages, maxTokens, 20000);
}
/* Главная: вернёт { ok, problem, attempts } или { ok:false, error, reason, attempts }. */
async function generate(topic, opts) {
opts = opts || {};
const ask = opts.ask || _defaultAsk;
const maxRetries = Math.max(1, Math.min(opts.maxRetries || 3, 5));
let feedback = '', lastReason = 'off';
for (let i = 0; i < maxRetries; i++) {
let res;
try { res = await ask(buildMessages(topic, { feedback }), 420); }
catch (e) { return { ok: false, error: 'ask-threw', attempts: i }; }
if (!res || !res.text) return { ok: false, error: (res && res.error) || 'off', attempts: i };
const obj = parseProblem(res.text);
if (!obj) { feedback = 'Верни строго один JSON-объект без текста вокруг.'; lastReason = 'no-json'; continue; }
const v = validateAndVerify(obj);
if (v.ok) return { ok: true, problem: v.problem, attempts: i + 1 };
lastReason = v.reason;
feedback = 'Причина: ' + v.reason + '. Проверь, что при ' + answerVarOf(obj) + '=' + obj.answer + ' левая часть равна правой.';
}
return { ok: false, error: 'unverified', reason: lastReason, attempts: maxRetries };
}
function answerVarOf(obj) { return (obj && typeof obj.answerVar === 'string') ? obj.answerVar : 'x'; }
module.exports = { generate, validateAndVerify, parseProblem, buildMessages };
+141
View File
@@ -0,0 +1,141 @@
'use strict';
/* ───────────────────────────────────────────────────────────────────────────
systemReset.js — общая логика «чистого запуска» (используют и CLI
backend/scripts/reset-system.js, и админ-эндпоинт POST /api/admin/reset-system).
⚠️ ДЕСТРУКТИВНО. Перед вызовом runReset ОБЯЗАТЕЛЬНО сделать бэкап БД.
Идея: сохранить ОДНОГО админа, переназначить ему авторский контент, стереть всех
остальных пользователей + всю активность/организацию, сохранить контент/конфиг.
Классифицируем ВСЕ таблицы; неизвестные НЕ трогаем.
─────────────────────────────────────────────────────────────────────────── */
/* Контент-таблицы: владелец переписывается на сохранённого админа (колонка у каждой своя). */
const REASSIGN = {
courses: 'created_by', tests: 'created_by',
flashcard_decks: 'user_id', custom_sims: 'owner_id',
course_templates: 'created_by', lesson_templates: 'created_by',
assignment_templates: 'created_by', lab_sim_links: 'created_by',
classroom_templates: 'teacher_id', folders: 'created_by', files: 'uploaded_by',
};
/* Активность/организация — полностью очищается. */
const WIPE = new Set([
'test_sessions', 'session_questions', 'user_answers',
'exam_attempts', 'exam_mock_sessions', 'exam_user_plan',
'assignments', 'assignment_sessions', 'assignment_completion',
'submissions', 'submission_log',
'classes', 'class_members', 'class_courses',
'classroom_sessions', 'classroom_attendance', 'classroom_chat', 'classroom_chat_reactions',
'classroom_draw_permissions', 'classroom_hands', 'classroom_invites', 'classroom_muted',
'classroom_notes', 'classroom_pages', 'classroom_strokes',
'live_sessions', 'live_answers',
'content_access',
'xp_log', 'coin_log', 'user_achievements', 'daily_goals', 'challenges', 'user_purchases',
'notifications', 'parent_notifications', 'parent_links',
'student_materials', 'material_collections',
'game_progress',
'lesson_progress', 'lesson_comments', 'lesson_notes',
'textbook_progress', 'textbook_bookmarks', 'bookmarks',
'flashcard_reviews', 'flashcard_deck_access',
'bio_user_challenges', 'bio_user_molecules', 'bio_user_pathway',
'rb_user_collection', 'rb_user_quests', 'rb_sightings',
'assistant_seen', 'assistant_memory', 'assistant_feedback', 'assistant_usage', 'assistant_cache',
'imggen_usage',
'folder_access', 'file_access',
'avatar_requests',
'geometry_submissions', 'geometry_tasks',
'security_events', 'error_log', 'admin_audit_log',
'student_prep',
'announcements', 'teacher_students', 'user_permissions', 'user_preferences',
]);
/* Контент/конфиг — НЕ трогаем (явный список, чтобы ловить «неизвестные» таблицы). */
const KEEP = new Set([
'subjects', 'questions', 'options', 'topics',
'textbooks', 'textbook_chunks',
'lessons', 'lesson_blocks', 'course_sections',
'exam_tasks', 'exam_topics', 'exam_tracks', 'exam9_variant_tests',
'test_questions', 'flashcard_cards', 'lab_sims',
'bio_challenges', 'bio_elements', 'bio_molecules', 'bio_pathways', 'bio_reactions',
'rb_food_web', 'rb_groups', 'rb_habitats', 'rb_population_data', 'rb_quests', 'rb_species', 'rb_species_regions',
'shop_items', 'achievements',
'roles', 'role_permissions', 'app_settings', '_migrations',
]);
const ADMIN_RESET_SQL =
`UPDATE users SET xp = 0, level = 1, coins = 0, streak_current = 0, streak_best = 0,
streak_date = NULL, goal_tier = 0, lab_experiments = 0, lab_reactions = 0,
pet_petting_streak = 0 WHERE id = ?`;
function allTables(db) {
return db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
.all().map(r => r.name);
}
function rowCount(db, t) { try { return db.prepare(`SELECT COUNT(*) c FROM "${t}"`).get().c; } catch { return null; } }
/** Кандидат-админ по умолчанию (минимальный id). null если админов нет. */
function pickKeptAdmin(db) {
return db.prepare("SELECT id, email, name FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get() || null;
}
/** План (без изменений): что переназначится / сотрётся / неизвестно. */
function classify(db) {
const tables = allTables(db);
const reassign = Object.entries(REASSIGN).map(([table, col]) => ({ table, col, rows: rowCount(db, table) }));
const wipe = [...WIPE].map(table => ({ table, rows: rowCount(db, table) }));
const unknown = tables.filter(t => t !== 'users' && !REASSIGN[t] && !WIPE.has(t) && !KEEP.has(t));
const totalUsers = rowCount(db, 'users');
const wipeRows = wipe.reduce((a, w) => a + (typeof w.rows === 'number' ? w.rows : 0), 0);
return { reassign, wipe, unknown, keepCount: KEEP.size, totalUsers, wipeRows };
}
/**
* Выполнить сброс. db — экземпляр node:sqlite DatabaseSync. keptAdminId — id админа,
* которого сохраняем (ему переназначается контент). Возвращает сводку.
* ⚠️ Бэкап делает ВЫЗЫВАЮЩИЙ код ДО вызова.
*/
function runReset(db, keptAdminId) {
const admin = db.prepare("SELECT id, role FROM users WHERE id = ?").get(keptAdminId);
if (!admin || admin.role !== 'admin') throw new Error('keptAdminId не является админом');
db.exec('PRAGMA foreign_keys = OFF'); // управляем удалением вручную, детерминированно
db.exec('BEGIN');
try {
for (const [t, col] of Object.entries(REASSIGN)) {
try { db.prepare(`UPDATE "${t}" SET "${col}" = ? WHERE "${col}" IS NOT NULL AND "${col}" != ?`).run(keptAdminId, keptAdminId); } catch { /* нет таблицы/колонки — пропуск */ }
}
for (const t of WIPE) {
try { db.prepare(`DELETE FROM "${t}"`).run(); } catch { /* нет таблицы — пропуск */ }
}
const del = db.prepare('DELETE FROM users WHERE id != ?').run(keptAdminId);
db.prepare(ADMIN_RESET_SQL).run(keptAdminId);
db.exec('COMMIT');
var deletedUsers = del.changes;
} catch (e) {
db.exec('ROLLBACK');
db.exec('PRAGMA foreign_keys = ON');
throw e;
}
db.exec('PRAGMA foreign_keys = ON');
let fkBad = 0;
try { fkBad = db.prepare('PRAGMA foreign_key_check').all().length; } catch {}
try { db.exec('VACUUM'); } catch {}
return {
ok: true,
keptAdminId,
deletedUsers,
remainingUsers: rowCount(db, 'users'),
fkDangling: fkBad,
kept: {
textbooks: rowCount(db, 'textbooks'),
questions: rowCount(db, 'questions'),
tests: rowCount(db, 'tests'),
courses: rowCount(db, 'courses'),
exam_tasks: rowCount(db, 'exam_tasks'),
},
};
}
module.exports = { REASSIGN, WIPE, KEEP, classify, pickKeptAdmin, runReset };
+15 -1
View File
@@ -18,6 +18,20 @@ const MAGIC = [
{ mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', bytes: [0x50,0x4B,0x03,0x04], offset: 0 }, { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', bytes: [0x50,0x4B,0x03,0x04], offset: 0 },
]; ];
/* Канонические расширения по проверенному MIME. Имя файла на диске берём
* ОТСЮДА, а не из client-controlled originalname, иначе можно сохранить
* .html/.svg и получить stored-XSS при раздаче статикой. */
const EXT_FOR_MIME = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'application/pdf': '.pdf',
};
function safeExt(declaredMime, fallback) {
return EXT_FOR_MIME[declaredMime] || fallback || '';
}
function checkMagicBytes(filePath, declaredMime) { function checkMagicBytes(filePath, declaredMime) {
if (declaredMime === 'text/plain') return true; // txt has no magic bytes if (declaredMime === 'text/plain') return true; // txt has no magic bytes
const rules = MAGIC.filter(m => m.mime === declaredMime); const rules = MAGIC.filter(m => m.mime === declaredMime);
@@ -35,4 +49,4 @@ function checkMagicBytes(filePath, declaredMime) {
} }
} }
module.exports = { checkMagicBytes }; module.exports = { checkMagicBytes, safeExt, EXT_FOR_MIME };
+31
View File
@@ -0,0 +1,31 @@
'use strict';
/* Серверная проверка задач тренажёра через SimExpr — тот же безопасный
* вычислитель, что на клиенте (⛔ без eval/new Function). Гарантирует, что любая
* задача (от LLM или учителя) КОРРЕКТНА: подставляем заявленный корень в обе
* части уравнения и сверяем с допуском. SimExpr — чистый (без DOM), грузится в
* Node через require: его IIFE цепляется к globalThis.SimExpr. */
require('../../../frontend/js/labs/_sim_expr.js'); // → globalThis.SimExpr
const SimExpr = globalThis.SimExpr;
const EPS = 1e-7;
/* Компиляция выражения; null при синтаксической ошибке (мусор от модели). */
function compileOk(expr) {
if (typeof expr !== 'string') return null;
const c = SimExpr.compile(expr);
return (c && !c.error) ? c : null;
}
/* Подстановочная проверка: lhs(var=value) ≈ rhs(var=value). */
function verifyRoot(lhs, rhs, varName, value) {
const cl = compileOk(lhs), cr = compileOk(rhs);
if (!cl || !cr) return { ok: false, reason: 'parse' };
if (typeof value !== 'number' || !isFinite(value)) return { ok: false, reason: 'bad-value' };
const env = {}; env[varName || 'x'] = value;
const L = cl.fn(env), R = cr.fn(env);
const residual = Math.abs(L - R);
const scale = Math.max(1, Math.abs(L), Math.abs(R));
return { ok: residual <= EPS * scale, residual: residual, lhs: L, rhs: R };
}
module.exports = { SimExpr, compileOk, verifyRoot };
+67
View File
@@ -0,0 +1,67 @@
'use strict';
/**
* Integration: /api/client-errors — приём браузерных ошибок в error_log (level='client').
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, db, cleanup } = require('./setup');
app.use('/api/client-errors', require('../src/routes/clientErrors'));
after(() => cleanup());
describe('/api/client-errors', () => {
let student;
before(async () => { student = await getToken('student'); });
it('требует авторизацию (401)', async () => {
const res = await inject('POST', '/api/client-errors', { message: 'x' }, null);
assert.equal(res.status, 401);
});
it('пустое сообщение → 400', async () => {
const res = await inject('POST', '/api/client-errors', { message: ' ' }, student.token);
assert.equal(res.status, 400);
});
it('пишет ошибку в error_log с level=client', async () => {
const res = await inject('POST', '/api/client-errors', {
kind: 'error', message: 'TypeError: x is null',
stack: 'at foo (app.js:10:5)', source: '/js/app.js', line: 10, col: 5,
url: '/lab?sim=demo#x',
}, student.token);
assert.equal(res.status, 200);
assert.equal(res.body.ok, true);
const row = db.prepare(
"SELECT * FROM error_log WHERE level='client' AND user_id=? ORDER BY id DESC LIMIT 1"
).get(student.userId);
assert.ok(row, 'строка должна появиться');
assert.equal(row.message, 'TypeError: x is null');
assert.equal(row.route, '/lab?sim=demo#x');
assert.equal(row.method, 'error');
assert.match(row.stack, /app\.js:10:5/);
});
it('unhandledrejection → method=rejection, stack из source при отсутствии stack', async () => {
const res = await inject('POST', '/api/client-errors', {
kind: 'unhandledrejection', message: 'boom', source: '/js/x.js', line: 3, col: 1, url: '/dashboard',
}, student.token);
assert.equal(res.status, 200);
const row = db.prepare(
"SELECT * FROM error_log WHERE level='client' AND message='boom' ORDER BY id DESC LIMIT 1"
).get();
assert.equal(row.method, 'rejection');
assert.match(row.stack, /x\.js:3:1/);
});
it('длинные поля обрезаются (не падает)', async () => {
const res = await inject('POST', '/api/client-errors', {
message: 'M'.repeat(5000), stack: 'S'.repeat(20000), url: 'U'.repeat(2000),
}, student.token);
assert.equal(res.status, 200);
const row = db.prepare("SELECT * FROM error_log WHERE level='client' ORDER BY id DESC LIMIT 1").get();
assert.ok(row.message.length <= 1000);
assert.ok(row.stack.length <= 4000);
assert.ok(row.route.length <= 400);
});
});
+106
View File
@@ -0,0 +1,106 @@
'use strict';
/**
* Tests: конструктор генераторов тренажёра (P13) — валидация + CRUD + доступ.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, cleanup } = require('./setup');
const cg = require('../src/controllers/customGeneratorController');
app.use('/api/practice', require('../src/routes/practice'));
after(() => cleanup());
const SPEC = {
title: 'Моё уравнение', topic: 'custom', kind: 'solve',
pick: { a: [2, 9], b: [1, 20], root: [-9, 9] },
derive: { c: 'a*root + b', cmb: 'a*root' },
require: 'root != 0',
lhs: '{a}*x + {b}', rhs: '{c}', answer: 'root', integerAnswer: true,
solution: [{ note: 'делим на {a}', tex: 'x = {cmb} / {a}' }]
};
describe('validateGenSpec', () => {
it('принимает корректный спек', () => {
const v = cg.validateGenSpec(SPEC);
assert.equal(v.ok, true, v.error);
assert.equal(v.clean.kind, 'solve');
assert.deepEqual(v.clean.pick.a, [2, 9]);
assert.equal(v.clean.integerAnswer, true);
});
it('отвергает без заголовка', () => {
assert.equal(cg.validateGenSpec(Object.assign({}, SPEC, { title: '' })).ok, false);
});
it('фильтрует нецелые диапазоны pick', () => {
const v = cg.validateGenSpec(Object.assign({}, SPEC, { pick: { a: [1.5, 9], b: [1, 20] } }));
assert.equal(v.ok, true);
assert.equal(v.clean.pick.a, undefined, 'нецелый диапазон отброшен');
assert.deepEqual(v.clean.pick.b, [1, 20]);
});
it('отвергает слишком большой спек', () => {
assert.equal(cg.validateGenSpec(Object.assign({}, SPEC, { display: 'x'.repeat(30000) })).ok, false);
});
});
describe('/api/practice/generators CRUD (конструктор — только админ)', () => {
let admin, other, teacher, student, gid;
before(async () => {
admin = (await getToken('admin')).token;
other = (await getToken('admin')).token;
teacher = (await getToken('teacher')).token;
student = (await getToken('student')).token;
});
it('админ создаёт генератор', async () => {
const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, admin);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.ok, true);
assert.ok(/^cg\d+$/.test(res.body.generator.id), 'id вида cg<dbid>');
gid = res.body.generator.dbid;
});
it('учителю создавать запрещено (403)', async () => {
const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, teacher);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('ученику создавать запрещено (403)', async () => {
const res = await inject('POST', '/api/practice/generators', { spec: SPEC }, student);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('невалидный спек → 400', async () => {
const res = await inject('POST', '/api/practice/generators', { spec: { title: '' } }, admin);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('автор видит свой генератор в списке', async () => {
const res = await inject('GET', '/api/practice/generators', null, admin);
assert.equal(res.status, 200);
assert.ok(res.body.generators.some(g => g.dbid === gid), 'свой генератор в списке');
});
it('чужой draft не виден другому админу', async () => {
const res = await inject('GET', '/api/practice/generators', null, other);
assert.ok(!res.body.generators.some(g => g.dbid === gid), 'чужой draft скрыт');
});
it('учителю изменять запрещено (403, роль)', async () => {
const res = await inject('PUT', '/api/practice/generators/' + gid, { spec: SPEC }, teacher);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('публикация делает генератор видимым другим (и ученику)', async () => {
const pub = await inject('PUT', '/api/practice/generators/' + gid, { status: 'published' }, admin);
assert.equal(pub.status, 200);
assert.equal(pub.body.generator.status, 'published');
const res = await inject('GET', '/api/practice/generators', null, student);
assert.ok(res.body.generators.some(g => g.dbid === gid), 'published виден ученику');
});
it('автор удаляет свой генератор', async () => {
const res = await inject('DELETE', '/api/practice/generators/' + gid, null, admin);
assert.equal(res.status, 200);
const after = await inject('GET', '/api/practice/generators/' + gid, null, admin);
assert.equal(after.status, 404, 'после удаления 404');
});
});
+74
View File
@@ -0,0 +1,74 @@
'use strict';
/**
* Tests: ИИ-репетитор тренажёра (разбор ошибки / подсказка).
* - explain (LLM застаблен): mistake/hint дают текст; grounding (ответ в промпте); off → not ok.
* - cleanText: снимает markdown-обёртки и экранирует HTML.
* - endpoint /explain: auth-only (ученику доступен); без провайдера → 503; без задачи → 400.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, cleanup } = require('./setup');
const svc = require('../src/services/practiceExplainService');
app.use('/api/practice', require('../src/routes/practice'));
after(() => cleanup());
const PROBLEM = { display: '3x + 7 = 22', answer: 'x = 5', solution: [{ note: 'Переносим 7', tex: '3*x = 15' }, { note: 'Делим на 3', tex: 'x = 5' }] };
describe('practiceExplainService.explain (LLM застаблен)', () => {
it('mistake: возвращает текст и передаёт модели правильный ответ (grounding)', async () => {
let seen = null;
const ask = async (messages) => { seen = messages; return { text: 'Ты не поделил обе части на 3.' }; };
const r = await svc.explain({ problem: PROBLEM, studentAnswer: '15', mode: 'mistake', ask });
assert.equal(r.ok, true);
assert.equal(r.mode, 'mistake');
assert.ok(r.text.indexOf('поделил') !== -1);
const userMsg = seen.map(m => m.content).join('\n');
assert.ok(userMsg.indexOf('x = 5') !== -1, 'правильный ответ передан модели');
assert.ok(userMsg.indexOf('15') !== -1, 'ответ ученика передан модели');
});
it('hint: не просит раскрывать ответ ученика (нет строки «Ответ ученика»)', async () => {
let seen = null;
const ask = async (messages) => { seen = messages; return { text: 'Что нужно сделать со свободным членом 7?' }; };
const r = await svc.explain({ problem: PROBLEM, mode: 'hint', ask });
assert.equal(r.ok, true);
assert.equal(r.mode, 'hint');
const sys = seen[0].content;
assert.ok(sys.indexOf('НЕ называй итоговый ответ') !== -1, 'hint-режим запрещает раскрывать ответ');
});
it('off-провайдер → not ok', async () => {
const r = await svc.explain({ problem: PROBLEM, mode: 'hint', ask: async () => ({ text: null, error: 'off' }) });
assert.equal(r.ok, false);
assert.equal(r.error, 'off');
});
it('cleanText снимает markdown и экранирует HTML', () => {
const out = svc.cleanText('```\nОшибка в знаке <b>тут</b>\n```');
assert.ok(out.indexOf('```') === -1, 'нет кодовых блоков');
assert.ok(out.indexOf('<b>') === -1 && out.indexOf('&lt;b&gt;') !== -1, 'HTML экранирован');
});
});
describe('/api/practice/explain endpoint', () => {
let student;
before(async () => { student = (await getToken('student')).token; });
it('доступен ученику; без провайдера → 503', async () => {
const res = await inject('POST', '/api/practice/explain',
{ display: '3x + 7 = 22', answer: 'x = 5', steps: PROBLEM.solution, studentAnswer: '15', mode: 'mistake' }, student);
assert.equal(res.status, 503, `got ${res.status}`);
assert.equal(res.body.error, 'off');
});
it('без текста задачи → 400', async () => {
const res = await inject('POST', '/api/practice/explain', { display: '', mode: 'hint' }, student);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('без авторизации → 401', async () => {
const res = await inject('POST', '/api/practice/explain', { display: '3x = 6', mode: 'hint' }, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
});
+142
View File
@@ -0,0 +1,142 @@
'use strict';
/**
* Tests: текстовые задачи тренажёра (Уровень 1) — генерация + проверка + пул.
* - validateAndVerify: корректную принимает, неверный корень/мусор отвергает, текст экранирует.
* - generate (LLM застаблен): валидная с 1 попытки; ретраи; провал → unverified; провайдер off.
* - endpoints: /generate только учитель/админ (403 ученику; 503 без провайдера); /pool отдаёт пул.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
const gen = require('../src/services/practiceGenService');
app.use('/api/practice', require('../src/routes/practice'));
after(() => cleanup());
const GOOD = { story: 'Задумали число x: <b>3x + 4 = 19</b>. Найдите x.', lhs: '3*x + 4', rhs: '19', answer: 5, answerVar: 'x', solution: [{ note: 'Перенесём 4', tex: '3*x = 15' }] };
describe('practiceGenService.validateAndVerify', () => {
it('принимает корректную задачу и экранирует текст', () => {
const v = gen.validateAndVerify(GOOD);
assert.equal(v.ok, true, v.reason);
assert.equal(v.problem.answer, 5);
assert.ok(v.problem.story.indexOf('<b>') === -1 && v.problem.story.indexOf('&lt;b&gt;') !== -1, 'story escaped');
assert.equal(v.problem.solution[0].tex, '3*x = 15');
});
it('отвергает неверный корень (подстановка не сходится)', () => {
const v = gen.validateAndVerify(Object.assign({}, GOOD, { answer: 6 }));
assert.equal(v.ok, false);
assert.ok(/verify-failed/.test(v.reason), v.reason);
});
it('отвергает невалидное выражение', () => {
const v = gen.validateAndVerify(Object.assign({}, GOOD, { lhs: '3x +' }));
assert.equal(v.ok, false);
assert.equal(v.reason, 'expr-parse');
});
it('отвергает без условия', () => {
const v = gen.validateAndVerify(Object.assign({}, GOOD, { story: '' }));
assert.equal(v.ok, false);
assert.equal(v.reason, 'no-story');
});
it('сбрасывает мусорный tex шага в пустую строку', () => {
const v = gen.validateAndVerify(Object.assign({}, GOOD, { solution: [{ note: 'ok', tex: 'не выражение!!!' }] }));
assert.equal(v.ok, true);
assert.equal(v.problem.solution[0].tex, '');
});
});
describe('practiceGenService.generate (LLM застаблен)', () => {
const askValid = async () => ({ text: '```json\n' + JSON.stringify(GOOD) + '\n```' });
const askWrong = async () => ({ text: JSON.stringify(Object.assign({}, GOOD, { answer: 99 })) });
const askOff = async () => ({ text: null, error: 'off' });
it('валидная задача с первой попытки', async () => {
const r = await gen.generate('word-linear', { ask: askValid, maxRetries: 3 });
assert.equal(r.ok, true);
assert.equal(r.attempts, 1);
assert.equal(r.problem.answer, 5);
});
it('ретраит и берёт валидную со второй попытки', async () => {
let n = 0;
const ask = async () => { n++; return n === 1 ? { text: 'мусор без json' } : { text: JSON.stringify(GOOD) }; };
const r = await gen.generate('word-linear', { ask, maxRetries: 3 });
assert.equal(r.ok, true);
assert.equal(r.attempts, 2);
});
it('неверный корень N раз → unverified (в пул не попадёт)', async () => {
const r = await gen.generate('word-linear', { ask: askWrong, maxRetries: 3 });
assert.equal(r.ok, false);
assert.equal(r.error, 'unverified');
assert.equal(r.attempts, 3);
});
it('нет провайдера → off', async () => {
const r = await gen.generate('word-linear', { ask: askOff, maxRetries: 3 });
assert.equal(r.ok, false);
assert.equal(r.error, 'off');
});
});
describe('/api/practice pool endpoints', () => {
let teacher, student;
before(async () => {
teacher = (await getToken('teacher')).token;
student = (await getToken('student')).token;
});
it('POST /generate запрещён ученику (403)', async () => {
const res = await inject('POST', '/api/practice/generate', { topic: 'word-linear' }, student);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('POST /generate учителю без провайдера → 503 (off)', async () => {
const res = await inject('POST', '/api/practice/generate', { topic: 'word-linear' }, teacher);
assert.equal(res.status, 503, `got ${res.status}`);
assert.equal(res.body.error, 'off');
});
it('POST /generate неизвестная тема → 400', async () => {
const res = await inject('POST', '/api/practice/generate', { topic: 'nope' }, teacher);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('POST /author учителем (валидная) → в пул', async () => {
const res = await inject('POST', '/api/practice/author',
{ topic: 'word-linear', story: 'Задача от учителя', lhs: '2*x + 1', rhs: '7', answer: 3 }, teacher);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.ok, true);
assert.equal(res.body.problem.answer, 3);
assert.equal(res.body.problem.kind, 'word');
});
it('POST /author с неверным корнем → 422 (в пул не попадёт)', async () => {
const res = await inject('POST', '/api/practice/author',
{ topic: 'word-linear', story: 'X', lhs: '2*x + 1', rhs: '7', answer: 5 }, teacher);
assert.equal(res.status, 422, `got ${res.status}`);
});
it('POST /author ученику запрещён (403)', async () => {
const res = await inject('POST', '/api/practice/author',
{ topic: 'word-linear', story: 'X', lhs: '2*x + 1', rhs: '7', answer: 3 }, student);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('GET /pool отдаёт одобренные задачи', async () => {
db.prepare(`INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status)
VALUES ('word-linear','word-linear',1,'Условие','3*x + 4','19','x',5,'[]','approved')`).run();
const res = await inject('GET', '/api/practice/pool?skill=word-linear', null, student);
assert.equal(res.status, 200, `got ${res.status}`);
assert.ok(Array.isArray(res.body.problems));
const p = res.body.problems.find(x => x.skill === 'word-linear');
assert.ok(p, 'pool problem present');
assert.equal(p.kind, 'word');
assert.equal(p.lhsExpr, '3*x + 4');
assert.equal(p.answer, 5);
});
});
+174
View File
@@ -0,0 +1,174 @@
'use strict';
/**
* Integration tests: /api/practice — прогресс ученика в ИИ-тренажёре (Фаза 0).
* Covers: auth-only (401); correct создаёт строку; wrong не растит solved, но
* растит attempts и обнуляет серию; серия из MASTERY_STREAK → mastered;
* прогресс per-user; валидация входа (400).
*/
const { describe, it, before } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/practice on the shared test app (setup.js не монтирует новые роуты).
app.use('/api/practice', require('../src/routes/practice'));
const { after } = require('node:test');
after(() => cleanup());
const SKILL = 'linear-basic';
describe('/api/practice progress', () => {
let token;
before(async () => {
token = (await getToken('student')).token;
});
it('GET /progress requires auth (401)', async () => {
const res = await inject('GET', '/api/practice/progress', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('POST /attempt requires auth (401)', async () => {
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('correct attempt creates a row (solved=1, streak=1)', async () => {
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.ok, true);
assert.equal(res.body.progress.skill, SKILL);
assert.equal(res.body.progress.solved, 1);
assert.equal(res.body.progress.attempts, 1);
assert.equal(res.body.progress.cur_streak, 1);
assert.equal(res.body.progress.best_streak, 1);
assert.equal(res.body.progress.mastered, 0);
});
it('GET /progress lists the row', async () => {
const res = await inject('GET', '/api/practice/progress', null, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.ok(Array.isArray(res.body.progress));
const row = res.body.progress.find(r => r.skill === SKILL);
assert.ok(row, 'skill row present');
assert.equal(row.solved, 1);
});
it('wrong attempt: attempts++, solved unchanged, streak resets to 0', async () => {
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: false }, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.progress.solved, 1, 'solved unchanged');
assert.equal(res.body.progress.attempts, 2, 'attempts incremented');
assert.equal(res.body.progress.cur_streak, 0, 'streak reset');
assert.equal(res.body.progress.best_streak, 1, 'best streak kept');
});
it('streak of 5 correct → mastered=1 (and stays mastered after a miss)', async () => {
const sk = 'mastery-skill';
let last;
for (let i = 0; i < 5; i++) {
last = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token);
}
assert.equal(last.body.progress.cur_streak, 5);
assert.equal(last.body.progress.best_streak, 5);
assert.equal(last.body.progress.mastered, 1, 'mastered after 5 in a row');
const miss = await inject('POST', '/api/practice/attempt', { skill: sk, correct: false }, token);
assert.equal(miss.body.progress.cur_streak, 0, 'streak reset on miss');
assert.equal(miss.body.progress.mastered, 1, 'mastered is sticky');
});
it('SR: box растёт на верный ответ и сбрасывается на ошибку; due отражает срок', async () => {
const sk = 'sr-skill';
const c1 = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token);
assert.equal(c1.body.progress.box, 1, 'box=1 после первого верного');
assert.equal(c1.body.progress.due, 0, 'свежий навык не просрочен (срок в будущем)');
const c2 = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token);
assert.equal(c2.body.progress.box, 2, 'box растёт на следующем верном');
const w = await inject('POST', '/api/practice/attempt', { skill: sk, correct: false }, token);
assert.equal(w.body.progress.box, 0, 'ошибка сбрасывает box в 0');
assert.equal(w.body.progress.due, 1, 'после ошибки навык сразу к повторению (due=1)');
});
it('progress is per-user (другой ученик начинает с нуля)', async () => {
const other = (await getToken('student')).token;
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, other);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.progress.attempts, 1, 'fresh user has attempts=1');
assert.equal(res.body.progress.solved, 1);
});
it('validation: missing skill → 400', async () => {
const res = await inject('POST', '/api/practice/attempt', { correct: true }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: correct not boolean → 400', async () => {
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: 'yes' }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: skill too long → 400', async () => {
const res = await inject('POST', '/api/practice/attempt', { skill: 'x'.repeat(200), correct: true }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
});
describe('/api/practice/class-stats (аналитика класса)', () => {
let teacher, other, s1, s2, classId;
before(async () => {
teacher = await getToken('teacher');
other = await getToken('teacher');
s1 = await getToken('student');
s2 = await getToken('student');
// класс учителя + два ученика в нём
const info = db.prepare("INSERT INTO classes (name, teacher_id, invite_code) VALUES ('P6 класс', ?, ?)").run(teacher.userId, 'P6CODE');
classId = info.lastInsertRowid;
db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s1.userId);
db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s2.userId);
// прогресс: s1 решил lin-basic верно, s2 ошибся на lin-basic
await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: true }, s1.token);
await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: false }, s2.token);
await inject('POST', '/api/practice/attempt', { skill: 'lin-paren', correct: true }, s1.token);
});
it('владелец класса видит агрегаты и матрицу', async () => {
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, teacher.token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.students.length, 2, 'два ученика');
assert.ok(res.body.skills.includes('lin-basic'), 'навык в списке');
const lb = res.body.perSkill.find(s => s.skill === 'lin-basic');
assert.ok(lb, 'агрегат по lin-basic есть');
assert.equal(lb.attempted, 2, 'оба пробовали lin-basic');
assert.equal(lb.accuracy, 50, '1 верный из 2 попыток → 50%');
});
it('чужой класс → 403', async () => {
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, other.token);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('ученику запрещено (требуется роль) → 403', async () => {
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, s1.token);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('без class_id → 400', async () => {
const res = await inject('GET', '/api/practice/class-stats', null, teacher.token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('POST /assign владельцем → уведомляет всех учеников', async () => {
const res = await inject('POST', '/api/practice/assign', { class_id: classId, topic: 'word-linear', title: 'Линейные уравнения' }, teacher.token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.ok, true);
assert.equal(res.body.notified, 2, 'двое учеников уведомлены');
});
it('POST /assign чужой класс → 403', async () => {
const res = await inject('POST', '/api/practice/assign', { class_id: classId, topic: 'word-linear' }, other.token);
assert.equal(res.status, 403, `got ${res.status}`);
});
});
+119
View File
@@ -0,0 +1,119 @@
'use strict';
/**
* Integration tests: /api/wishes — трекер пожеланий по улучшению.
* Covers: auth-only; создание (валидация); приватность (автор видит только свои,
* админ — все + counts); триаж только админом (403 ученику); смена статуса; удаление
* (автор «новое» / админ; чужое нельзя).
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, cleanup } = require('./setup');
app.use('/api/wishes', require('../src/routes/wishes'));
after(() => cleanup());
describe('/api/wishes', () => {
let s1, s2, admin;
before(async () => {
s1 = await getToken('student');
s2 = await getToken('student');
admin = await getToken('admin');
});
it('POST /wishes requires auth (401)', async () => {
const res = await inject('POST', '/api/wishes', { title: 'x' }, null);
assert.equal(res.status, 401);
});
it('создание: пустой заголовок → 400', async () => {
const res = await inject('POST', '/api/wishes', { title: ' ' }, s1.token);
assert.equal(res.status, 400);
});
let wishId;
it('создание пожелания учеником → 201, статус new', async () => {
const res = await inject('POST', '/api/wishes',
{ title: 'Тёмная тема', body: 'Хочу ночной режим', category: 'ui' }, s1.token);
assert.equal(res.status, 201, JSON.stringify(res.body));
assert.equal(res.body.status, 'new');
assert.equal(res.body.category, 'ui');
assert.equal(res.body.user_id, s1.userId);
wishId = res.body.id;
});
it('неизвестная категория → other', async () => {
const res = await inject('POST', '/api/wishes', { title: 'Что-то', category: 'hack' }, s1.token);
assert.equal(res.body.category, 'other');
});
it('приватность: автор видит свои', async () => {
const res = await inject('GET', '/api/wishes', null, s1.token);
assert.equal(res.status, 200);
assert.ok(res.body.wishes.some(w => w.id === wishId));
assert.equal(res.body.isAdmin, false);
});
it('приватность: другой ученик НЕ видит чужое', async () => {
const res = await inject('GET', '/api/wishes', null, s2.token);
assert.ok(!res.body.wishes.some(w => w.id === wishId));
});
it('админ видит все + counts', async () => {
const res = await inject('GET', '/api/wishes', null, admin.token);
assert.equal(res.status, 200);
assert.equal(res.body.isAdmin, true);
assert.ok(res.body.wishes.some(w => w.id === wishId));
assert.ok(res.body.counts && typeof res.body.counts.new === 'number');
// у админа в списке есть имя автора
const w = res.body.wishes.find(x => x.id === wishId);
assert.ok(w.author_name);
});
it('триаж учеником запрещён (403)', async () => {
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'done' }, s1.token);
assert.equal(res.status, 403);
});
it('админ меняет статус + ответ → 200', async () => {
const res = await inject('PATCH', `/api/wishes/${wishId}`,
{ status: 'planned', admin_note: 'Запланировано на лето' }, admin.token);
assert.equal(res.status, 200, JSON.stringify(res.body));
assert.equal(res.body.status, 'planned');
assert.equal(res.body.admin_note, 'Запланировано на лето');
});
it('неверный статус → 400', async () => {
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'bogus' }, admin.token);
assert.equal(res.status, 400);
});
it('фильтр по статусу у админа', async () => {
const res = await inject('GET', '/api/wishes?status=planned', null, admin.token);
assert.ok(res.body.wishes.every(w => w.status === 'planned'));
assert.ok(res.body.wishes.some(w => w.id === wishId));
});
it('автор НЕ может удалить уже обработанное (не new) → 403', async () => {
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s1.token);
assert.equal(res.status, 403);
});
it('чужой ученик не может удалить → 403', async () => {
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s2.token);
assert.equal(res.status, 403);
});
it('автор удаляет своё «новое» → 200', async () => {
const c = await inject('POST', '/api/wishes', { title: 'Черновик' }, s1.token);
const res = await inject('DELETE', `/api/wishes/${c.body.id}`, null, s1.token);
assert.equal(res.status, 200);
});
it('админ удаляет любое → 200', async () => {
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, admin.token);
assert.equal(res.status, 200);
const gone = await inject('GET', '/api/wishes', null, admin.token);
assert.ok(!gone.body.wishes.some(w => w.id === wishId));
});
});
+11 -4
View File
@@ -486,6 +486,13 @@
.tst-search { width: 100%; padding: 7px 12px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.83rem; background: #fff; color: var(--text); margin-bottom: 8px; outline: none; } .tst-search { width: 100%; padding: 7px 12px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.83rem; background: #fff; color: var(--text); margin-bottom: 8px; outline: none; }
.tst-search:focus { border-color: var(--violet); } .tst-search:focus { border-color: var(--violet); }
.tst-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; } .tst-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; }
.tst-pick-filters { display: flex; gap: 8px; margin-bottom: 8px; }
.tst-pick-sel { flex: 1; min-width: 0; padding: 6px 10px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.78rem; background: #fff; color: var(--text); cursor: pointer; outline: none; }
.tst-pick-sel:focus { border-color: var(--violet); }
.tst-pick-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; min-height: 24px; }
.tst-pick-count { font-size: 0.74rem; color: var(--text-3); }
.btn-tst-more { padding: 6px 14px; border: 1.5px solid var(--violet); border-radius: 8px; background: rgba(155,93,229,0.06); color: var(--violet); font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: background var(--tr); }
.btn-tst-more:hover { background: rgba(155,93,229,0.14); }
.src-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; } .src-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
/* formula bar */ /* formula bar */
/* Formula bar: hidden by default, toggled via #qf-fml-toggle */ /* Formula bar: hidden by default, toggled via #qf-fml-toggle */
@@ -1071,7 +1078,7 @@
<i data-lucide="clipboard-check" style="width:15px;height:15px"></i> Экзамен-модули <i data-lucide="clipboard-check" style="width:15px;height:15px"></i> Экзамен-модули
</button> </button>
<button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none"> <button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none">
<i data-lucide="gamepad-2" style="width:15px;height:15px"></i> Игры <i data-lucide="layout-grid" style="width:15px;height:15px"></i> Модули
</button> </button>
<button class="admin-nav-item" data-tab="assistant" onclick="switchTab(this)" id="btn-tab-assistant" style="display:none"> <button class="admin-nav-item" data-tab="assistant" onclick="switchTab(this)" id="btn-tab-assistant" style="display:none">
<i data-lucide="sparkles" style="width:15px;height:15px"></i> Помощник Квантик <i data-lucide="sparkles" style="width:15px;height:15px"></i> Помощник Квантик
@@ -1568,10 +1575,10 @@
<div id="imggen-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div> <div id="imggen-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div>
</div> </div>
<!-- ── Игры ── --> <!-- ── Модули ── -->
<div class="tab-pane" id="tab-games"> <div class="tab-pane" id="tab-games">
<div class="section-title">Управление играми</div> <div class="section-title">Управление модулями</div>
<div class="perm-desc" style="margin-bottom:20px">Отключённые игры скрываются из бокового меню и становятся недоступны для всех пользователей.</div> <div class="perm-desc" style="margin-bottom:20px">Отключённые модули скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
<div class="perm-grid" id="games-features-grid"> <div class="perm-grid" id="games-features-grid">
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div> <div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
</div> </div>
+134 -4
View File
@@ -109,6 +109,29 @@
.deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); } .deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); }
.deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); } .deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); }
/* ── Долги (что висит у учеников) ── */
.debt-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; font-size: 0.84rem; color: var(--text-2); }
.debt-summary b { color: var(--text); font-family: 'Unbounded', sans-serif; }
.debt-card { border: 1px solid var(--border); border-radius: 14px; padding: 14px 18px; margin-bottom: 12px; }
.debt-card.has-over { border-color: rgba(241,91,181,0.35); }
.debt-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.debt-name { font-size: 0.9rem; font-weight: 700; }
.debt-email { font-size: 0.74rem; color: var(--text-3); }
.debt-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-left: auto; }
.debt-chip { font-size: 0.68rem; font-weight: 700; padding: 2px 9px; border-radius: var(--r-pill); white-space: nowrap; }
.dc-overdue { background: rgba(241,91,181,0.12); color: var(--pink); }
.dc-in_progress { background: rgba(255,179,71,0.14); color: var(--amber); }
.dc-revision { background: rgba(245,158,11,0.14); color: #d97706; }
.dc-not_started { background: rgba(15,23,42,0.06); color: var(--text-3); }
.debt-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--border); }
.debt-item-title { font-size: 0.82rem; font-weight: 600; flex: 1; min-width: 0; }
.debt-item-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 2px; }
.debt-del { border: none; background: transparent; color: var(--text-3); cursor: pointer; padding: 5px; border-radius: 8px; flex-shrink: 0; }
.debt-del:hover { background: rgba(241,91,181,0.1); color: var(--pink); }
.debt-allclear { text-align: center; padding: 40px 20px; color: var(--green); font-weight: 600; }
.debt-rest { font-size: 0.78rem; color: var(--text-3); margin-top: 8px; }
.tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--pink); color: #fff; font-size: 0.68rem; font-weight: 800; vertical-align: 1px; }
/* ── Student search ── */ /* ── Student search ── */
.student-search-wrap { position: relative; flex: 1; max-width: 360px; } .student-search-wrap { position: relative; flex: 1; max-width: 360px; }
.student-search-wrap .form-input { width: 100%; } .student-search-wrap .form-input { width: 100%; }
@@ -628,6 +651,7 @@
<button class="tab-btn active" data-tab="dash" onclick="switchDetailTab(this)">Дашборд</button> <button class="tab-btn active" data-tab="dash" onclick="switchDetailTab(this)">Дашборд</button>
<button class="tab-btn" data-tab="members" onclick="switchDetailTab(this)">Ученики</button> <button class="tab-btn" data-tab="members" onclick="switchDetailTab(this)">Ученики</button>
<button class="tab-btn" data-tab="assign" onclick="switchDetailTab(this)">Задания</button> <button class="tab-btn" data-tab="assign" onclick="switchDetailTab(this)">Задания</button>
<button class="tab-btn" data-tab="debts" onclick="switchDetailTab(this)">Долги <span class="tab-badge" id="debts-tab-badge" style="display:none"></span></button>
<button class="tab-btn" data-tab="journal" onclick="switchDetailTab(this)">Журнал</button> <button class="tab-btn" data-tab="journal" onclick="switchDetailTab(this)">Журнал</button>
<button class="tab-btn" data-tab="announce" onclick="switchDetailTab(this)">Объявления</button> <button class="tab-btn" data-tab="announce" onclick="switchDetailTab(this)">Объявления</button>
<button class="tab-btn" data-tab="works" onclick="switchDetailTab(this)"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Работы</button> <button class="tab-btn" data-tab="works" onclick="switchDetailTab(this)"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Работы</button>
@@ -667,6 +691,11 @@
<div class="assign-list" id="d-assignments"></div> <div class="assign-list" id="d-assignments"></div>
</div> </div>
<!-- Debts (что висит у учеников) -->
<div class="tab-pane" id="dtab-debts">
<div id="debts-content"><div class="spinner"></div></div>
</div>
<!-- Journal --> <!-- Journal -->
<div class="tab-pane" id="dtab-journal"> <div class="tab-pane" id="dtab-journal">
<div id="journal-content"><div class="spinner"></div></div> <div id="journal-content"><div class="spinner"></div></div>
@@ -794,7 +823,7 @@
<button class="atype-tab active" id="atype-random-btn" onclick="setAssignType('random')"><i data-lucide="shuffle" style="width:13px;height:13px;vertical-align:-2px"></i> Случайные</button> <button class="atype-tab active" id="atype-random-btn" onclick="setAssignType('random')"><i data-lucide="shuffle" style="width:13px;height:13px;vertical-align:-2px"></i> Случайные</button>
<button class="atype-tab" id="atype-fixtest-btn" onclick="setAssignType('fixed_test')"><i data-lucide="clipboard-list" style="width:13px;height:13px;vertical-align:-2px"></i> Готовый тест</button> <button class="atype-tab" id="atype-fixtest-btn" onclick="setAssignType('fixed_test')"><i data-lucide="clipboard-list" style="width:13px;height:13px;vertical-align:-2px"></i> Готовый тест</button>
<button class="atype-tab" id="atype-file-btn" onclick="setAssignType('file')"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Файл</button> <button class="atype-tab" id="atype-file-btn" onclick="setAssignType('file')"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Файл</button>
<button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Сдать работу</button> <button class="atype-tab" id="atype-upload-btn" onclick="setAssignType('upload')"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Загрузка работы</button>
</div> </div>
<div id="a-type-hint" style="font-size:0.76rem;color:var(--text-3);margin:-12px 0 16px;padding:0 4px;line-height:1.5"> <div id="a-type-hint" style="font-size:0.76rem;color:var(--text-3);margin:-12px 0 16px;padding:0 4px;line-height:1.5">
Вопросы подбираются случайно из базы по выбранному предмету Вопросы подбираются случайно из базы по выбранному предмету
@@ -1005,6 +1034,10 @@
function fmtDate(d) { return d ? new Date(d).toLocaleDateString('ru',{day:'numeric',month:'short',year:'numeric'}) : '—'; } function fmtDate(d) { return d ? new Date(d).toLocaleDateString('ru',{day:'numeric',month:'short',year:'numeric'}) : '—'; }
function pctCls(p) { return p===null?'':p>=75?'pct-hi':p>=50?'pct-mid':'pct-lo'; } function pctCls(p) { return p===null?'':p>=75?'pct-hi':p>=50?'pct-mid':'pct-lo'; }
// Безопасная подстановка строки в JS-строковый литерал внутри inline-обработчика
// (onclick="f('${escJ(name)}')"). esc() не экранирует ' → нужно ещё \-экранировать
// обратный слэш и кавычку, иначе имя ученика с ' даёт XSS.
function escJ(s) { return esc(String(s ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
function toast(msg) { function toast(msg) {
const el = document.getElementById('toast'); const el = document.getElementById('toast');
el.textContent = msg; el.classList.add('show'); el.textContent = msg; el.classList.add('show');
@@ -1133,7 +1166,7 @@
<td><span class="pct-cell ${pc}">${m.avg_pct!==null?m.avg_pct+'%':'—'}</span></td> <td><span class="pct-cell ${pc}">${m.avg_pct!==null?m.avg_pct+'%':'—'}</span></td>
<td style="color:var(--text-3);font-size:0.78rem">${fmtDate(m.joined_at)}</td> <td style="color:var(--text-3);font-size:0.78rem">${fmtDate(m.joined_at)}</td>
<td>${prepToggleHtml(m.id)}</td> <td>${prepToggleHtml(m.id)}</td>
<td><button class="btn-danger" onclick="kickMember(${m.id},'${esc(m.name)}')">Удалить</button></td> <td><button class="btn-danger" onclick="kickMember(${m.id},'${escJ(m.name)}')">Удалить</button></td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
@@ -1199,6 +1232,102 @@
}).join(''); }).join('');
} }
/* ══ Долги: что висит у учеников (классовые + личные задания) ══ */
const DEBT_STATUS = {
overdue: { label: 'просрочено', cls: 'dc-overdue' },
in_progress: { label: 'в процессе', cls: 'dc-in_progress' },
revision: { label: 'на доработке', cls: 'dc-revision' },
not_started: { label: 'не начато', cls: 'dc-not_started' },
};
const DEBT_TYPE_ICON = { test: 'clipboard-list', upload: 'upload', file: 'paperclip', textbook: 'book-open' };
let _debtData = null;
async function loadDebts() {
if (!currentClass) return;
const el = document.getElementById('debts-content');
el.innerHTML = '<div class="spinner"></div>';
try {
_debtData = await LS.classOutstanding(currentClass.id);
renderDebts();
} catch (e) {
el.innerHTML = `<div class="empty">Не удалось загрузить: ${esc(e.message || '')}</div>`;
}
}
function debtChips(c) {
return ['overdue', 'in_progress', 'revision', 'not_started']
.filter(k => c[k] > 0)
.map(k => `<span class="debt-chip ${DEBT_STATUS[k].cls}">${DEBT_STATUS[k].label}: ${c[k]}</span>`)
.join('');
}
function debtItemHtml(s, p) {
const st = DEBT_STATUS[p.status] || DEBT_STATUS.not_started;
const icon = DEBT_TYPE_ICON[p.type] || 'file-text';
const dl = p.deadline ? `<span>${p.status === 'overdue' ? 'просрочено ' : 'до '}${fmtDate(p.deadline)}</span>` : '';
const scopeTag = p.scope === 'direct' ? '<span style="color:var(--violet)">личное</span>' : '';
return `<div class="debt-item">
<i data-lucide="${icon}" style="width:15px;height:15px;color:var(--text-3);flex-shrink:0"></i>
<div style="flex:1;min-width:0">
<div class="debt-item-title">${esc(p.title)}</div>
<div class="debt-item-meta"><span class="debt-chip ${st.cls}">${st.label}</span>${dl}${scopeTag}</div>
</div>
<button class="debt-del" title="Удалить задание" onclick="deleteDebtAssignment(${p.assignment_id},'${p.scope}',${s.id})"><i data-lucide="trash-2" style="width:15px;height:15px"></i></button>
</div>`;
}
function renderDebts() {
const el = document.getElementById('debts-content');
if (!_debtData) { el.innerHTML = ''; return; }
const { summary, students } = _debtData;
const withDebt = students.filter(s => s.counts.total > 0);
const badge = document.getElementById('debts-tab-badge');
if (badge) {
if (summary.overdue > 0) { badge.textContent = summary.overdue; badge.style.display = ''; }
else badge.style.display = 'none';
}
if (!withDebt.length) {
el.innerHTML = `<div class="debt-allclear"><i data-lucide="check-circle-2" style="width:36px;height:36px"></i><div style="margin-top:8px">Задолженностей нет — все ученики всё сдали</div></div>`;
if (window.lucide) lucide.createIcons();
return;
}
let html = `<div class="debt-summary">
<span>Должников: <b>${summary.debtors}</b> из ${summary.students_total}</span>
<span>Просроченных позиций: <b style="color:var(--pink)">${summary.overdue}</b></span>
</div>`;
html += withDebt.map(s => `
<div class="debt-card${s.counts.overdue > 0 ? ' has-over' : ''}">
<div class="debt-head">
<div><div class="debt-name">${esc(s.name)}</div><div class="debt-email">${esc(s.email || '')}</div></div>
<div class="debt-chips">${debtChips(s.counts)}</div>
</div>
${s.pending.map(p => debtItemHtml(s, p)).join('')}
</div>`).join('');
const clear = summary.students_total - withDebt.length;
if (clear > 0) html += `<div class="debt-rest">Остальные ${clear} ученик(ов) — без задолженностей.</div>`;
el.innerHTML = html;
if (window.lucide) lucide.createIcons();
}
async function deleteDebtAssignment(id, scope, studentId) {
let title = 'задание', studentName = '';
const stu = _debtData && _debtData.students.find(s => s.id === studentId);
if (stu) {
studentName = stu.name;
const it = stu.pending.find(p => p.assignment_id === id);
if (it) title = it.title;
}
const msg = scope === 'direct'
? `Удалить персональное задание «${title}» у ученика ${studentName}?`
: `Удалить задание «${title}» у ВСЕГО класса? Оно исчезнет у всех учеников.`;
if (!await LS.confirm(msg, { title: 'Удалить задание', confirmText: 'Удалить', danger: true })) return;
try {
await LS.deleteAssignment(id);
LS.toast('Задание удалено', 'info');
await loadDebts();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ══ Detail tabs ══ */ /* ══ Detail tabs ══ */
function switchDetailTab(btn) { function switchDetailTab(btn) {
const name = btn.dataset.tab; const name = btn.dataset.tab;
@@ -1208,6 +1337,7 @@
document.getElementById('dtab-' + name).classList.add('active'); document.getElementById('dtab-' + name).classList.add('active');
if (name === 'announce') loadAnnouncements(); if (name === 'announce') loadAnnouncements();
if (name === 'dash') loadClassDashboard(); if (name === 'dash') loadClassDashboard();
if (name === 'debts') loadDebts();
if (name === 'journal') loadJournal(); if (name === 'journal') loadJournal();
if (name === 'settings') loadSettings(); if (name === 'settings') loadSettings();
if (name === 'works') loadClassWorks(); if (name === 'works') loadClassWorks();
@@ -1403,7 +1533,7 @@
drop.innerHTML = '<div class="student-opt-empty">Ничего не найдено</div>'; drop.innerHTML = '<div class="student-opt-empty">Ничего не найдено</div>';
} else { } else {
drop.innerHTML = matches.map(s => ` drop.innerHTML = matches.map(s => `
<div class="student-opt" onmousedown="selectStudent(${s.id},'${esc(s.name)}','${esc(s.email)}')"> <div class="student-opt" onmousedown="selectStudent(${s.id},'${escJ(s.name)}','${escJ(s.email)}')">
<div class="student-opt-name">${esc(s.name)}</div> <div class="student-opt-name">${esc(s.name)}</div>
<div class="student-opt-email">${esc(s.email)}</div> <div class="student-opt-email">${esc(s.email)}</div>
</div>`).join(''); </div>`).join('');
@@ -1884,7 +2014,7 @@
const rank = (r, i) => { const rank = (r, i) => {
const medal = ['<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>'][i] || (i+1)+'.'; const medal = ['<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>'][i] || (i+1)+'.';
const pc = pctCls(r.percent); const pc = pctCls(r.percent);
const clickable = r.session_id ? `onclick="openResDrill(${r.session_id},'${esc(r.name)}',${r.percent??'null'})"` : ''; const clickable = r.session_id ? `onclick="openResDrill(${r.session_id},'${escJ(r.name)}',${r.percent??'null'})"` : '';
return `<div class="res-row" ${clickable} title="${r.session_id ? 'Посмотреть ответы' : ''}"> return `<div class="res-row" ${clickable} title="${r.session_id ? 'Посмотреть ответы' : ''}">
<div class="res-rank">${medal}</div> <div class="res-rank">${medal}</div>
<div class="res-name"> <div class="res-name">
+1 -1
View File
@@ -365,7 +365,7 @@
LS.sidebar?.init(); LS.sidebar?.init();
lucide.createIcons(); lucide.createIcons();
const feats = await LS.loadFeatures(); const feats = await LS.loadFeatures();
if (feats.collection === false) { window.location.replace('/403'); return; } if (feats.collection === false && user?.role !== 'admin') { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.(); LS.hideDisabledFeatures?.();
await loadCollection(); await loadCollection();
})(); })();
+55 -8
View File
@@ -181,22 +181,42 @@
background: var(--fn-color, var(--violet)); background: var(--fn-color, var(--violet));
box-shadow: 0 0 6px var(--fn-color, var(--violet)); box-shadow: 0 0 6px var(--fn-color, var(--violet));
} }
.fn-field { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; }
.fn-input { .fn-input {
flex: 1; border: none; outline: none; background: transparent; width: 100%; border: none; outline: none; background: transparent;
font-family: 'Manrope', monospace; font-size: 0.88rem; font-weight: 600; font-family: 'Manrope', monospace; font-size: 0.88rem; font-weight: 600;
color: var(--text); padding: 0; min-width: 0; color: var(--text); padding: 0; min-width: 0;
} }
.fn-input::placeholder { color: var(--text-3); font-weight: 500; } .fn-input::placeholder { color: var(--text-3); font-weight: 500; }
/* KaTeX live preview */ /* введённая функция как KaTeX прямо в строке; клик — правка текста на месте */
.fn-math { display: none; width: 100%; cursor: text; overflow-x: auto; overflow-y: hidden; min-height: 19px; line-height: 1.3; }
.fn-math .katex { color: var(--fn-color, rgba(255,255,255,.9)); font-size: 1.12em; }
.fn-field.has-math .fn-input { display: none; }
.fn-field.has-math .fn-math { display: block; }
/* живое превью формулы — только пока строка в режиме правки (поле в фокусе) */
.fn-preview { .fn-preview {
min-height: 20px; padding: 3px 4px 3px 36px; min-height: 0; padding: 4px 8px 2px 36px; margin-top: 2px;
font-size: 0.82rem; line-height: 1.5; font-size: 0.8rem; line-height: 1.4;
color: rgba(255,255,255,.65); overflow-x: auto; overflow-y: hidden; display: none;
overflow: hidden; display: none;
} }
.fn-preview.has-content { display: block; } .fn-row:focus-within + .fn-preview.has-content { display: block; }
.fn-preview .katex { color: rgba(255,255,255,.8); font-size: 1em; } .fn-preview .katex { color: var(--fn-color, rgba(255,255,255,.6)); font-size: 1.05em; opacity: .8; }
/* keypad вставки структур (как редактор формул) */
.gp-keypad { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; margin-bottom: 6px; }
.kp-btn {
display: flex; align-items: center; justify-content: center; min-height: 32px;
padding: 5px 2px; border-radius: 9px; border: 1.5px solid var(--border-h);
background: rgba(255,255,255,.02); color: var(--text-2);
font-family: 'Manrope', monospace; font-size: 0.78rem; font-weight: 700;
cursor: pointer; transition: all .14s;
}
.kp-btn:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.08); transform: translateY(-1px); }
.kp-btn:active { transform: translateY(0); }
.kp-btn .katex { font-size: 1em; }
.preset-btn .katex { font-size: 0.96em; }
.fn-err { .fn-err {
font-size: 0.68rem; color: var(--pink); font-weight: 600; font-size: 0.68rem; color: var(--pink); font-weight: 600;
padding: 2px 0 0 22px; display: none; padding: 2px 0 0 22px; display: none;
@@ -439,6 +459,33 @@
} }
#graph-canvas { display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; } #graph-canvas { display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
/* плавающие контролы вида поверх canvas */
.graph-view-ctrls {
position: absolute; top: 12px; right: 12px; z-index: 4;
display: flex; flex-direction: column; gap: 6px;
}
.gv-btn {
width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
border-radius: 10px; border: 1px solid rgba(255,255,255,.12);
background: rgba(13,13,26,.62); color: rgba(255,255,255,.72);
cursor: pointer; transition: all .14s; backdrop-filter: blur(6px);
}
.gv-btn svg { width: 17px; height: 17px; }
.gv-btn:hover { border-color: var(--violet); color: #fff; background: rgba(155,93,229,.32); }
.gv-btn.active { border-color: var(--violet); color: #fff; background: var(--violet); box-shadow: 0 0 12px rgba(155,93,229,.5); }
/* кнопки управления функцией (глаз/очистить) в строке */
.fn-act {
flex-shrink: 0; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
border: none; background: transparent; color: var(--text-3);
border-radius: 8px; cursor: pointer; transition: all .14s; padding: 0;
}
.fn-act svg { width: 15px; height: 15px; }
.fn-act:hover { color: var(--fn-color, var(--violet)); background: rgba(155,93,229,.1); }
.fn-act.off { color: var(--text-3); opacity: .85; }
.fn-row.fn-hidden { opacity: .5; }
.fn-row.fn-hidden .fn-math, .fn-row.fn-hidden .fn-input { text-decoration: line-through; text-decoration-color: rgba(255,255,255,.3); }
/* info bar */ /* info bar */
.graph-info-bar { .graph-info-bar {
flex-shrink: 0; flex-shrink: 0;
+34 -16
View File
@@ -408,6 +408,13 @@ body {
background: var(--grad-1); background: var(--grad-1);
} }
/* Админ-инструмент (конструктор задач) — янтарное выделение, как кнопка в тренажёре */
.sb-link.sb-admin-tool { color: #b45309; background: rgba(245,158,11,0.10); }
.sb-link.sb-admin-tool .sb-icon { color: #f59e0b; }
.sb-link.sb-admin-tool:hover { color: #92400e; background: rgba(245,158,11,0.18); }
.sb-link.sb-admin-tool.active { color: #b45309; background: linear-gradient(135deg, rgba(245,158,11,0.20), rgba(249,115,22,0.16)); font-weight: 700; }
.sb-link.sb-admin-tool.active::before { background: linear-gradient(180deg, #f59e0b, #f97316); }
.sb-icon { .sb-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -1039,7 +1046,7 @@ body {
body.no-class #lb-section { display: none !important; } body.no-class #lb-section { display: none !important; }
/* Gamification kill-switch. /* Gamification kill-switch.
When admin turns off the feature, body.no-gamification is set by When admin turns off the feature, .no-gamification is set by
api.js/hideDisabledFeatures and EVERY XP / coin / streak / shop / api.js/hideDisabledFeatures and EVERY XP / coin / streak / shop /
achievement / frame element must vanish across the whole app, achievement / frame element must vanish across the whole app,
not just the dashboard. The rules below cover: not just the dashboard. The rules below cover:
@@ -1050,21 +1057,32 @@ body.no-class #lb-section { display: none !important; }
a catch-all [data-gamified] hook that wraps any future block a catch-all [data-gamified] hook that wraps any future block
authors of new pages should wrap XP UI in a <div data-gamified> authors of new pages should wrap XP UI in a <div data-gamified>
instead of inventing new classes. */ instead of inventing new classes. */
body.no-gamification .gam-bar, .no-gamification .gam-bar,
body.no-gamification .lb-widget, .no-gamification .lb-widget,
body.no-gamification .achievements-section, .no-gamification .achievements-section,
body.no-gamification #tab-btn-achievements, .no-gamification #tab-btn-achievements,
body.no-gamification #tab-btn-shop, .no-gamification #tab-btn-shop,
body.no-gamification #tab-achievements, .no-gamification #tab-achievements,
body.no-gamification #tab-shop, .no-gamification #tab-shop,
body.no-gamification #frames-section, .no-gamification #frames-section,
body.no-gamification .hero-xp-badge, .no-gamification .hero-xp-badge,
body.no-gamification .po-xp, .no-gamification .po-xp,
body.no-gamification .xp-card, .no-gamification .xp-card,
body.no-gamification .xp-bar, .no-gamification .xp-bar,
body.no-gamification .xp-pill, .no-gamification .xp-pill,
body.no-gamification .xp-badge, .no-gamification .xp-badge,
body.no-gamification [data-gamified] { display: none !important; } /* challenges / еженедельные испытания (dashboard) */
.no-gamification .ch-widget,
.no-gamification #ch-section,
/* серия/стрик: календарь, стат-кольцо, чипы на карточке питомца */
.no-gamification .streak-cal,
.no-gamification #sr-streak,
.no-gamification .hc-pet .chip-streak,
.no-gamification .hc-pet .chip-goal,
/* монеты (профиль) и xp-прогресс */
.no-gamification #p-coins-row,
.no-gamification .gam-progress,
.no-gamification [data-gamified] { display: none !important; }
/* /*
RESPONSIVE SMALL PHONES ( 480px) RESPONSIVE SMALL PHONES ( 480px)
+148 -24
View File
@@ -81,7 +81,33 @@
} }
.ab-btn:hover { background: rgba(255,255,255,0.25); } .ab-btn:hover { background: rgba(255,255,255,0.25); }
/* ── Hero cards row (Reading · Lab of day · Pet) ── */ /* ── Hero cards row (Reading · Lab of day · Pet) ── */
.hero-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } .hero-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
/* ── Live online-lesson banner ── */
.live-lesson {
display: flex; align-items: center; gap: 14px; text-decoration: none;
background: linear-gradient(100deg, #059652, #06D6A0); color: #fff;
border-radius: 16px; padding: 14px 20px; margin-bottom: 18px;
box-shadow: 0 6px 22px rgba(5,150,82,0.28); transition: transform .15s, box-shadow .15s;
}
.live-lesson:hover { transform: translateY(-1px); box-shadow: 0 10px 28px rgba(5,150,82,0.34); }
.ll-dot { width: 12px; height: 12px; border-radius: 50%; background: #fff; flex-shrink: 0;
box-shadow: 0 0 0 0 rgba(255,255,255,0.7); animation: llPulse 1.6s infinite; }
@keyframes llPulse {
0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.6); }
70% { box-shadow: 0 0 0 10px rgba(255,255,255,0); }
100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
}
.ll-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ll-text b { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ll-text span { font-size: 0.78rem; opacity: 0.92; }
.ll-cta { flex-shrink: 0; background: rgba(255,255,255,0.95); color: #059652;
font-weight: 800; font-size: 0.82rem; padding: 8px 16px; border-radius: 10px; white-space: nowrap; }
@media (max-width: 480px) {
.live-lesson { padding: 12px 14px; gap: 10px; }
.ll-cta { padding: 7px 12px; font-size: 0.78rem; }
}
.hero-card { .hero-card {
position: relative; border-radius: 18px; padding: 18px 20px; position: relative; border-radius: 18px; padding: 18px 20px;
display: flex; flex-direction: column; min-height: 196px; display: flex; flex-direction: column; min-height: 196px;
@@ -1532,6 +1558,13 @@
<div class="container"> <div class="container">
<!-- Live online-lesson status (student/teacher) -->
<a class="live-lesson" id="live-lesson-banner" href="/classroom" style="display:none">
<span class="ll-dot"></span>
<span class="ll-text"><b id="ll-title">Идёт онлайн-урок</b><span id="ll-sub"></span></span>
<span class="ll-cta" id="ll-cta">Присоединиться</span>
</a>
<!-- Gamification Bar (students only) --> <!-- Gamification Bar (students only) -->
<div class="gam-bar" id="gam-bar" style="display:none"> <div class="gam-bar" id="gam-bar" style="display:none">
<div class="gam-level"> <div class="gam-level">
@@ -1750,6 +1783,11 @@
<div class="widget" id="w-tests"> <div class="widget" id="w-tests">
<div class="w-head"><div class="w-title">Тесты</div></div> <div class="w-head"><div class="w-title">Тесты</div></div>
<div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div> <div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div>
<!-- Витрина: тесты, открытые учителем/админом ученикам -->
<div id="avail-tests-wrap" style="display:none;margin-top:14px">
<div style="font-size:.74rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin:0 0 8px 2px">Доступные тесты</div>
<div class="subj-mini-grid" id="available-tests-list"></div>
</div>
</div> </div>
<!-- Col 3: Progress --> <!-- Col 3: Progress -->
@@ -1884,6 +1922,7 @@
<!-- Join modal --> <!-- Join modal -->
<!-- Quick-start test modal --> <!-- Quick-start test modal -->
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/assignment-utils.js"></script>
<script src="/js/sound.js"></script> <script src="/js/sound.js"></script>
<script src="/js/sidebar.js"></script> <script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script> <script src="/js/notifications.js"></script>
@@ -2222,10 +2261,14 @@
async function loadSubjects() { async function loadSubjects() {
const list = document.getElementById('subjects-list'); const list = document.getElementById('subjects-list');
try { try {
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест', topic:'По теме', random:'Случайный' }; const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест' };
const subjects = await LS.getSubjects(); // Прячем предметы, по которым нечего запустить (нет вопросов в банке и нет фикс-теста).
const subjects = (await LS.getSubjects())
.filter(s => (s.question_count || 0) > 0 || s.default_test_id);
if (!subjects.length) { list.innerHTML = '<div class="empty">Тесты пока недоступны</div>'; return; }
list.innerHTML = subjects.map((s, si) => { list.innerHTML = subjects.map((s, si) => {
const mode = s.default_mode || 'exam'; let mode = s.default_mode || 'exam';
if (mode !== 'exam' && mode !== 'practice') mode = 'practice'; // старые topic/random → practice (старт сессии их не принимает)
const count = s.default_count || 25; const count = s.default_count || 25;
const testId = s.default_test_id || null; const testId = s.default_test_id || null;
const modeLabel = SUBJ_MODE_LABELS[mode] || mode; const modeLabel = SUBJ_MODE_LABELS[mode] || mode;
@@ -2253,6 +2296,32 @@
window.location.href = url; window.location.href = url;
} }
/* Витрина доступных тестов (бэкенд ученику отдаёт только помеченные доступными). */
async function loadAvailableTests() {
const wrap = document.getElementById('avail-tests-wrap');
const list = document.getElementById('available-tests-list');
if (!wrap || !list) return;
try {
const tests = await LS.getTests();
if (!tests || !tests.length) { wrap.style.display = 'none'; return; }
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
list.innerHTML = tests.map((t, i) => {
const color = SUBJ_COLORS[t.subject_slug] || '#9B5DE5';
const iconName = ICONS[t.subject_slug] || 'book-open';
return `<div class="subj-mini-card stagger-item" style="--i:${i}" onclick="startSubjectTest('${t.subject_slug}','exam',25,${t.id})">
<div class="smc-icon" style="background:${color}">${lci(iconName)}</div>
<div class="smc-body">
<div class="smc-name">${esc(t.title)}</div>
<div class="smc-meta">${SUBJ_N[t.subject_slug] || t.subject_slug} · ${t.question_count} вопр.</div>
</div>
<i data-lucide="chevron-right" class="smc-arrow"></i>
</div>`;
}).join('');
wrap.style.display = '';
reIcons();
} catch { wrap.style.display = 'none'; }
}
/* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */ /* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */
async function loadAssignments() { async function loadAssignments() {
try { try {
@@ -2346,15 +2415,8 @@
body.classList.toggle('collapsed'); body.classList.toggle('collapsed');
} }
/* ── Urgency sort score (lower = shown first) ── */ /* ── Urgency sort score (lower = shown first) — общий модуль ── */
function urgencyScore(a) { function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
if (a.session_status === 'in_progress') return -4; // in progress <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> top
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
if (dlMs < 0) return -3; // overdue
if (dlMs < 24 * 3600 * 1000) return -2; // urgent <24h
if (dlMs < Infinity) return dlMs; // sorted by deadline
return 1e12; // no deadline <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> last
}
/* ── Is assignment urgent for teacher (within 48h) ── */ /* ── Is assignment urgent for teacher (within 48h) ── */
function isTeacherUrgent(a) { function isTeacherUrgent(a) {
@@ -2422,7 +2484,7 @@
} }
/* ── Upload-only homework (no test, no file) ── */ /* ── Upload-only homework (no test, no file) ── */
if (a.is_homework && !a.file_id && !a.session_id && a.count <= 1 && (!a.subject_slug || a.subject_slug === 'other')) { if (AssignmentUtils.type(a) === 'upload') {
const over = a.deadline && new Date(a.deadline) < new Date(); const over = a.deadline && new Date(a.deadline) < new Date();
const sub = _mySubmissions.get(a.id); const sub = _mySubmissions.get(a.id);
const metaParts = [classStr, dl ? `до ${dl}` : null, const metaParts = [classStr, dl ? `до ${dl}` : null,
@@ -2659,18 +2721,22 @@
reIcons(); return; reIcons(); return;
} }
// Classify // Classify (active/overdue/done) — тип и «сдано» из общего модуля AssignmentUtils.
function classify(a) { function classify(a) {
const maxAtt = a.max_attempts || 0; const t = AssignmentUtils.type(a);
const usedAtt = a.attempts_used ?? 0; if (t === 'textbook') {
if (a.textbook_id) { if (AssignmentUtils.isDone(a)) return 'done';
if (a.completed_at || a.textbook_all_read) return 'done';
if (a.deadline && new Date(a.deadline) < now) return 'overdue'; if (a.deadline && new Date(a.deadline) < now) return 'overdue';
return 'active'; return 'active';
} }
if (maxAtt > 0 && usedAtt >= maxAtt) return 'done'; if (t === 'test') {
if (a.session_status === 'completed' && a.mode !== 'repeat') return 'done'; if (AssignmentUtils.isDone(a)) return 'done';
if (!a.file_id && a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue'; if (a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
return 'active';
}
// upload / file: «сдано» по сабмишену здесь не считаем (как и раньше — статус
// показывает чип сдачи в карточке); upload просрочивается по дедлайну, file — всегда активен.
if (t === 'upload' && a.deadline && new Date(a.deadline) < now) return 'overdue';
return 'active'; return 'active';
} }
@@ -3670,12 +3736,36 @@
document.getElementById('act-cal-pane').classList.toggle('visible', tab === 'calendar'); document.getElementById('act-cal-pane').classList.toggle('visible', tab === 'calendar');
} }
/* Колонка прогресса (#w-progress-col) — это один .widget-бокс с тремя секциями
(карточка / по предметам / результаты). Если все секции скрыты (напр. флешкарты
отключены и нет данных) — прячем сам бокс, иначе висит пустая рамка. */
function syncProgressCol() {
const col = document.getElementById('w-progress-col');
if (!col) return;
const any = ['w-flashcard', 'w-subj-progress', 'w-last-results'].some(id => {
const e = document.getElementById(id);
return e && getComputedStyle(e).display !== 'none';
});
col.style.display = any ? '' : 'none';
}
/* Hero-ряд (чтение/лаборатория/питомец): карточки скрываются по фиче (через CSS).
Подгоняем число колонок под видимые карточки и прячем весь ряд, если пусто. */
function syncHeroRow() {
const row = document.getElementById('hero-row');
if (!row) return;
const vis = [...row.querySelectorAll('.hero-card')]
.filter(c => getComputedStyle(c).display !== 'none');
row.style.display = vis.length ? '' : 'none';
// ширину колонок под число карточек делает CSS (auto-fit), мобайл не трогаем.
}
/* ══ WIDGET: Last results (compact, 5 items) ══════════════════════ */ /* ══ WIDGET: Last results (compact, 5 items) ══════════════════════ */
function loadLastResultsWidget(rows) { function loadLastResultsWidget(rows) {
const w = document.getElementById('w-last-results'); const w = document.getElementById('w-last-results');
if (!w) return; if (!w) return;
const completed = (rows || []).filter(r => r.score !== null && r.total > 0).slice(0, 5); const completed = (rows || []).filter(r => r.score !== null && r.total > 0).slice(0, 5);
if (!completed.length) { w.style.display = 'none'; return; } if (!completed.length) { w.style.display = 'none'; syncProgressCol(); return; }
w.style.display = ''; w.style.display = '';
document.getElementById('last-results-list').innerHTML = completed.map(h => { document.getElementById('last-results-list').innerHTML = completed.map(h => {
const pct = Math.round(h.score / h.total * 100); const pct = Math.round(h.score / h.total * 100);
@@ -3689,6 +3779,7 @@
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
syncProgressCol();
} }
/* ══ WIDGET: Subject progress bars ════════════════════════════════ */ /* ══ WIDGET: Subject progress bars ════════════════════════════════ */
@@ -3702,7 +3793,7 @@
bySubj[r.subject_slug].scores.push(Math.round(r.score / r.total * 100)); bySubj[r.subject_slug].scores.push(Math.round(r.score / r.total * 100));
}); });
const entries = Object.entries(bySubj); const entries = Object.entries(bySubj);
if (!entries.length) { w.style.display = 'none'; return; } if (!entries.length) { w.style.display = 'none'; syncProgressCol(); return; }
w.style.display = ''; w.style.display = '';
document.getElementById('subj-progress-bars').innerHTML = entries.map(([slug, d]) => { document.getElementById('subj-progress-bars').innerHTML = entries.map(([slug, d]) => {
const avg = Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length); const avg = Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length);
@@ -3713,6 +3804,7 @@
<span class="sp-pct" style="color:${color}">${avg}%</span> <span class="sp-pct" style="color:${color}">${avg}%</span>
</div>`; </div>`;
}).join(''); }).join('');
syncProgressCol();
} }
/* ══ WIDGET: Theory progress ══════════════════════════════════════ */ /* ══ WIDGET: Theory progress ══════════════════════════════════════ */
@@ -4285,6 +4377,7 @@
loadLabOfDay(); loadLabOfDay();
loadPetHero(); loadPetHero();
loadFlashcardWidget(); loadFlashcardWidget();
syncHeroRow(); // спрятать карточки отключённых модулей и подогнать сетку
} }
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */ /* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
@@ -4298,6 +4391,7 @@
renderFlashcardWidget(r); renderFlashcardWidget(r);
w.style.display = ''; w.style.display = '';
} catch { /* фича выключена или ошибка — оставляем скрытым */ } } catch { /* фича выключена или ошибка — оставляем скрытым */ }
syncProgressCol(); // если карточка скрыта и нет прогресса/результатов — спрятать бокс
} }
function renderFlashcardWidget(r) { function renderFlashcardWidget(r) {
@@ -4473,6 +4567,7 @@
} else { } else {
// Student: full layout // Student: full layout
loadSubjects(); loadSubjects();
loadAvailableTests();
loadAssignments(); loadAssignments();
loadStats(); loadStats();
loadGamification(); loadGamification();
@@ -4481,13 +4576,42 @@
loadDashboardStats(); loadDashboardStats();
applyDashboardPrefs(); applyDashboardPrefs();
} }
loadLiveLesson();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadLiveLesson(); });
LS.notif.init(); LS.notif.init();
// Статус онлайн-урока: показываем баннер, если у ученика/учителя идёт активная сессия.
async function loadLiveLesson() {
const el = document.getElementById('live-lesson-banner');
if (!el) return;
let data;
try { data = await LS.crGetMySession(); } catch { el.style.display = 'none'; return; }
const s = data && data.session;
if (!s) { el.style.display = 'none'; return; }
const title = (s.title && s.title.trim()) ? s.title.trim() : 'Онлайн-урок';
document.getElementById('ll-title').textContent = (isTeacher ? 'Ваш урок идёт: ' : 'Идёт урок: ') + title;
let sub;
if (isTeacher) {
const online = Array.isArray(s.attendance) ? s.attendance.filter(a => !a.left_at).length : 0;
sub = online ? (online + ' онлайн') : 'ожидание учеников';
} else {
sub = data.wasJoined ? 'Вы участник — вернуться к доске' : 'Нажмите, чтобы присоединиться';
}
document.getElementById('ll-sub').textContent = sub;
document.getElementById('ll-cta').textContent = isTeacher
? 'Вернуться к доске'
: (data.wasJoined ? 'Вернуться' : 'Присоединиться');
el.style.display = '';
}
// Real-time SSE for page-specific events (notif handled by notifications.js) // Real-time SSE for page-specific events (notif handled by notifications.js)
LS.connectSSE(ev => { LS.connectSSE(ev => {
if (ev.type === 'assignment') { if (ev.type === 'assignment') {
LS.toast(ev.message, 'info'); LS.toast(ev.message, 'info');
isTeacher ? loadAdminAssignments() : loadAssignments(); isTeacher ? loadAdminAssignments() : loadAssignments();
} else if (ev.type === 'classroom_live') {
loadLiveLesson();
if (ev.state === 'started' && !isTeacher && window.LS && LS.sfx) LS.sfx.play('user_joined');
} else if (ev.type === 'session') { } else if (ev.type === 'session') {
LS.toast(ev.message, 'info'); LS.toast(ev.message, 'info');
if (isTeacher) loadAdminSessions(); if (isTeacher) loadAdminSessions();
+4 -2
View File
@@ -447,7 +447,7 @@
</div> </div>
<div class="fc-stats" id="fc-stats-bar"></div> <div class="fc-stats" id="fc-stats-bar"></div>
<div class="deck-grid" id="deck-grid"> <div class="deck-grid" id="deck-grid">
<div style="grid-column:1/-1; text-align:center; padding:40px; color:var(--text-3)">Загрузка…</div> <div style="grid-column:1/-1"><div class="spinner"></div></div>
</div> </div>
</div> </div>
@@ -694,6 +694,7 @@ let _curDeckReadonly = false; // общая колода (не вла
let _shareData = { shares: [], classes: [], students: [] }; let _shareData = { shares: [], classes: [], students: [] };
let _shareTab = 'class'; let _shareTab = 'class';
let _shareSet = new Set(); // ключи 'class:<id>' / 'user:<id>' текущих назначений let _shareSet = new Set(); // ключи 'class:<id>' / 'user:<id>' текущих назначений
let _collLabels = null; // { collectionKey: label } для заголовков папок-коллекций (объявлено ДО init, иначе TDZ)
(async () => { (async () => {
/* ── auth ── */ /* ── auth ── */
@@ -747,8 +748,9 @@ function bindStudyKeys() {
}); });
} }
let _collLabels = null; // { collectionKey: label } для заголовков папок-коллекций
async function loadDecks() { async function loadDecks() {
const _grid = document.getElementById('deck-grid');
if (_grid && LS.skeleton) _grid.innerHTML = LS.skeleton(6, 'card'); // системные skeleton-карточки вместо «Загрузка…»
const [decks, stats, tracks] = await Promise.all([ const [decks, stats, tracks] = await Promise.all([
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})), LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
LS.api('/api/flashcards/stats').catch(()=>null), LS.api('/api/flashcards/stats').catch(()=>null),
+228 -22
View File
@@ -125,6 +125,41 @@
/* student name in teacher view */ /* student name in teacher view */
.hw-student-name { font-size: 0.78rem; font-weight: 700; color: var(--violet); } .hw-student-name { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
/* ── section titles (multi-block student view) ── */
.hw-sec-title {
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
color: #0F172A; margin: 4px 0 12px; display: flex; align-items: center; gap: 9px;
}
.hw-sec-count {
font-family: 'Manrope', sans-serif; font-size: 0.7rem; font-weight: 700;
color: var(--violet); background: rgba(155,93,229,0.1);
padding: 2px 9px; border-radius: 999px;
}
#hw-active-wrap { margin-bottom: 28px; }
/* ── active assignment cards ── */
.hw-acard {
background: #fff; border-radius: 16px; padding: 16px 18px;
border: 1px solid rgba(15,23,42,0.06); border-left: 3px solid var(--ac, #9B5DE5);
display: flex; align-items: center; gap: 14px; transition: all .15s;
}
.hw-acard:hover { box-shadow: 0 2px 12px rgba(15,23,42,0.06); }
.hw-acard.over { border-left-color: #EF476F; }
.hw-acard.urgent { border-left-color: #F59E0B; }
.hw-acard-icon {
width: 42px; height: 42px; border-radius: 12px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
.hw-acard-body { flex: 1; min-width: 0; }
.hw-acard-title { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 4px; }
.hw-acard-meta { font-size: 0.74rem; color: var(--text-3); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.hw-acard-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.hw-dl-chip { font-size: 0.7rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
.hw-dl-soon { background: rgba(245,158,11,0.12); color: #F59E0B; }
.hw-dl-over { background: rgba(239,71,111,0.12); color: #EF476F; }
.hw-dl-ok { background: rgba(15,23,42,0.05); color: var(--text-3); }
.hw-sub-chip { font-size: 0.68rem; font-weight: 700; padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
@media (max-width: 768px) { @media (max-width: 768px) {
.container { padding: 16px 14px 80px; } .container { padding: 16px 14px 80px; }
.hw-top { gap: 8px; } .hw-top { gap: 8px; }
@@ -133,6 +168,8 @@
.hw-card-right { flex-direction: row; align-items: center; justify-content: flex-start; width: 100%; } .hw-card-right { flex-direction: row; align-items: center; justify-content: flex-start; width: 100%; }
.hw-card-actions { flex-wrap: wrap; } .hw-card-actions { flex-wrap: wrap; }
.hw-upload-area { padding: 20px 16px; } .hw-upload-area { padding: 20px 16px; }
.hw-acard { flex-wrap: wrap; }
.hw-acard-right { width: 100%; justify-content: flex-end; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.container { padding: 12px 10px 80px; } .container { padding: 12px 10px 80px; }
@@ -152,8 +189,15 @@
<div class="page-title">Домашние задания</div> <div class="page-title">Домашние задания</div>
<div class="page-sub" id="hw-sub">Загрузка…</div> <div class="page-sub" id="hw-sub">Загрузка…</div>
<!-- Student: active assignments (что нужно сделать) -->
<div id="hw-active-wrap" style="display:none">
<div class="hw-sec-title">Актуальные задания <span class="hw-sec-count" id="hw-active-count"></span></div>
<div class="hw-list" id="hw-active-list"></div>
</div>
<!-- Student: upload area --> <!-- Student: upload area -->
<div id="hw-upload-wrap" style="display:none"> <div id="hw-upload-wrap" style="display:none">
<div class="hw-sec-title">Сдать работу</div>
<div class="hw-upload-area" id="hw-upload-area" onclick="document.getElementById('hw-file-input').click()"> <div class="hw-upload-area" id="hw-upload-area" onclick="document.getElementById('hw-file-input').click()">
<div class="hw-upload-icon"><i data-lucide="upload-cloud" style="width:36px;height:36px"></i></div> <div class="hw-upload-icon"><i data-lucide="upload-cloud" style="width:36px;height:36px"></i></div>
<div class="hw-upload-text">Загрузить работу</div> <div class="hw-upload-text">Загрузить работу</div>
@@ -195,6 +239,7 @@
</div> </div>
<!-- Student: status filters --> <!-- Student: status filters -->
<div class="hw-sec-title" id="hw-mysubs-title" style="display:none">Мои сдачи</div>
<div class="hw-top" id="hw-top-student" style="display:none"> <div class="hw-top" id="hw-top-student" style="display:none">
<div class="hw-status-filters"> <div class="hw-status-filters">
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button> <button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
@@ -213,6 +258,7 @@
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script> <script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script> <script src="/js/notifications.js"></script>
<script src="/js/assignment-utils.js"></script>
<script> <script>
const { user, isTeacher, isAdmin } = LS.initPage(); const { user, isTeacher, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in'); if (!user) throw new Error('Not logged in');
@@ -247,6 +293,14 @@
resubmitted: 'Повторно', accepted: 'Принято' resubmitted: 'Повторно', accepted: 'Принято'
}; };
/* subject label/colour/icon maps (как на дашборде) */
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
const SUBJ_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap', other:'file-check' };
const SUBJ_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B', other:'#7c3aed' };
let _assignments = []; // актуальные задания (LS.myAssignments)
let _subByAsgn = new Map(); // assignment_id -> последняя сдача
/* ── filter ── */ /* ── filter ── */
function filterStatus(st, btn) { function filterStatus(st, btn) {
_statusFilter = st; _statusFilter = st;
@@ -257,33 +311,31 @@
/* ── STUDENT VIEW ── */ /* ── STUDENT VIEW ── */
async function initStudent() { async function initStudent() {
document.getElementById('hw-sub').textContent = 'Сдавайте работы и отслеживайте оценки'; document.getElementById('hw-sub').textContent = 'Ваши актуальные задания и сданные работы';
document.getElementById('hw-top-student').style.display = ''; document.getElementById('hw-top-student').style.display = '';
document.getElementById('hw-mysubs-title').style.display = '';
// Find student's class // Find student's class (нужен для загрузки работ без привязки к заданию)
try { try {
const classes = await LS.myClasses(); const classes = await LS.myClasses();
if (classes.length) { if (classes.length) {
_studentClassId = classes[0].id; _studentClassId = classes[0].id;
document.getElementById('hw-upload-wrap').style.display = ''; document.getElementById('hw-upload-wrap').style.display = '';
// Load assignments for selector
try {
const feed = await LS.classFeed(classes[0].id);
const sel = document.getElementById('hw-assignment-sel');
(feed.assignments || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.title;
sel.appendChild(opt);
});
} catch {}
} }
} catch {} } catch {}
// Load submissions // Грузим актуальные задания (все классы) + сдачи параллельно
try { try {
_submissions = await LS.getMySubmissions(); const [assigns, subs] = await Promise.all([
LS.myAssignments().catch(() => []),
LS.getMySubmissions().catch(() => []),
]);
_assignments = Array.isArray(assigns) ? assigns : [];
_submissions = Array.isArray(subs) ? subs : [];
_subByAsgn.clear();
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
populateAssignmentSelect(_assignments);
renderActiveAssignments();
renderSubmissions(); renderSubmissions();
} catch { } catch {
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>'; document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
@@ -312,14 +364,22 @@
} }
async function submitHomework() { async function submitHomework() {
if (!_selectedFile || !_studentClassId) return; if (!_selectedFile) return;
const sel = document.getElementById('hw-assignment-sel');
const assignId = sel.value;
// Класс берём от выбранного задания (важно для учеников в нескольких классах),
// иначе — первый класс ученика.
let classId = _studentClassId;
if (assignId && sel.selectedOptions[0] && sel.selectedOptions[0].dataset.class) {
classId = sel.selectedOptions[0].dataset.class;
}
if (!classId) { LS.toast('Вы не состоите в классе', 'error'); return; }
const btn = document.getElementById('hw-submit-btn'); const btn = document.getElementById('hw-submit-btn');
btn.disabled = true; btn.disabled = true;
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append('file', _selectedFile); fd.append('file', _selectedFile);
fd.append('class_id', _studentClassId); fd.append('class_id', classId);
const assignId = document.getElementById('hw-assignment-sel').value;
if (assignId) fd.append('assignment_id', assignId); if (assignId) fd.append('assignment_id', assignId);
const msg = document.getElementById('hw-message').value.trim(); const msg = document.getElementById('hw-message').value.trim();
if (msg) fd.append('message', msg); if (msg) fd.append('message', msg);
@@ -336,12 +396,20 @@
// Reload // Reload
_submissions = await LS.getMySubmissions(); _submissions = await LS.getMySubmissions();
renderSubmissions(); syncStudentLists();
} catch (e) { } catch (e) {
LS.toast(e.message || 'Ошибка отправки', 'error'); LS.toast(e.message || 'Ошибка отправки', 'error');
} finally { btn.disabled = !_selectedFile; } } finally { btn.disabled = !_selectedFile; }
} }
/* Пересобрать карту сдач и перерисовать обе студенческие секции. */
function syncStudentLists() {
_subByAsgn.clear();
_submissions.forEach(s => { if (s.assignment_id) _subByAsgn.set(s.assignment_id, s); });
renderActiveAssignments();
renderSubmissions();
}
async function resubmitHomework(subId) { async function resubmitHomework(subId) {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
@@ -355,7 +423,7 @@
await LS.resubmitWork(subId, fd); await LS.resubmitWork(subId, fd);
LS.toast('Работа отправлена повторно!', 'success'); LS.toast('Работа отправлена повторно!', 'success');
_submissions = await LS.getMySubmissions(); _submissions = await LS.getMySubmissions();
renderSubmissions(); syncStudentLists();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}; };
input.click(); input.click();
@@ -366,7 +434,7 @@
try { try {
await LS.deleteSubmission(id); await LS.deleteSubmission(id);
_submissions = _submissions.filter(s => s.id !== id); _submissions = _submissions.filter(s => s.id !== id);
renderSubmissions(); syncStudentLists();
LS.toast('Удалено', 'info'); LS.toast('Удалено', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} }
@@ -381,6 +449,144 @@
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} }
/* ══ АКТУАЛЬНЫЕ ЗАДАНИЯ (что нужно сделать) ══════════════════════════ */
// Тип / «сдано» / срочность — из общего модуля AssignmentUtils (тот же, что у дашборда
// и сервера). Вид ученика: upload/file закрыт ТОЛЬКО при принятой сдаче (acceptedOnly).
function asgnType(a) { return AssignmentUtils.type(a); }
function asgnDone(a) { return AssignmentUtils.isDone(a, _subByAsgn.get(a.id), { acceptedOnly: true }); }
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
function deadlineChip(a) {
if (!a.deadline) return '<span class="hw-dl-chip hw-dl-ok">Без срока</span>';
const dlMs = new Date(a.deadline) - Date.now();
const date = new Date(a.deadline).toLocaleDateString('ru', { day: 'numeric', month: 'short' });
if (dlMs < 0) return `<span class="hw-dl-chip hw-dl-over">Просрочено · ${date}</span>`;
if (dlMs < 24 * 3600 * 1000) return `<span class="hw-dl-chip hw-dl-soon">Сегодня · до ${date}</span>`;
const days = Math.ceil(dlMs / 86400000);
const txt = days === 1 ? '1 день' : `${days} дн.`;
return `<span class="hw-dl-chip hw-dl-ok">${txt} · до ${date}</span>`;
}
function actionFor(a) {
const t = asgnType(a);
if (t === 'textbook') {
let hash = '';
if (a.textbook_paragraphs) { const m = String(a.textbook_paragraphs).match(/^\s*(\d+)/); if (m) hash = '#p' + m[1]; }
const href = `/textbook/${a.textbook_slug || ''}${hash}`;
return `<a class="hw-btn hw-btn-primary" href="${href}">${(a.textbook_read_count || 0) > 0 ? 'Продолжить' : 'Открыть'}</a>`;
}
if (t === 'file') {
const sub = _subByAsgn.get(a.id);
const submit = sub && sub.status !== 'revision'
? `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`
: `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
return `<a class="hw-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download>Скачать</a>${submit}`;
}
if (t === 'upload') {
const sub = _subByAsgn.get(a.id);
if (sub && sub.status !== 'revision') {
return `<span class="hw-badge hw-badge-${sub.status}">${STATUS_LABELS[sub.status] || sub.status}</span>`;
}
return `<button class="hw-btn hw-btn-primary" onclick="sdatNow(${a.id})">${sub ? 'Пересдать' : 'Сдать'}</button>`;
}
// test
const inProgress = a.session_status === 'in_progress';
const isDone = a.session_status === 'completed';
const label = inProgress ? 'Продолжить' : (isDone && a.mode === 'repeat') ? 'Повторить' : 'Начать';
return `<button class="hw-btn hw-btn-primary" onclick="startAsgn(event,${a.id},'${a.mode || 'exam'}')">${label}</button>`;
}
function activeCardHtml(a) {
const t = asgnType(a);
const color = SUBJ_COLORS[a.subject_slug] || (t === 'textbook' ? '#7c3aed' : '#9B5DE5');
const icon = t === 'textbook' ? 'book-open-text'
: t === 'file' ? 'paperclip'
: t === 'upload' ? 'upload'
: (SUBJ_ICONS[a.subject_slug] || 'file-text');
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
const over = dlMs < 0;
const urgent = !over && dlMs < 24 * 3600 * 1000;
const cls = over ? ' over' : urgent ? ' urgent' : '';
const classStr = a.class_id ? esc(a.class_name) : 'Личное';
const subjStr = SUBJ[a.subject_slug] || (t === 'textbook' ? 'Чтение' : '');
const meta = [classStr, subjStr].filter(Boolean).join(' · ');
return `<div class="hw-acard${cls}" style="--ac:${color}">
<div class="hw-acard-icon" style="background:${color}1a;color:${color}"><i data-lucide="${icon}" style="width:20px;height:20px"></i></div>
<div class="hw-acard-body">
<div class="hw-acard-title">${esc(a.title)}</div>
<div class="hw-acard-meta">${meta ? `<span>${meta}</span>` : ''}${deadlineChip(a)}</div>
</div>
<div class="hw-acard-right">${actionFor(a)}</div>
</div>`;
}
function renderActiveAssignments() {
const wrap = document.getElementById('hw-active-wrap');
const list = document.getElementById('hw-active-list');
if (!wrap || !list) return;
// Только задания с флагом ДЗ (is_homework) — это страница «Домашние задания»,
// обычные тесты/экзамены сюда не попадают.
const active = _assignments
.filter(a => a.is_homework && !asgnDone(a))
.sort((x, y) => urgencyScore(x) - urgencyScore(y));
if (!active.length) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
document.getElementById('hw-active-count').textContent = active.length;
list.innerHTML = active.map(activeCardHtml).join('');
lucide.createIcons();
}
// Наполнить выпадашку «Задание» при загрузке работы — по ВСЕМ классам ученика.
function populateAssignmentSelect(list) {
const sel = document.getElementById('hw-assignment-sel');
if (!sel) return;
sel.querySelectorAll('option[data-asgn]').forEach(o => o.remove());
// Привязать загрузку можно только к ДЗ, куда ученик сдаёт файл (тип upload/file).
list.filter(a => a.is_homework && (asgnType(a) === 'upload' || asgnType(a) === 'file')).forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.title + (a.class_name && a.class_name !== 'Личное задание' ? ' · ' + a.class_name : '');
opt.dataset.asgn = '1';
if (a.class_id) opt.dataset.class = a.class_id;
sel.appendChild(opt);
});
}
// Начать/продолжить тест-задание (как на дашборде).
async function startAsgn(e, id, mode) {
const btn = e.currentTarget;
const orig = btn.textContent;
btn.disabled = true; btn.textContent = '…';
try {
const r = await LS.startAssignment(id);
if (r.error && r.max_attempts) {
LS.toast(`Исчерпан лимит попыток (${r.attempts_used}/${r.max_attempts})`, 'warn');
btn.disabled = false; btn.textContent = orig; return;
}
const aMode = r.assignment_mode || mode || 'exam';
if (r.status === 'completed' && aMode !== 'repeat') location.href = `/test-result?session=${r.session_id}`;
else location.href = `/test-run?session=${r.session_id}&assignment_mode=${aMode}`;
} catch (err) {
const isLimit = err.message && (err.message.includes('лимит') || err.message.includes('Исчерпан'));
LS.toast(isLimit ? err.message : ('Ошибка: ' + err.message), isLimit ? 'warn' : 'error');
btn.disabled = false; btn.textContent = orig;
}
}
// «Сдать» из карточки → прокрутить к области загрузки и преднабрать задание.
function sdatNow(assignId) {
const wrap = document.getElementById('hw-upload-wrap');
if (!wrap || wrap.style.display === 'none') {
LS.toast('Загрузка работ доступна участникам класса', 'warn'); return;
}
const sel = document.getElementById('hw-assignment-sel');
if (sel && [...sel.options].some(o => o.value == assignId)) sel.value = String(assignId);
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
const area = document.getElementById('hw-upload-area');
if (area) { area.classList.add('dragover'); setTimeout(() => area.classList.remove('dragover'), 1200); }
}
/* ── TEACHER VIEW ── */ /* ── TEACHER VIEW ── */
async function initTeacher() { async function initTeacher() {
document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки'; document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки';
+8 -2
View File
@@ -284,9 +284,15 @@
el.innerHTML = rows.map(r => { el.innerHTML = rows.map(r => {
const dt = new Date(r.created_at); const dt = new Date(r.created_at);
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'}); const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid var(--pink)"> const isClient = r.level === 'client';
const accent = isClient ? 'var(--violet)' : 'var(--pink)';
const badge = isClient
? `<span style="font-size:0.64rem;font-weight:800;letter-spacing:.03em;padding:2px 7px;border-radius:999px;background:rgba(155,93,229,0.12);color:var(--violet)">БРАУЗЕР</span>`
: '';
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid ${accent}">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px"> <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
<span style="font-size:0.78rem;color:var(--pink);font-weight:700">${r.method || ''} ${esc(r.route || '')}</span> ${badge}
<span style="font-size:0.78rem;color:${accent};font-weight:700">${esc(r.method || '')} ${esc(r.route || '')}</span>
<span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span> <span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span>
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''} ${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
</div> </div>
+5 -5
View File
@@ -311,7 +311,7 @@
} }
async function bulk(allow) { async function bulk(allow) {
if (!allow && !confirm(`Закрыть «${_selContent.title}» у всех классов?`)) return; if (!allow && !await LS.confirm(`Закрыть доступ к «${_selContent.title}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
const classes = _targets.classes || []; const classes = _targets.classes || [];
try { try {
await Promise.all(classes.map(c => await Promise.all(classes.map(c =>
@@ -436,7 +436,7 @@
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; } if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
const srcId = Number(sel.value); const srcId = Number(sel.value);
const srcName = sel.options[sel.selectedIndex].text; const srcName = sel.options[sel.selectedIndex].text;
if (!confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`)) return; if (!await LS.confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`, { title: 'Скопировать доступ', confirmText: 'Скопировать', danger: false })) return;
try { try {
const src = await LS.accessClassOpen(srcId); const src = await LS.accessClassOpen(srcId);
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref])); const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
@@ -449,7 +449,7 @@
} }
async function classBulk(allow) { async function classBulk(allow) {
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return; if (!allow && !await LS.confirm(`Закрыть весь контент у класса «${_selClass.name}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]])); const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
try { try {
await Promise.all(all.map(([type, ref]) => await Promise.all(all.map(([type, ref]) =>
@@ -543,7 +543,7 @@
const classes = _matrix.classes || []; const classes = _matrix.classes || [];
const allOpen = classes.length && classes.every(c => ((_matrix.open[c.id] || {})[type] || []).includes(ref)); const allOpen = classes.length && classes.every(c => ((_matrix.open[c.id] || {})[type] || []).includes(ref));
const open = !allOpen; const open = !allOpen;
if (!open && !confirm(`Закрыть «${contentTitle(type, ref)}» у всех классов?`)) return; if (!open && !await LS.confirm(`Закрыть доступ к «${contentTitle(type, ref)}» у всех классов?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
try { try {
await Promise.all(classes.map(c => LS.accessSetRule(type, ref, 'class', c.id, open ? 1 : null))); await Promise.all(classes.map(c => LS.accessSetRule(type, ref, 'class', c.id, open ? 1 : null)));
classes.forEach(c => mxApply(_matrix.open[c.id] || (_matrix.open[c.id] = {}), type, ref, open)); classes.forEach(c => mxApply(_matrix.open[c.id] || (_matrix.open[c.id] = {}), type, ref, open));
@@ -557,7 +557,7 @@
const allOpen = items.length && items.every(([t, ref]) => (o[t] || []).includes(ref)); const allOpen = items.length && items.every(([t, ref]) => (o[t] || []).includes(ref));
const open = !allOpen; const open = !allOpen;
const cls = (_matrix.classes.find(c => c.id === classId) || {}).name || ('#' + classId); const cls = (_matrix.classes.find(c => c.id === classId) || {}).name || ('#' + classId);
if (!open && !confirm(`Закрыть весь контент у класса «${cls}»?`)) return; if (!open && !await LS.confirm(`Закрыть весь контент у класса «${cls}»?`, { title: 'Закрыть доступ', confirmText: 'Закрыть' })) return;
try { try {
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', classId, open ? 1 : null))); await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', classId, open ? 1 : null)));
items.forEach(([t, ref]) => mxApply(o, t, ref, open)); items.forEach(([t, ref]) => mxApply(o, t, ref, open));
+126 -4
View File
@@ -65,10 +65,11 @@
var cfg = {}; try { cfg = await LS.adminGetAssistant(); } catch (e) {} var cfg = {}; try { cfg = await LS.adminGetAssistant(); } catch (e) {}
var providers = cfg.providers || [], activeId = cfg.activeId, presets = cfg.presets || [], kiloModels = cfg.kiloModels || []; var providers = cfg.providers || [], activeId = cfg.activeId, presets = cfg.presets || [], kiloModels = cfg.kiloModels || [];
var health = cfg.health || {};
// ── Баннер failover ── // ── Баннер failover ──
if (cfg.failover) { if (cfg.failover) {
var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка' }; var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка', health: 'не прошёл авто-проверку' };
var when = ''; try { when = new Date(fo.at).toLocaleString('ru'); } catch (e) {} var when = ''; try { when = new Date(fo.at).toLocaleString('ru'); } catch (e) {}
var ban = document.createElement('div'); var ban = document.createElement('div');
ban.style.cssText = 'margin-top:14px;padding:11px 14px;border-radius:11px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);color:#92400e;font-size:.84rem;line-height:1.5;display:flex;align-items:flex-start;gap:12px'; ban.style.cssText = 'margin-top:14px;padding:11px 14px;border-radius:11px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);color:#92400e;font-size:.84rem;line-height:1.5;display:flex;align-items:flex-start;gap:12px';
@@ -108,6 +109,20 @@
'</div>'; '</div>';
host.appendChild(pc); host.appendChild(pc);
// ── Сканер бесплатных моделей шлюза Kilo ──
var sk = document.createElement('div');
sk.className = 'perm-card'; sk.style.cssText = 'flex-direction:column;align-items:stretch;gap:10px;margin-top:14px';
sk.innerHTML =
'<div class="perm-label"><i data-lucide="radar" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Каталог бесплатных моделей Kilo</div>' +
'<div class="perm-desc">Сканирует шлюз, находит бесплатные модели и тестирует каждую тест-запросом на русском. Этот список показывается в выпадашке моделей у Kilo-провайдеров. ' +
(cfg.kiloModelsCustom ? '<b>Сейчас: обновлён сканированием.</b>' : 'Сейчас: встроенный список.') + '</div>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button id="asst-scan" class="asst-ib primary" style="padding:8px 14px;display:inline-flex;align-items:center;gap:6px">' + SPARK + 'Сканировать модели</button>' +
(cfg.kiloModelsCustom ? '<button id="asst-scan-reset" class="asst-ib">Вернуть встроенный список</button>' : '') +
'<span id="asst-scan-st" style="font-size:.78rem;color:#8a94a6"></span></div>' +
'<div id="asst-scan-res"></div>';
host.appendChild(sk);
// ── Настройки/статистика ── // ── Настройки/статистика ──
var u = cfg.usage || {}, u30 = cfg.usage30 || {}, f = cfg.feedback || {}; var u = cfg.usage || {}, u30 = cfg.usage30 || {}, f = cfg.feedback || {};
var sc = document.createElement('div'); var sc = document.createElement('div');
@@ -117,11 +132,28 @@
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-rag" ' + (cfg.rag !== false ? 'checked' : '') + '> Искать ответы по учебникам (RAG)</label>' + '<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-rag" ' + (cfg.rag !== false ? 'checked' : '') + '> Искать ответы по учебникам (RAG)</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-exambtn" ' + (cfg.examButtons ? 'checked' : '') + '> Кнопки помощника на карточках экзамена</label>' + '<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-exambtn" ' + (cfg.examButtons ? 'checked' : '') + '> Кнопки помощника на карточках экзамена</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-memory" ' + (cfg.memory !== false ? 'checked' : '') + '> Персональная память об ученике (слабые темы, заметки)</label>' + '<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-memory" ' + (cfg.memory !== false ? 'checked' : '') + '> Персональная память об ученике (слабые темы, заметки)</label>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span></div>' + '<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-socratic" ' + (cfg.socratic ? 'checked' : '') + '> Сократический режим: не решать задачи за ученика (теорию объясняет, задачи — наводит)</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-health" ' + (cfg.healthEnabled !== false ? 'checked' : '') + '> Авто-проверка провайдеров (каждые 15 мин): упавший активный автоматически уступает место здоровому</label>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span><button id="asst-healthrun" class="asst-ib">Проверить провайдеров сейчас</button></div>' +
'<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.</div>' + '<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.</div>' +
'<div style="font-size:.78rem;color:#8a94a6">Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '</div>'; '<div style="font-size:.78rem;color:#8a94a6">Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '</div>';
host.appendChild(sc); host.appendChild(sc);
// ── Знания о системе (индексация модулей/флагов + описание) ──
var skb = document.createElement('div');
skb.className = 'perm-card'; skb.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-top:14px';
var _skAt = cfg.systemKbAt ? (function () { try { return new Date(cfg.systemKbAt).toLocaleString('ru'); } catch (e) { return ''; } })() : '';
var _skInfo = cfg.systemKbCount ? (cfg.systemKbCount + ' фрагментов' + (_skAt ? ' · ' + _skAt : '')) : 'ещё не индексировалось';
skb.innerHTML =
'<div class="perm-label"><i data-lucide="boxes" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Знания о системе для Квантика</div>' +
'<div class="perm-desc">Снимок включённых модулей + каталог разделов + ваше описание индексируются, чтобы Квантик знал актуальное состояние платформы и не предлагал отключённое. Запускайте после смены фича-флагов.</div>' +
'<textarea id="asst-sysdoc" rows="5" placeholder="Опишите модули/правила платформы своими словами (необязательно) — это тоже попадёт в знания Квантика…" style="' + IN + ';resize:vertical">' + esc(cfg.systemDoc || '') + '</textarea>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-index-sys" class="asst-ib primary">Сохранить и проиндексировать систему</button><span id="asst-sysidx-st" style="font-size:.78rem;color:#8a94a6">' + esc(_skInfo) + '</span></div>' +
((cfg.systemUndoc && cfg.systemUndoc.length)
? '<div style="font-size:.76rem;color:#b45309;background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.3);border-radius:8px;padding:6px 10px">Без описания (только статус вкл/выкл): <b>' + cfg.systemUndoc.map(esc).join(', ') + '</b>. Опишите их в поле выше, чтобы Квантик отвечал по ним подробно.</div>'
: '');
host.appendChild(skb);
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
var Q = function (s) { return host.querySelector(s); }; var Q = function (s) { return host.querySelector(s); };
@@ -143,11 +175,13 @@
var lim = L var lim = L
? '<div class="asst-pclim" data-lim="' + p.id + '">' + fmtLimits(L) + '</div>' ? '<div class="asst-pclim" data-lim="' + p.id + '">' + fmtLimits(L) + '</div>'
: '<div class="asst-pclim" data-lim="' + p.id + '" style="opacity:.6">лимиты: загрузка…</div>'; : '<div class="asst-pclim" data-lim="' + p.id + '" style="opacity:.6">лимиты: загрузка…</div>';
var h = health[p.id];
var hdot = h ? '<span title="' + esc((h.ok ? 'отвечает' : (h.error || 'не отвечает')) + (h.at ? ' · ' + (function () { try { return new Date(h.at).toLocaleString('ru'); } catch (e) { return ''; } })() : '')) + '" style="display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;background:' + (h.ok ? '#059652' : '#e0335e') + ';margin-left:2px;align-self:center"></span>' : '';
return '<div class="asst-pcard' + (act ? ' active' : '') + '">' + return '<div class="asst-pcard' + (act ? ' active' : '') + '">' +
'<div class="asst-pcic">' + SPARK + '</div>' + '<div class="asst-pcic">' + SPARK + '</div>' +
'<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') + '<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') + hdot +
(act ? '<span class="asst-bdg act">активен</span>' : '') + (act ? '<span class="asst-bdg act">активен</span>' : '') +
'<span class="asst-bdg ' + (p.hasKey ? 'key' : 'nokey') + '">' + (p.hasKey ? 'ключ есть' : 'нет ключа') + '</span></div>' + (p.hasKey ? '<span class="asst-bdg key">ключ есть</span>' : p.noKey ? '<span class="asst-bdg key">без ключа</span>' : '<span class="asst-bdg nokey">нет ключа</span>') + '</div>' +
'<div class="asst-pcs">' + esc(p.model || '') + '</div>' + ksel + lim + '</div>' + '<div class="asst-pcs">' + esc(p.model || '') + '</div>' + ksel + lim + '</div>' +
'<div class="asst-pca">' + '<div class="asst-pca">' +
(act ? '' : '<button class="asst-ib primary" data-act="activate" data-id="' + p.id + '">Сделать активным</button>') + (act ? '' : '<button class="asst-ib primary" data-act="activate" data-id="' + p.id + '">Сделать активным</button>') +
@@ -249,11 +283,99 @@
Q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: Q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); }); Q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: Q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: Q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); }); Q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: Q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); });
Q('#asst-memory').addEventListener('change', function () { LS.adminSaveAssistant({ memory: Q('#asst-memory').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); }); Q('#asst-memory').addEventListener('change', function () { LS.adminSaveAssistant({ memory: Q('#asst-memory').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-socratic').addEventListener('change', function () { LS.adminSaveAssistant({ socratic: Q('#asst-socratic').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-health').addEventListener('change', function () { LS.adminSaveAssistant({ healthEnabled: Q('#asst-health').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-healthrun').addEventListener('click', async function () {
var btn = Q('#asst-healthrun'); btn.disabled = true; btn.textContent = 'Проверяю…';
try { await LS.adminAssistantHealth(); LS.toast('Проверка завершена', 'success'); render(); }
catch (e) { LS.toast('Ошибка проверки', 'error'); btn.disabled = false; btn.textContent = 'Проверить провайдеров сейчас'; }
});
Q('#asst-reindex').addEventListener('click', async function () { Q('#asst-reindex').addEventListener('click', async function () {
var btn = Q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…'; var btn = Q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…';
try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); } try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); }
catch (e) { LS.toast('Ошибка индексации', 'error'); } finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; } catch (e) { LS.toast('Ошибка индексации', 'error'); } finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
}); });
Q('#asst-index-sys').addEventListener('click', async function () {
var btn = Q('#asst-index-sys'), st = Q('#asst-sysidx-st'), old = btn.textContent; btn.disabled = true; btn.textContent = 'Индексирую…';
try {
await LS.adminSaveAssistant({ systemDoc: Q('#asst-sysdoc').value });
var r = await LS.adminAssistantIndexSystem();
st.textContent = ((r && r.count) || 0) + ' фрагментов · только что';
LS.toast('Система проиндексирована', 'success');
} catch (e) { LS.toast('Ошибка индексации', 'error'); }
finally { btn.disabled = false; btn.textContent = old; }
});
// ── Сканер моделей ──
var scanProvId = null;
function applyScan() {
var trs = Q('#asst-scan-res').querySelectorAll('tbody tr[data-mid]');
var models = [];
trs.forEach(function (tr) {
var cb = tr.querySelector('.asst-scan-cb'); if (!cb || !cb.checked) return;
var id = tr.getAttribute('data-mid');
var ctx = Number(tr.getAttribute('data-ctx')) || null, out = Number(tr.getAttribute('data-out')) || null;
var ex = (cfg.kiloModels || []).find(function (x) { return x.id === id; });
var label = ex ? ex.label : (id.split('/').pop().replace(/:free$/, '') + (ctx ? ' (' + fmtTok(ctx) + ')' : ''));
models.push({ id: id, label: label, ctx: ctx, out: out });
});
if (!models.length) { LS.toast('Отметьте хотя бы одну модель', 'warn'); return; }
LS.adminAssistantApplyModels(models).then(function () { LS.toast('Список моделей обновлён (' + models.length + ')', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); });
}
async function runScan() {
var st = Q('#asst-scan-st'), res = Q('#asst-scan-res'), btn = Q('#asst-scan');
btn.disabled = true; st.textContent = 'Сканирую шлюз…'; res.innerHTML = '';
var r; try { r = await LS.adminAssistantScan(); } catch (e) { st.textContent = 'Ошибка запроса'; btn.disabled = false; return; }
if (!r || r.error) { st.textContent = 'Ошибка: ' + esc((r && r.error) || '—'); btn.disabled = false; return; }
scanProvId = r.providerId;
st.textContent = 'Найдено бесплатных: ' + r.models.length + ' (провайдер «' + esc(r.providerName) + '»). Тестирую русский…';
var rows = r.models.map(function (m) {
return '<tr data-mid="' + esc(m.id) + '" data-ctx="' + (m.ctx || '') + '" data-out="' + (m.out || '') + '">' +
'<td style="padding:5px 6px"><input type="checkbox" class="asst-scan-cb"' + (m.status === 'current' ? ' checked' : '') + '></td>' +
'<td style="padding:5px 6px;font-family:ui-monospace,monospace;font-size:.74rem">' + esc(m.id) + '</td>' +
'<td style="padding:5px 6px;white-space:nowrap;color:#8a94a6">' + fmtTok(m.ctx) + '/' + fmtTok(m.out) + '</td>' +
'<td style="padding:5px 6px"><span class="asst-bdg ' + (m.status === 'current' ? 'key' : 'act') + '">' + (m.status === 'current' ? 'в списке' : 'новая') + '</span></td>' +
'<td class="asst-ru" style="padding:5px 6px;font-size:.75rem;color:#8a94a6">…</td></tr>';
}).join('');
var goneRows = (r.gone || []).map(function (g) {
return '<tr style="opacity:.55"><td style="padding:5px 6px">—</td>' +
'<td style="padding:5px 6px;font-family:ui-monospace,monospace;font-size:.74rem;text-decoration:line-through">' + esc(g.id) + '</td>' +
'<td style="padding:5px 6px">—</td><td style="padding:5px 6px"><span class="asst-bdg nokey">исчезла</span></td>' +
'<td style="padding:5px 6px;font-size:.75rem;color:#e0335e">будет убрана</td></tr>';
}).join('');
res.innerHTML = '<div style="overflow-x:auto;margin-top:4px"><table style="width:100%;border-collapse:collapse">' +
'<thead><tr style="text-align:left;color:#8a94a6;font-size:.72rem;border-bottom:1px solid var(--border,#e2e8f0)">' +
'<th style="padding:4px 6px"></th><th style="padding:4px 6px">модель</th><th style="padding:4px 6px">ctx/out</th><th style="padding:4px 6px">статус</th><th style="padding:4px 6px">русский</th></tr></thead>' +
'<tbody>' + rows + goneRows + '</tbody></table></div>' +
'<div style="margin-top:10px"><button id="asst-scan-apply" class="asst-ib primary" style="padding:8px 16px">Применить выбранные</button> ' +
'<span style="font-size:.74rem;color:#8a94a6">отмечены: текущие + новые с чистым русским</span></div>';
Q('#asst-scan-apply').addEventListener('click', applyScan);
// последовательный прогон тест-запросов
var trs = res.querySelectorAll('tbody tr[data-mid]');
for (var i = 0; i < trs.length; i++) {
var tr = trs[i], mid = tr.getAttribute('data-mid'), cell = tr.querySelector('.asst-ru');
cell.textContent = 'тест…';
try {
var pr = await LS.adminAssistantProbe(scanProvId, mid);
if (pr && pr.ok) {
var good = pr.cjk === 0 && pr.ratio > 55;
var col = good ? '#059652' : (pr.ratio > 20 && pr.cjk === 0) ? '#b45309' : '#e0335e';
cell.innerHTML = '<span style="color:' + col + '" title="' + esc(pr.sample || '') + '">' + pr.ratio + '% · ' + esc(pr.verdict) + ' · ' + (pr.ms / 1000).toFixed(1) + 'с</span>';
if (!good) { var cb = tr.querySelector('.asst-scan-cb'); if (cb) cb.checked = false; }
} else {
cell.innerHTML = '<span style="color:#e0335e">' + esc(String((pr && (pr.error || ('HTTP ' + pr.status))) || 'ошибка').slice(0, 60)) + '</span>';
var cb2 = tr.querySelector('.asst-scan-cb'); if (cb2) cb2.checked = false;
}
} catch (e) { cell.textContent = 'ошибка'; }
}
st.textContent = 'Готово. Отметьте нужные модели и нажмите «Применить выбранные».';
btn.disabled = false;
}
Q('#asst-scan').addEventListener('click', runScan);
if (Q('#asst-scan-reset')) Q('#asst-scan-reset').addEventListener('click', async function () {
if (!await LS.confirm('Вернуть встроенный список бесплатных моделей?', { title: 'Сброс списка', confirmText: 'Вернуть' })) return;
try { await LS.adminAssistantApplyModels(null, true); LS.toast('Возвращён встроенный список', 'success'); render(); } catch (e) { LS.toast('Ошибка', 'error'); }
});
} }
window.AdminSections = window.AdminSections || {}; window.AdminSections = window.AdminSections || {};
+6
View File
@@ -5,6 +5,7 @@
let inited = false; let inited = false;
const GAME_FEATURES = [ const GAME_FEATURES = [
{ key: 'gamification', label: 'Геймификация (всё)', desc: 'Мастер-выключатель: XP, уровни, достижения, монеты, стрики, магазин, лидерборд, испытания, рамки. Выкл → всё это скрыто и не начисляется у ВСЕХ', icon: 'trophy' },
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' }, { key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' }, { key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' }, { key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
@@ -13,12 +14,17 @@
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' }, { key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
{ key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' }, { key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' },
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' }, { key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
{ key: 'sitemap', label: 'Путеводитель', desc: 'Пункт «Путеводитель» в меню — обзорная карта разделов системы', icon: 'map' },
{ key: 'lab', label: 'Лаборатория', desc: 'Раздел «Лаборатория»: виртуальные симуляции и интерактивные опыты', icon: 'atom' },
{ key: 'theory', label: 'Теория', desc: 'Раздел «Теория»: учебные курсы и уроки для учеников', icon: 'brain' },
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'}, { key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'},
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' }, { key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' }, { key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' }, { key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' }, { key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
{ key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' }, { key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' },
{ key: 'trainer', label: 'Тренажёр', desc: 'ИИ-тренажёр: бесконечные сгенерированные задачи по темам (уравнения 7 класс), мгновенная проверка ответа подстановкой, прогресс по навыкам', icon: 'dumbbell' },
{ key: 'wishes', label: 'Пожелания', desc: 'Трекер пожеланий по улучшению: пользователи подают идеи, админ ведёт по статусам', icon: 'lightbulb' },
]; ];
const FS_FEATURES = [ const FS_FEATURES = [
+128
View File
@@ -452,6 +452,24 @@
<i data-lucide="file-text"></i> Audit log <i data-lucide="file-text"></i> Audit log
</button> </button>
</div> </div>
<div class="ov-section-title" style="margin-top:32px;color:var(--pink)">Опасная зона</div>
<div class="ov-card danger" style="padding-bottom:16px">
<div class="ov-card-icon"><i data-lucide="alert-octagon" style="width:18px;height:18px"></i></div>
<div class="ov-card-label" style="margin-bottom:10px;font-weight:700;color:#0F172A">
Сброс системы «чистый запуск»
</div>
<div style="font-size:.82rem;color:#56687A;line-height:1.5;margin-bottom:14px;max-width:560px">
Удаляет всех пользователей (кроме вас), классы, сессии, задания, прогресс, уведомления и
историю. Учебники, вопросы, тесты, курсы и настройки сохраняются авторский контент
переназначается на ваш аккаунт. Перед сбросом автоматически создаётся резервная копия БД.
Действие необратимо.
</div>
<button class="ov-quick-btn" id="ov-reset-system-btn"
style="border-color:rgba(241,91,181,0.5);color:var(--pink);max-width:280px">
<i data-lucide="trash-2"></i> Сбросить систему
</button>
</div>
`; `;
/* ── wire quick-links via event delegation ───────────────── */ /* ── wire quick-links via event delegation ───────────────── */
@@ -459,9 +477,119 @@
btn.addEventListener('click', function () { navigateTo(btn.dataset.go); }); btn.addEventListener('click', function () { navigateTo(btn.dataset.go); });
}); });
const resetBtn = el.querySelector('#ov-reset-system-btn');
if (resetBtn) resetBtn.addEventListener('click', openResetModal);
if (window.lucide) lucide.createIcons({ nodes: [el] }); if (window.lucide) lucide.createIcons({ nodes: [el] });
} }
/* ── Сброс системы «чистый запуск» — модалка с предпросмотром + вводом «СБРОС» ── */
async function openResetModal() {
const e = LS.esc;
const m = LS.modal({
title: 'Сброс системы — чистый запуск',
size: 'md',
content: '<div style="padding:8px 0;color:#56687A">Загрузка плана…</div>',
actions: [{ label: 'Отмена' }],
});
let plan;
try {
plan = await LS.api('/api/admin/reset-system/plan');
} catch (err) {
m.setBody('<div style="color:#F94144">Не удалось загрузить план: ' + e(err.message) + '</div>');
return;
}
const kept = plan.keptAdmin || {};
const delUsers = Math.max(0, (plan.totalUsers || 0) - 1);
const wipeRows = plan.wipeRows || 0;
const reassignRows = (plan.reassign || []).reduce(function (a, r) {
return a + (typeof r.rows === 'number' ? r.rows : 0);
}, 0);
const unknownNote = (plan.unknown && plan.unknown.length)
? '<div style="margin-top:10px;padding:8px 11px;border-radius:8px;background:rgba(255,179,71,.12);' +
'border:1px solid rgba(255,179,71,.35);font-size:.8rem;color:#9a6a10">' +
'Неизвестные таблицы (не трогаются): ' + e(plan.unknown.join(', ')) + '</div>'
: '';
m.setBody(
'<div style="font-size:.88rem;line-height:1.6;color:#0F172A">' +
'<div style="padding:10px 13px;border-radius:10px;background:rgba(241,91,68,.08);' +
'border:1px solid rgba(241,91,68,.3);margin-bottom:14px">' +
'<strong>Это действие необратимо.</strong> Перед сбросом будет создан бэкап БД.' +
'</div>' +
'<div style="margin-bottom:6px">Останется один администратор:</div>' +
'<div style="padding:8px 12px;border-radius:8px;background:rgba(15,23,42,.04);margin-bottom:14px">' +
'<strong>' + e(kept.name || '—') + '</strong> · ' + e(kept.email || '') +
' <span style="color:#56687A">(вы)</span></div>' +
'<ul style="margin:0 0 14px;padding-left:18px;color:#334155">' +
'<li>Удалится пользователей: <strong>' + delUsers + '</strong></li>' +
'<li>Очистится записей активности/организации: <strong>~' + wipeRows + '</strong></li>' +
'<li>Контента переназначится на вас: <strong>' + reassignRows + '</strong> записей</li>' +
'<li>Сохранится контент-таблиц: <strong>' + (plan.keepCount || 0) + '</strong></li>' +
'</ul>' +
unknownNote +
'<div style="margin:16px 0 6px">Для подтверждения введите <strong>СБРОС</strong>:</div>' +
'<input id="ov-reset-confirm-inp" type="text" autocomplete="off" ' +
'style="width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.18);border-radius:10px;' +
'font-size:.95rem;font-family:inherit" placeholder="СБРОС">' +
'</div>'
);
const inp = m.body.querySelector('#ov-reset-confirm-inp');
function syncBtn() {
const ok = inp && inp.value.trim() === 'СБРОС';
const btn = document.getElementById('ov-reset-go');
if (btn) btn.disabled = !ok;
}
function setReadyActions() {
m.setActions([
{ label: 'Отмена' },
{
label: 'Сбросить систему', danger: true, id: 'ov-reset-go', close: false,
onClick: doReset,
},
]);
const btn = document.getElementById('ov-reset-go');
if (btn) btn.disabled = true;
}
async function doReset() {
const btn = document.getElementById('ov-reset-go');
if (!inp || inp.value.trim() !== 'СБРОС') return;
if (btn) { btn.disabled = true; btn.textContent = 'Выполняется…'; }
m.setError('');
let res;
try {
res = await LS.api('/api/admin/reset-system', { method: 'POST', body: { confirm: 'СБРОС' } });
} catch (err) {
m.setError('Ошибка: ' + (err.message || 'сброс не выполнен'));
if (btn) { btn.disabled = false; btn.textContent = 'Сбросить систему'; }
return;
}
m.setBody(
'<div style="text-align:center;padding:14px 0">' +
'<div style="font-size:2rem;margin-bottom:6px;color:var(--green)">' +
'<i data-lucide="check-circle-2" style="width:40px;height:40px"></i></div>' +
'<div style="font-size:1rem;font-weight:800;color:#0F172A;margin-bottom:10px">Система сброшена</div>' +
'<div style="font-size:.86rem;color:#56687A;line-height:1.6">' +
'Удалено пользователей: <strong>' + (res.deletedUsers || 0) + '</strong>, осталось: <strong>' +
(res.remainingUsers || 1) + '</strong>.<br>' +
'Бэкап сохранён: <code style="font-size:.8rem">' + LS.esc(res.backup || '—') + '</code>' +
(res.fkDangling ? '<br><span style="color:#F94144">Висячих ссылок: ' + res.fkDangling + '</span>' : '') +
'</div>' +
'</div>'
);
m.setActions([{ label: 'Перезагрузить', primary: true, close: false, onClick: function () { location.reload(); } }]);
if (window.lucide) lucide.createIcons({ nodes: [m.body] });
}
setReadyActions();
if (inp) { inp.addEventListener('input', syncBtn); setTimeout(function () { inp.focus(); }, 60); }
}
async function load() { async function load() {
const el = document.getElementById('overview-content'); const el = document.getElementById('overview-content');
if (!el) return; if (!el) return;
+2 -1
View File
@@ -4,7 +4,8 @@
'use strict'; 'use strict';
let inited = false; let inited = false;
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест', topic: 'По теме', random: 'Случайный' }; // Старт сессии поддерживает только exam/practice (topic/random убраны — давали 400 на дашборде).
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест' };
const SC_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' }; const SC_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' }; const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
+112 -20
View File
@@ -40,10 +40,12 @@
<span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span> <span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span>
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span> <span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span> <span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
${t.available_to_students ? `<span class="q-badge" style="background:rgba(6,214,160,.14);color:#059669">Доступен ученикам</span>` : ''}
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''} ${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
</div> </div>
</div> </div>
<div class="q-card-actions"> <div class="q-card-actions">
<button class="btn-edit-q" onclick="toggleTstAvail(${t.id})" title="Показывать ли тест ученикам в каталоге">${t.available_to_students ? 'Скрыть' : 'Ученикам'}</button>
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button> <button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button> <button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div> </div>
@@ -77,16 +79,15 @@
if (!inner) return; if (!inner) return;
inner.innerHTML = '<div class="spinner"></div>'; inner.innerHTML = '<div class="spinner"></div>';
try { try {
const [t, subjectQs] = await Promise.all([ const t = await LS.getTest(id);
LS.getTest(id),
LS.getQuestions(
(_tstPickerCache[id]?.subject_slug) || allTests.find(x => x.id === id)?.subject_slug || '',
null, 'date_asc'
).catch(() => []),
]);
const inIds = new Set(t.questions.map(q => q.id)); const inIds = new Set(t.questions.map(q => q.id));
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug }; // Сохраняем поиск/фильтры между перерисовками (напр. после добавления вопроса).
const prev = _tstPickerCache[id] || {};
_tstPickerCache[id] = {
subject_slug: t.subject_slug, inIds,
q: prev.q || '', difficulty: prev.difficulty || '', type: prev.type || '',
rows: [], total: 0, page: 1, loading: false,
};
inner.innerHTML = ` inner.innerHTML = `
<div class="tst-cols"> <div class="tst-cols">
@@ -96,17 +97,89 @@
</div> </div>
<div> <div>
<div class="tst-panel-title">Добавить вопросы</div> <div class="tst-panel-title">Добавить вопросы</div>
<input class="tst-search" id="tstps-${id}" placeholder="Поиск вопросов…" oninput="filterTstPicker(${id})" /> <input class="tst-search" id="tstps-${id}" placeholder="Поиск по всему банку предмета…" oninput="filterTstPicker(${id})" />
<div class="tst-q-list" id="tstpicker-${id}">${renderTstPicker(subjectQs, inIds, id)}</div> <div class="tst-pick-filters">
<select class="tst-pick-sel" id="tstfd-${id}" onchange="pickerFilterChange(${id})">
<option value="">Любая сложность</option>
<option value="1">Лёгкий</option>
<option value="2">Средний</option>
<option value="3">Сложный</option>
</select>
<select class="tst-pick-sel" id="tstft-${id}" onchange="pickerFilterChange(${id})">
<option value="">Любой тип</option>
<option value="single">Один</option>
<option value="multi">Несколько</option>
<option value="true_false">Верно/Нет</option>
<option value="short_answer">Краткий</option>
<option value="matching">Сопоставление</option>
</select>
</div>
<div class="tst-q-list" id="tstpicker-${id}"><div class="spinner"></div></div>
<div class="tst-pick-foot" id="tstfoot-${id}"></div>
</div> </div>
</div>`; </div>`;
// restore search/filters into controls
const si = document.getElementById('tstps-' + id); if (si) si.value = _tstPickerCache[id].q;
const fd = document.getElementById('tstfd-' + id); if (fd) fd.value = _tstPickerCache[id].difficulty;
const ft = document.getElementById('tstft-' + id); if (ft) ft.value = _tstPickerCache[id].type;
AdminCtx.renderMath(inner); AdminCtx.renderMath(inner);
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
await pickerLoad(id, true);
} catch (e) { } catch (e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`; inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
} }
} }
/* Серверная подгрузка вопросов в пикер (весь банк предмета, не первые 100).
reset=true новый поиск/фильтр (страница 1, заменяем); иначе «показать ещё». */
async function pickerLoad(id, reset) {
const cache = _tstPickerCache[id];
if (!cache || cache.loading) return;
cache.loading = true;
if (reset) { cache.page = 1; cache.rows = []; }
const listEl = document.getElementById('tstpicker-' + id);
const footEl = document.getElementById('tstfoot-' + id);
if (reset && listEl) listEl.innerHTML = '<div class="spinner"></div>';
try {
const p = new URLSearchParams();
p.set('subject', cache.subject_slug || '');
p.set('sort', 'date_desc');
p.set('page', cache.page);
p.set('limit', 100);
if (cache.q) p.set('q', cache.q);
if (cache.difficulty) p.set('difficulty', cache.difficulty);
if (cache.type) p.set('type', cache.type);
const data = await LS.get('/api/questions?' + p.toString());
const rows = Array.isArray(data) ? data : (data.rows || []);
cache.total = Array.isArray(data) ? rows.length : (data.total != null ? data.total : rows.length);
cache.rows = reset ? rows : cache.rows.concat(rows);
cache.page += 1;
if (listEl) { listEl.innerHTML = renderTstPicker(cache.rows, cache.inIds, id); AdminCtx.renderMath(listEl); }
if (footEl) footEl.innerHTML = pickerFootHtml(id);
if (window.lucide) lucide.createIcons();
} catch (e) {
if (listEl) listEl.innerHTML = `<div class="tst-empty">Ошибка: ${esc(e.message)}</div>`;
} finally { cache.loading = false; }
}
function pickerFootHtml(id) {
const c = _tstPickerCache[id];
if (!c || !c.total) return '';
const more = c.rows.length < c.total
? `<button class="btn-tst-more" onclick="pickerMore(${id})">Показать ещё</button>` : '';
return `<span class="tst-pick-count">Показано ${c.rows.length} из ${c.total}</span>${more}`;
}
function pickerMore(id) { pickerLoad(id, false); }
function pickerFilterChange(id) {
const c = _tstPickerCache[id];
if (!c) return;
c.difficulty = document.getElementById('tstfd-' + id)?.value || '';
c.type = document.getElementById('tstft-' + id)?.value || '';
pickerLoad(id, true);
}
function renderTstQList(questions, tid) { function renderTstQList(questions, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx; const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет. Добавьте справа <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></div>'; if (!questions.length) return '<div class="tst-empty">Вопросов нет. Добавьте справа <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></div>';
@@ -127,7 +200,11 @@
function renderTstPicker(questions, inIds, tid) { function renderTstPicker(questions, inIds, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx; const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>'; if (!questions.length) {
const c = _tstPickerCache[tid] || {};
const searching = c.q || c.difficulty || c.type;
return `<div class="tst-empty">${searching ? 'Ничего не найдено — измените запрос или фильтры' : 'Вопросов нет в этом предмете'}</div>`;
}
return questions.map(q => { return questions.map(q => {
const added = inIds.has(q.id); const added = inIds.has(q.id);
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}"> return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
@@ -145,15 +222,13 @@
}).join(''); }).join('');
} }
async function filterTstPicker(tid) { const _pickDebounce = {};
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || ''; function filterTstPicker(tid) {
const cache = _tstPickerCache[tid]; const cache = _tstPickerCache[tid];
if (!cache) return; if (!cache) return;
const filtered = search cache.q = (document.getElementById('tstps-' + tid)?.value || '').trim();
? cache.subjectQs.filter(q => q.text.toLowerCase().includes(search)) clearTimeout(_pickDebounce[tid]);
: cache.subjectQs; _pickDebounce[tid] = setTimeout(() => pickerLoad(tid, true), 300); // серверный поиск по всему банку
const picker = document.getElementById('tstpicker-'+tid);
if (picker) { picker.innerHTML = renderTstPicker(filtered, cache.inIds, tid); AdminCtx.renderMath(picker); if(window.lucide)lucide.createIcons(); }
} }
async function tstAddQ(tid, qid) { async function tstAddQ(tid, qid) {
@@ -261,11 +336,27 @@
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
} }
// Открыть/скрыть тест для учеников (попадает в каталог на дашборде)
async function toggleTstAvail(id) {
const t = allTests.find(x => x.id === id);
if (!t) return;
if (!t.question_count) { LS.toast('Сначала добавьте вопросы в тест', 'error'); return; }
const next = t.available_to_students ? 0 : 1;
try {
await LS.updateTest(id, { available_to_students: next });
t.available_to_students = next;
renderTests();
LS.toast(next ? 'Тест открыт ученикам' : 'Тест скрыт от учеников', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers // Expose handlers
window.loadTests = load; window.loadTests = load;
window.renderTests = renderTests; window.renderTests = renderTests;
window.toggleTstDrawer = toggleTstDrawer; window.toggleTstDrawer = toggleTstDrawer;
window.filterTstPicker = filterTstPicker; window.filterTstPicker = filterTstPicker;
window.pickerMore = pickerMore;
window.pickerFilterChange = pickerFilterChange;
window.tstAddQ = tstAddQ; window.tstAddQ = tstAddQ;
window.tstRemoveQ = tstRemoveQ; window.tstRemoveQ = tstRemoveQ;
window.setTstShowAnswers = setTstShowAnswers; window.setTstShowAnswers = setTstShowAnswers;
@@ -274,6 +365,7 @@
window.closeTstModal = closeTstModal; window.closeTstModal = closeTstModal;
window.saveTst = saveTst; window.saveTst = saveTst;
window.deleteTst = deleteTst; window.deleteTst = deleteTst;
window.toggleTstAvail = toggleTstAvail;
window.AdminSections = window.AdminSections || {}; window.AdminSections = window.AdminSections || {};
window.AdminSections.tests = { window.AdminSections.tests = {
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/* assignment-utils.js единый источник правды для классификации заданий и статуса «сдано».
*
* Раньше эта логика дублировалась в трёх местах (dashboard.html, homework.html,
* assignmentController.js) и начала расходиться. Теперь один модуль, который грузится
* и в браузере (window.AssignmentUtils), и в Node (module.exports) как svg-sanitize.js.
*
* Поля задания (как их отдаёт /assignments/my и assignmentRowsForUser):
* textbook_id, file_id, is_homework, count, subject_slug, mode, session_status,
* max_attempts, attempts_used, deadline, textbook_all_read, completed_at.
*/
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof window !== 'undefined') window.AssignmentUtils = api;
})(this, function () {
/* Тип задания: textbook | file | upload | test.
Порядок проверки: учебник файл загрузка работы тест. */
function type(a) {
if (a.textbook_id) return 'textbook';
if (a.file_id) return 'file';
if (a.is_homework && (a.count == null || a.count <= 1)
&& (!a.subject_slug || a.subject_slug === 'other')) return 'upload';
return 'test';
}
/* «Закрыто» (сдано/выполнено/прочитано) задание уходит из активных/долгов.
sub последняя сдача (объект с .status) для upload/file, иначе null/undefined.
opts.acceptedOnly=true (вид ученика на /homework): upload/file закрыт ТОЛЬКО при
status==='accepted' (пока не приняли у ученика «висит»).
по умолчанию (учитель / обзор долгов): любая сдача не на доработке = закрыто
(ученик свою часть сделал это уже не его долг). */
function isDone(a, sub, opts) {
opts = opts || {};
const t = type(a);
if (t === 'textbook') return !!(a.textbook_all_read || a.completed_at);
if (t === 'upload' || t === 'file') {
const st = sub && sub.status;
if (!st || st === 'revision') return false;
return opts.acceptedOnly ? st === 'accepted' : true;
}
// test
const maxAtt = a.max_attempts || 0;
const used = (a.attempts_used != null) ? a.attempts_used : 0;
if (maxAtt > 0 && used >= maxAtt) return true;
return a.session_status === 'completed' && a.mode !== 'repeat';
}
/* Срочность для сортировки (меньше — выше): идёт → просрочено → <24ч → по дедлайну → без срока. */
function urgencyScore(a) {
if (a.session_status === 'in_progress') return -4;
const dlMs = a.deadline ? (new Date(a.deadline).getTime() - Date.now()) : Infinity;
if (dlMs < 0) return -3;
if (dlMs < 24 * 3600 * 1000) return -2;
if (dlMs < Infinity) return dlMs;
return 1e12;
}
return { type, isDone, urgencyScore };
});
+274 -42
View File
@@ -282,17 +282,33 @@
'.asst-dot{position:absolute;top:0;right:0;width:13px;height:13px;border-radius:50%;background:#F15BB5;border:2px solid #fff;}', '.asst-dot{position:absolute;top:0;right:0;width:13px;height:13px;border-radius:50%;background:#F15BB5;border:2px solid #fff;}',
reduceMotion ? '' : '.asst-fab.pulse{animation:asstPulse 2.2s ease-in-out infinite;}', reduceMotion ? '' : '.asst-fab.pulse{animation:asstPulse 2.2s ease-in-out infinite;}',
'@keyframes asstPulse{0%,100%{box-shadow:0 8px 24px rgba(139,92,246,.32);}50%{box-shadow:0 8px 30px rgba(241,91,181,.5);}}', '@keyframes asstPulse{0%,100%{box-shadow:0 8px 24px rgba(139,92,246,.32);}50%{box-shadow:0 8px 30px rgba(241,91,181,.5);}}',
'.asst-bubble{position:absolute;left:0;bottom:66px;width:380px;max-width:92vw;background:#fff;border-radius:18px;', '.asst-name-face{display:inline-block;transition:transform .2s;}',
' box-shadow:0 20px 56px rgba(15,23,42,.24);padding:15px 17px;border:1px solid rgba(15,23,42,.07);', reduceMotion ? '' : '.asst-name-face.asst-think{animation:asstThink 1.3s ease-in-out infinite;transform-origin:60% 70%;}',
'@keyframes asstThink{0%,100%{transform:scale(1) rotate(0);}50%{transform:scale(1.08) rotate(-4deg);}}',
'.asst-bubble{position:absolute;left:0;bottom:66px;width:418px;max-width:94vw;background:rgba(255,255,255,.97);backdrop-filter:blur(16px) saturate(1.4);-webkit-backdrop-filter:blur(16px) saturate(1.4);border-radius:20px;overflow:hidden;',
' box-shadow:0 28px 70px -16px rgba(76,29,149,.38),0 6px 20px rgba(15,23,42,.1),0 0 0 1px rgba(155,93,229,.12);padding:14px 16px 16px;border:none;',
' opacity:0;transform:translateY(8px) scale(.98);pointer-events:none;transition:opacity .18s,transform .18s;transform-origin:bottom left;}', ' opacity:0;transform:translateY(8px) scale(.98);pointer-events:none;transition:opacity .18s,transform .18s;transform-origin:bottom left;}',
'.asst-name-face{display:inline-block;width:20px;height:20px;vertical-align:-4px;margin-right:7px;}', '.asst-name-face{position:relative;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;vertical-align:middle;border-radius:50%;background:linear-gradient(135deg,rgba(255,255,255,.9),rgba(255,255,255,.55));margin-right:10px;box-shadow:inset 0 0 0 1px rgba(155,93,229,.28),0 2px 6px rgba(155,93,229,.18);}',
'.asst-name-face svg{width:100%;height:100%;display:block;}', '.asst-name-face svg{width:25px;height:25px;display:block;}',
'.asst-memnote{font-size:.66rem;color:#9aa5b4;margin-top:9px;line-height:1.45;border-top:1px solid rgba(15,23,42,.05);padding-top:8px;}', '.asst-name-face::after{content:"";position:absolute;right:-1px;bottom:0;width:9px;height:9px;border-radius:50%;background:#22c55e;border:2px solid #fff;}',
reduceMotion ? '' : '.asst-name-face::after{animation:asstOnline 2.2s ease-out infinite;}',
'@keyframes asstOnline{0%{box-shadow:0 0 0 0 rgba(34,197,94,.5);}70%,100%{box-shadow:0 0 0 6px rgba(34,197,94,0);}}',
// анимированный индикатор печати
'.asst-typing{display:inline-flex;gap:4px;align-items:center;padding:3px 0;}',
'.asst-typing span{width:7px;height:7px;border-radius:50%;background:#9B5DE5;opacity:.45;}',
reduceMotion ? '' : '.asst-typing span{animation:asstDot 1.1s infinite ease-in-out;}.asst-typing span:nth-child(2){animation-delay:.16s;}.asst-typing span:nth-child(3){animation-delay:.32s;}',
'@keyframes asstDot{0%,80%,100%{transform:translateY(0);opacity:.4;}40%{transform:translateY(-5px);opacity:1;}}',
// плавное появление сообщений
reduceMotion ? '' : '.asst-msg{animation:asstMsgIn .26s cubic-bezier(.4,0,.2,1);}',
'@keyframes asstMsgIn{from{opacity:0;transform:translateY(7px);}to{opacity:1;transform:translateY(0);}}',
'.asst-memnote{display:flex;gap:7px;align-items:flex-start;font-size:.66rem;color:#9aa5b4;margin-top:9px;line-height:1.45;border-top:1px solid rgba(15,23,42,.05);padding-top:9px;}',
'.asst-memnote .ic{width:13px;height:13px;flex:none;margin-top:1px;color:#b6abdd;}',
'.asst-memnote b{color:#7e3eca;font-weight:700;}',
'.asst-bubble.open{opacity:1;transform:translateY(0);pointer-events:auto;}', '.asst-bubble.open{opacity:1;transform:translateY(0);pointer-events:auto;}',
'.asst-x{position:absolute;top:8px;right:8px;width:26px;height:26px;border:none;background:transparent;color:#8a94a6;', '.asst-x{position:absolute;top:12px;right:12px;width:28px;height:28px;border:none;background:transparent;color:#94a3b8;z-index:3;',
' cursor:pointer;border-radius:7px;font-size:18px;line-height:1;}', ' cursor:pointer;border-radius:8px;font-size:19px;line-height:1;transition:all .14s;}',
'.asst-x:hover{background:rgba(15,23,42,.06);color:#0F172A;}', '.asst-x:hover{background:rgba(15,23,42,.06);color:#0F172A;}',
'.asst-name{font-size:.7rem;font-weight:800;color:#9B5DE5;text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px;}', '.asst-name{font-size:.98rem;font-weight:800;color:#0F172A;text-transform:none;letter-spacing:0;margin:-14px -16px 12px;padding:13px 48px 12px 16px;background:linear-gradient(120deg,rgba(155,93,229,.13),rgba(6,182,212,.10));border-bottom:1px solid rgba(155,93,229,.14);border-radius:20px 20px 0 0;line-height:34px;}',
'.asst-text{font-size:.86rem;line-height:1.5;color:#28324a;margin-bottom:12px;white-space:pre-line;}', '.asst-text{font-size:.86rem;line-height:1.5;color:#28324a;margin-bottom:12px;white-space:pre-line;}',
'.asst-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}', '.asst-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}',
'.asst-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:99px;border:none;cursor:pointer;', '.asst-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:99px;border:none;cursor:pointer;',
@@ -300,7 +316,13 @@
'.asst-btn:hover{background:#7e3eca;}', '.asst-btn:hover{background:#7e3eca;}',
'.asst-link{background:none;border:none;color:#8a94a6;cursor:pointer;font:600 .76rem Manrope,sans-serif;padding:4px 2px;text-decoration:none;}', '.asst-link{background:none;border:none;color:#8a94a6;cursor:pointer;font:600 .76rem Manrope,sans-serif;padding:4px 2px;text-decoration:none;}',
'.asst-link:hover{color:#9B5DE5;}', '.asst-link:hover{color:#9B5DE5;}',
'.asst-ask-in{width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid #e2e8f0;border-radius:10px;font:inherit;font-size:.84rem;margin-bottom:10px;}', '.asst-ask-row{display:flex;gap:8px;align-items:center;margin-bottom:4px;}',
'.asst-ask-in{flex:1;min-width:0;box-sizing:border-box;padding:11px 14px;border:1.5px solid #e6e3f2;border-radius:13px;font:inherit;font-size:.85rem;background:#faf9fd;transition:border-color .15s,box-shadow .15s,background .15s;}',
'.asst-ask-in:focus{outline:none;border-color:#9B5DE5;background:#fff;box-shadow:0 0 0 3px rgba(155,93,229,.13);}',
'.asst-send{flex-shrink:0;width:42px;height:42px;border:none;border-radius:13px;background:linear-gradient(135deg,#9B5DE5,#7d3fc8);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform .12s,box-shadow .15s;box-shadow:0 4px 14px rgba(155,93,229,.32);}',
'.asst-send:hover{transform:translateY(-1px);box-shadow:0 7px 18px rgba(155,93,229,.42);}',
'.asst-send:active{transform:translateY(0);}',
'.asst-send svg{width:19px;height:19px;}',
'.asst-ans{font-size:.82rem;line-height:1.5;color:#28324a;border-top:1px solid rgba(15,23,42,.06);padding:9px 0;}', '.asst-ans{font-size:.82rem;line-height:1.5;color:#28324a;border-top:1px solid rgba(15,23,42,.06);padding:9px 0;}',
'.asst-ans:first-of-type{border-top:none;}', '.asst-ans:first-of-type{border-top:none;}',
'.asst-ans-q{font-weight:700;color:#0F172A;margin-bottom:2px;}', '.asst-ans-q{font-weight:700;color:#0F172A;margin-bottom:2px;}',
@@ -308,7 +330,7 @@
'.asst-ans-sec{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:12px 0 2px;}', '.asst-ans-sec{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:12px 0 2px;}',
'.asst-ans-box{max-height:46vh;overflow:auto;}', '.asst-ans-box{max-height:46vh;overflow:auto;}',
'.asst-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px;}', '.asst-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px;}',
'.asst-chip{border:1px solid #e2e8f0;background:#f8fafc;border-radius:99px;padding:5px 10px;font:600 .72rem Manrope,sans-serif;color:#475569;cursor:pointer;text-align:left;}', '.asst-chip{border:1px solid #e6e3f2;background:#faf9fd;border-radius:99px;padding:6px 12px;font:600 .72rem Manrope,sans-serif;color:#475569;cursor:pointer;text-align:left;transition:all .14s;}',
'.asst-chip:hover{border-color:#9B5DE5;color:#9B5DE5;}', '.asst-chip:hover{border-color:#9B5DE5;color:#9B5DE5;}',
'.asst-chip-ctx{background:rgba(155,93,229,.1);border-color:rgba(155,93,229,.35);color:#7e3eca;}', '.asst-chip-ctx{background:rgba(155,93,229,.1);border-color:rgba(155,93,229,.35);color:#7e3eca;}',
'.asst-rich{font-size:.84rem;line-height:1.55;color:#28324a;}', '.asst-rich{font-size:.84rem;line-height:1.55;color:#28324a;}',
@@ -322,18 +344,29 @@
'.asst-rich .katex-display::-webkit-scrollbar{height:6px;}', '.asst-rich .katex-display::-webkit-scrollbar{height:6px;}',
'.asst-rich .katex-display::-webkit-scrollbar-thumb{background:rgba(15,23,42,.18);border-radius:99px;}', '.asst-rich .katex-display::-webkit-scrollbar-thumb{background:rgba(15,23,42,.18);border-radius:99px;}',
'.asst-rich .katex{max-width:100%;}', '.asst-rich .katex{max-width:100%;}',
// мигающий курсор во время стриминга ответа (CSS-каретка, без глифа)
'.asst-streaming{white-space:pre-wrap;}',
'.asst-streaming::after{content:"";display:inline-block;width:2px;height:1em;vertical-align:-2px;margin-left:2px;background:#9B5DE5;animation:asst-blink 1s steps(2) infinite;}',
'@keyframes asst-blink{50%{opacity:0;}}',
'.asst-md-h{font-weight:800;color:#0F172A;margin:6px 0 2px;}', '.asst-md-h{font-weight:800;color:#0F172A;margin:6px 0 2px;}',
'.asst-chat{max-height:46vh;overflow:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:8px;}', '.asst-chat{max-height:54vh;overflow:auto;display:flex;flex-direction:column;gap:9px;margin-bottom:10px;padding-right:5px;}',
'.asst-chat::-webkit-scrollbar{width:7px;}',
'.asst-chat::-webkit-scrollbar-thumb{background:rgba(155,93,229,.28);border-radius:99px;}',
'.asst-chat::-webkit-scrollbar-track{background:transparent;}',
'.asst-chat:empty{display:none;}', '.asst-chat:empty{display:none;}',
'.asst-msg{font-size:.84rem;line-height:1.5;border-radius:12px;padding:8px 11px;max-width:92%;word-break:break-word;}', '.asst-msg{font-size:.85rem;line-height:1.55;border-radius:15px;padding:9px 13px;max-width:88%;word-break:break-word;box-shadow:0 1px 3px rgba(15,23,42,.06);}',
'.asst-msg-user{align-self:flex-end;background:#9B5DE5;color:#fff;}', '.asst-msg-user{align-self:flex-end;background:linear-gradient(135deg,#9B5DE5,#7d3fc8);color:#fff;border-bottom-right-radius:5px;}',
'.asst-msg-assistant{align-self:flex-start;background:rgba(15,23,42,.05);max-width:100%;}', '.asst-msg-assistant{align-self:flex-start;background:#f6f4fc;border:1px solid rgba(155,93,229,.1);max-width:100%;border-bottom-left-radius:5px;}',
'.asst-msg-assistant .asst-rich{color:#28324a;}', '.asst-msg-assistant .asst-rich{color:#28324a;}',
'.asst-msg-ph{opacity:.6;}', '.asst-msg-ph{opacity:.6;}',
'.asst-msg-links{align-self:flex-start;font-size:.74rem;}', '.asst-msg-links{align-self:flex-start;font-size:.74rem;}',
'.asst-modes{display:flex;gap:6px;margin:2px 0 8px;}', '.asst-modes{display:flex;gap:5px;margin:2px 0 9px;}',
'.asst-mode{flex:1;border:1px solid #e2e8f0;background:#f8fafc;border-radius:8px;padding:5px 6px;font:700 .68rem Manrope,sans-serif;color:#475569;cursor:pointer;}', '.asst-mode{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;border:1px solid #ece9f6;background:#faf9fd;border-radius:11px;padding:7px 3px;font:700 .6rem Manrope,sans-serif;color:#64748b;cursor:pointer;transition:all .14s;}',
'.asst-mode.on{background:#9B5DE5;border-color:#9B5DE5;color:#fff;}', '.asst-mode .ic{width:15px;height:15px;opacity:.85;}',
'.asst-mode span{white-space:nowrap;}',
'.asst-mode:hover{border-color:#cdbdf2;color:#7e3eca;background:#fff;}',
'.asst-mode.on{background:linear-gradient(135deg,#9B5DE5,#7d3fc8);border-color:transparent;color:#fff;box-shadow:0 4px 11px rgba(155,93,229,.3);}',
'.asst-mode.on .ic{opacity:1;}',
'.asst-src{align-self:flex-start;font-size:.72rem;color:#8a94a6;}', '.asst-src{align-self:flex-start;font-size:.72rem;color:#8a94a6;}',
'.asst-src a{color:#7e3eca;font-weight:700;text-decoration:none;}', '.asst-src a{color:#7e3eca;font-weight:700;text-decoration:none;}',
'.asst-fb{align-self:flex-start;display:flex;gap:6px;}', '.asst-fb{align-self:flex-start;display:flex;gap:6px;}',
@@ -347,6 +380,7 @@
'.asst-mem-prof{background:rgba(155,93,229,.07);border:1px solid rgba(155,93,229,.18);border-radius:10px;padding:9px 12px;line-height:1.75;margin-bottom:10px;}', '.asst-mem-prof{background:rgba(155,93,229,.07);border:1px solid rgba(155,93,229,.18);border-radius:10px;padding:9px 12px;line-height:1.75;margin-bottom:10px;}',
'.asst-mem-notes-h{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:6px 0 4px;}', '.asst-mem-notes-h{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:6px 0 4px;}',
'.asst-mem-note{display:flex;align-items:center;gap:8px;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(15,23,42,.06);}', '.asst-mem-note{display:flex;align-items:center;gap:8px;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(15,23,42,.06);}',
'.asst-mem-cat{display:inline-block;font-size:.6rem;font-weight:800;text-transform:uppercase;letter-spacing:.03em;color:#7e3eca;background:rgba(155,93,229,.1);border-radius:99px;padding:1px 7px;margin-right:7px;vertical-align:1px;}',
'.asst-mem-note:last-of-type{border-bottom:none;}', '.asst-mem-note:last-of-type{border-bottom:none;}',
'.asst-mem-x{border:none;background:none;color:#b4bcc8;cursor:pointer;font-size:1.15rem;line-height:1;padding:0 4px;}', '.asst-mem-x{border:none;background:none;color:#b4bcc8;cursor:pointer;font-size:1.15rem;line-height:1;padding:0 4px;}',
'.asst-mem-x:hover{color:#e0335e;}', '.asst-mem-x:hover{color:#e0335e;}',
@@ -359,6 +393,8 @@
/* ── рендер ──────────────────────────────────────────────────────────── */ /* ── рендер ──────────────────────────────────────────────────────────── */
function setFace(mood) { var f = root.querySelector('.asst-face'); if (f) f.innerHTML = faceSVG(mood); } function setFace(mood) { var f = root.querySelector('.asst-face'); if (f) f.innerHTML = faceSVG(mood); }
// живость: лицо Квантика в шапке чата реагирует на состояние (думает/радуется/грустит)
function setNameFace(mood) { var f = bubble && bubble.querySelector && bubble.querySelector('.asst-name-face'); if (f) { f.innerHTML = faceSVG(mood === 'thinking' ? 'neutral' : mood); f.classList.toggle('asst-think', mood === 'thinking'); } }
function openBubble(html, opts) { function openBubble(html, opts) {
opts = opts || {}; opts = opts || {};
@@ -476,17 +512,57 @@
var h = sec.querySelector('.sec-h'); var h = sec.querySelector('.sec-h');
var title = (h && h.textContent.trim()) || (document.title || 'Параграф').split('·')[0].trim(); var title = (h && h.textContent.trim()) || (document.title || 'Параграф').split('·')[0].trim();
var text = (sec.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 3500); var text = (sec.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 3500);
if (text.length > 40) return { title: title, text: text }; if (text.length > 40) return { title: title, text: text, kind: 'textbook' };
}
}
if (PAGE === 'theory') {
var c = document.querySelector('.lesson-content, .lsn-content, .lesson-body, #lesson-content, article.lesson, .course-lesson, .lesson-view');
if (c) {
var lt = document.querySelector('h1, .lsn-title, .lesson-title');
var ltitle = (lt && lt.textContent.trim()) || (document.title || 'Урок').split('·')[0].trim();
var ltext = (c.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 3500);
if (ltext.length > 60) return { title: ltitle, text: ltext, kind: 'lesson' };
} }
} }
} catch (e) {} } catch (e) {}
return null; return null;
} }
// Лёгкий ситуативный контекст для ЛЮБОГО вопроса — где сейчас ученик (заголовок+раздел).
var PAGE_LABEL = { textbook: 'учебник', theory: 'урок/курс', exam: 'экзамен или тест', flashcards: 'флешкарты',
lab: 'лаборатория', homework: 'домашние задания', dashboard: 'главная', knowledge: 'карта знаний',
library: 'библиотека', analytics: 'аналитика', gradebook: 'журнал', qbank: 'банк вопросов' };
function pageHint() {
try {
var label = PAGE_LABEL[PAGE] || '';
var hEl = document.querySelector('.sec.active .sec-h, h1, .lsn-title, .lesson-title, .page-title');
var title = (hEl && hEl.textContent.trim()) || (document.title || '').split('·')[0].split('|')[0].trim();
title = title.replace(/\s+/g, ' ').slice(0, 120);
if (!title && !label) return '';
return 'Ученик сейчас на странице платформы' + (label ? ' («' + label + '»)' : '') + (title ? ': «' + title + '»' : '') + '. Учитывай это, если вопрос относится к материалу страницы.';
} catch (e) { return ''; }
}
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */ /* ── «Спроси Квантика» ───────────────────────────────────────────────── */
var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?']; var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
var TEACHER_SUGGESTIONS = ['Как создать класс и выдать задание?', 'Идеи заданий по теме…', 'Составь план урока по теме…', 'Как работает журнал и аналитика?', 'Как провести онлайн-урок?']; var TEACHER_SUGGESTIONS = ['Как создать класс и выдать задание?', 'Идеи заданий по теме…', 'Составь план урока по теме…', 'Как работает журнал и аналитика?', 'Как провести онлайн-урок?'];
var _chat = []; // многоходовой диалог: [{role:'user'|'assistant', content}] var _chat = []; // многоходовой диалог: [{role:'user'|'assistant', content}]
// Диалог переживает обновление/переходы (localStorage, per-user), пока ученик сам не нажмёт «Очистить»
// или пока не пройдёт CHAT_TTL без новых сообщений (срок жизни от последней реплики).
var CHAT_TTL = 7 * 24 * 3600 * 1000; // 7 дней
function _chatKey() { try { var u = LS.getUser && LS.getUser(); return u && u.id ? 'asst_chat_' + u.id : null; } catch (e) { return null; } }
function saveChat() { var k = _chatKey(); if (k) lsSet(k, JSON.stringify({ t: Date.now(), c: _chat.slice(-30) })); }
function loadChat() {
var k = _chatKey(); if (!k) return;
try {
var raw = JSON.parse(lsGet(k) || 'null'), arr, ts = null;
if (Array.isArray(raw)) arr = raw; // старый формат (без метки времени)
else if (raw && Array.isArray(raw.c)) { arr = raw.c; ts = raw.t; }
else return;
if (ts && (Date.now() - ts) > CHAT_TTL) { clearChatStore(); return; } // протух — забываем
_chat = arr.filter(function (m) { return m && (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string'; });
} catch (e) {}
}
function clearChatStore() { var k = _chatKey(); if (k) try { localStorage.removeItem(k); } catch (e) {} }
function msgEl(role) { var d = document.createElement('div'); d.className = 'asst-msg asst-msg-' + role; return d; } function msgEl(role) { var d = document.createElement('div'); d.className = 'asst-msg asst-msg-' + role; return d; }
function renderChat(chatEl) { function renderChat(chatEl) {
chatEl.innerHTML = ''; chatEl.innerHTML = '';
@@ -501,37 +577,50 @@
} }
var FB_UP = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>'; var FB_UP = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
var FB_DOWN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg)"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>'; var FB_DOWN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg)"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»' }; var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»', quiz: 'Тема или текст — сгенерирую вопросы для банка' };
var _svg = function (p) { return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + p + '</svg>'; };
var MODE_DEFS = [
{ m: 'answer', label: 'Ответ', title: 'Ответить на вопрос', ic: _svg('<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>') },
{ m: 'hint', label: 'Подсказка', title: 'Подсказать, не решая целиком', ic: _svg('<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>') },
{ m: 'check', label: 'Проверить', title: 'Проверить твоё решение', ic: _svg('<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>') },
{ m: 'quiz', label: 'В банк', title: 'Сгенерировать вопросы в банк', ic: _svg('<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>'), tch: true },
{ m: 'draw', label: 'Рисунок', title: 'Нарисовать картинку', ic: _svg('<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>') }
];
function openAsk(prefill) { function openAsk(prefill) {
var sel = _lastSel, pc = getPageContext(); var sel = _lastSel, pc = getPageContext();
var noun = pc && pc.kind === 'lesson' ? 'этот урок' : 'этот параграф';
var noun2 = pc && pc.kind === 'lesson' ? 'урока' : 'параграфа';
var ctxBtns = ''; var ctxBtns = '';
if (sel) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sel" type="button">Объяснить выделенное</button>'; if (sel) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sel" type="button">Объяснить выделенное</button>';
if (pc) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sec" type="button">Объяснить этот параграф</button>' + if (pc) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sec" type="button">Объяснить ' + noun + '</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="sum" type="button">Конспект параграфа</button>' + '<button class="asst-chip asst-chip-ctx" data-ctx="sum" type="button">Конспект ' + noun2 + '</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="cards" type="button">Флешкарты из параграфа</button>'; '<button class="asst-chip asst-chip-ctx" data-ctx="cards" type="button">Флешкарты из ' + noun2 + '</button>';
var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS; var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS;
var chips = '<div class="asst-chips">' + ctxBtns + var chips = '<div class="asst-chips">' + ctxBtns +
sug.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>'; sug.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>';
var modes = '<div class="asst-modes">' + var isTch = (_role === 'teacher' || _role === 'admin');
'<button class="asst-mode on" data-m="answer">Ответ</button>' + var modes = '<div class="asst-modes">' + MODE_DEFS.filter(function (d) { return !d.tch || isTch; })
'<button class="asst-mode" data-m="hint">Подсказка</button>' + .map(function (d) { return '<button class="asst-mode' + (d.m === 'answer' ? ' on' : '') + '" data-m="' + d.m + '" title="' + d.title + '">' + d.ic + '<span>' + d.label + '</span></button>'; })
'<button class="asst-mode" data-m="check">Проверить решение</button>' + .join('') + '</div>';
'<button class="asst-mode" data-m="draw">Нарисовать</button></div>';
openBubble( openBubble(
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' + '<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' +
'<button class="asst-link" data-a="mem" style="float:right;font-weight:600;margin-right:24px">Память</button>' + '<button class="asst-link" data-a="mem" style="float:right;font-weight:600;margin-right:24px">Память</button>' +
(_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:8px">Очистить</button>' : '') + '</div>' + (_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:8px">Очистить</button>' : '') + '</div>' +
'<div class="asst-chat"></div>' + chips + modes + '<div class="asst-chat"></div>' + chips + modes +
'<input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />' + '<div class="asst-ask-row"><input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />' +
'<div class="asst-memnote">Я помню последние ~6 сообщений этого разговора — как рабочая память: что было раньше, понимаю; старое постепенно забывается. «Очистить» — начать с чистого листа.</div>', {}); '<button class="asst-send" type="button" title="Отправить" aria-label="Отправить"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4 20-7z"/></svg></button></div>' +
'<div class="asst-memnote">' + _svg('<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>') +
'<span>Держу в голове ход беседы — последние <b>~14 сообщений</b>. Диалог сохраняется между заходами, пока ты сам не нажмёшь «Очистить» или пока не пройдёт неделя без общения.</span></div>', {});
var inp = bubble.querySelector('.asst-ask-in'); var inp = bubble.querySelector('.asst-ask-in');
var chatEl = bubble.querySelector('.asst-chat'); var chatEl = bubble.querySelector('.asst-chat');
var chipsEl = bubble.querySelector('.asst-chips'); var chipsEl = bubble.querySelector('.asst-chips');
var mode = 'answer'; var mode = 'answer';
renderChat(chatEl); renderChat(chatEl);
if (_chat.length) chipsEl.style.display = 'none'; if (_chat.length) chipsEl.style.display = 'none';
function go(q, context, m) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; send(q, context, chatEl, m || mode); } // свободный вопрос (context не задан явно) → подмешиваем лёгкий ситуативный контекст страницы
function go(q, context, m) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; if (context == null) context = pageHint() || undefined; send(q, context, chatEl, m || mode); }
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') go(inp.value); }); inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') go(inp.value); });
var sendBtn = bubble.querySelector('.asst-send'); if (sendBtn) sendBtn.addEventListener('click', function () { go(inp.value); });
bubble.querySelectorAll('.asst-mode').forEach(function (b) { bubble.querySelectorAll('.asst-mode').forEach(function (b) {
b.addEventListener('click', function () { b.addEventListener('click', function () {
mode = b.getAttribute('data-m'); mode = b.getAttribute('data-m');
@@ -550,7 +639,7 @@
}); });
}); });
var clr = bubble.querySelector('[data-a="clear"]'); var clr = bubble.querySelector('[data-a="clear"]');
if (clr) clr.onclick = function () { _chat = []; openAsk(); }; if (clr) clr.onclick = function () { _chat = []; clearChatStore(); openAsk(); };
var memBtn = bubble.querySelector('[data-a="mem"]'); var memBtn = bubble.querySelector('[data-a="mem"]');
if (memBtn) memBtn.onclick = openMemory; if (memBtn) memBtn.onclick = openMemory;
if (prefill && prefill.q) go(prefill.q, prefill.context, prefill.mode); if (prefill && prefill.q) go(prefill.q, prefill.context, prefill.mode);
@@ -565,13 +654,18 @@
if (p.weakSubjects && p.weakSubjects.length) prof.push('Слабые предметы: ' + p.weakSubjects.map(function (s) { return esc(s.name) + ' ' + s.avg + '%'; }).join(', ')); if (p.weakSubjects && p.weakSubjects.length) prof.push('Слабые предметы: ' + p.weakSubjects.map(function (s) { return esc(s.name) + ' ' + s.avg + '%'; }).join(', '));
if (p.weakTopics && p.weakTopics.length) prof.push('Трудные темы: ' + p.weakTopics.map(function (t) { return esc(t.topic) + ' ' + t.rate + '%'; }).join(', ')); if (p.weakTopics && p.weakTopics.length) prof.push('Трудные темы: ' + p.weakTopics.map(function (t) { return esc(t.topic) + ' ' + t.rate + '%'; }).join(', '));
if (p.streak >= 3) prof.push('Серия занятий: ' + p.streak + ' дн.'); if (p.streak >= 3) prof.push('Серия занятий: ' + p.streak + ' дн.');
var notes = (m.notes || []).map(function (n) { return '<div class="asst-mem-note"><span>' + esc(n.text) + '</span><button class="asst-mem-x" data-id="' + n.id + '" title="Забыть">&times;</button></div>'; }).join(''); var MEM_CAT = { difficulty: 'трудность', goal: 'цель', preference: 'предпочтение', strength: 'сильная сторона', personal: 'личное', note: 'заметка' };
var notes = (m.notes || []).map(function (n) {
var cat = MEM_CAT[n.kind] || 'заметка';
return '<div class="asst-mem-note"><span><span class="asst-mem-cat">' + esc(cat) + '</span>' + esc(n.text) + '</span><button class="asst-mem-x" data-id="' + n.id + '" title="Забыть">&times;</button></div>';
}).join('');
var forgettable = (m.notes && m.notes.length) || (p.weakSubjects && p.weakSubjects.length) || (p.weakTopics && p.weakTopics.length);
var body = m.enabled === false var body = m.enabled === false
? '<div class="asst-mem-off">Персональная память выключена администратором.</div>' ? '<div class="asst-mem-off">Персональная память выключена администратором.</div>'
: '<div class="asst-mem-body">' + : '<div class="asst-mem-body">' +
(prof.length ? '<div class="asst-mem-prof">' + prof.map(function (x) { return '<div>• ' + x + '</div>'; }).join('') + '</div>' : '') + (prof.length ? '<div class="asst-mem-prof">' + prof.map(function (x) { return '<div>• ' + x + '</div>'; }).join('') + '<div style="font-size:.66rem;color:#9aa5b4;margin-top:7px">Считается по твоей активности и обновляется автоматически.</div></div>' : '') +
(notes ? '<div class="asst-mem-notes-h">Заметки</div>' + notes : (prof.length ? '' : '<div class="asst-empty">Пока я ничего не запомнил — позанимайся, и здесь появятся слабые темы и заметки.</div>')) + (notes ? '<div class="asst-mem-notes-h">Заметки</div>' + notes : (prof.length ? '' : '<div class="asst-empty">Пока я ничего не запомнил — позанимайся, и здесь появятся слабые темы и заметки.</div>')) +
((notes || prof.length) ? '<button class="asst-link" data-a="forget" style="margin-top:12px;color:#e0335e">Забыть всё</button>' : '') + (forgettable ? '<button class="asst-link" data-a="forget" style="margin-top:12px;color:#e0335e">Забыть всё</button>' : '') +
'</div>'; '</div>';
openBubble( openBubble(
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Что я о тебе помню' + '<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Что я о тебе помню' +
@@ -590,15 +684,15 @@
_chat.push({ role: 'user', content: 'Нарисуй: ' + prompt }); _chat.push({ role: 'user', content: 'Нарисуй: ' + prompt });
var u = msgEl('user'); u.textContent = 'Нарисуй: ' + prompt; chatEl.appendChild(u); var u = msgEl('user'); u.textContent = 'Нарисуй: ' + prompt; chatEl.appendChild(u);
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = 'Рисую картинку…'; chatEl.appendChild(ph); var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = 'Рисую картинку…'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight; chatEl.scrollTop = chatEl.scrollHeight; setNameFace('thinking');
LS.imageGen(prompt).then(function (r) { LS.imageGen(prompt).then(function (r) {
ph.remove(); ph.remove();
var d = msgEl('assistant'); var d = msgEl('assistant');
if (r && r.url) { d.innerHTML = '<img src="' + r.url + '" alt="" style="width:100%;border-radius:10px;display:block">'; _chat.push({ role: 'assistant', content: '[картинка]', img: r.url }); } if (r && r.url) { d.innerHTML = '<img src="' + r.url + '" alt="" style="width:100%;border-radius:10px;display:block">'; _chat.push({ role: 'assistant', content: '[картинка]', img: r.url }); saveChat(); setNameFace('ecstatic'); }
else d.textContent = 'Не получилось нарисовать.'; else { d.textContent = 'Не получилось нарисовать.'; setNameFace('sad'); }
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight; chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
}).catch(function (err) { }).catch(function (err) {
ph.remove(); var d = msgEl('assistant'); ph.remove(); var d = msgEl('assistant'); setNameFace('sad');
d.textContent = (err && err.data && err.data.error) || 'Не получилось нарисовать.'; d.textContent = (err && err.data && err.data.error) || 'Не получилось нарисовать.';
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight; chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
}); });
@@ -607,11 +701,90 @@
q = (q || '').trim(); q = (q || '').trim();
if (q.length < 2) return; if (q.length < 2) return;
if (mode === 'draw') return drawInChat(q, chatEl); if (mode === 'draw') return drawInChat(q, chatEl);
var history = _chat.slice(-6); if (mode === 'quiz') return makeQuiz(q, chatEl);
// стриминг недоступен (старый кэш api.js / нет ReadableStream) — обычный путь
if (!LS.assistantAskStream || typeof ReadableStream === 'undefined') return sendNonStream(q, context, chatEl, mode);
var history = _chat.slice(-14);
_chat.push({ role: 'user', content: q }); _chat.push({ role: 'user', content: q });
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u); var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = mode === 'check' ? 'Проверяю…' : 'Думаю…'; chatEl.appendChild(ph); var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.innerHTML = '<span class="asst-typing"><span></span><span></span><span></span></span>'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight; chatEl.scrollTop = chatEl.scrollHeight;
setNameFace('thinking');
var searchP = (LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; });
var meta = { answers: [], sources: [] }, full = '', msgD = null, richEl = null, streamed = false, finalized = false;
function ensureMsg() {
if (msgD) return;
if (ph.parentNode) ph.remove();
msgD = msgEl('assistant'); msgD.innerHTML = '<div class="asst-rich asst-streaming"></div>';
richEl = msgD.querySelector('.asst-rich'); chatEl.appendChild(msgD);
}
function finalize(done) {
if (finalized) return; finalized = true;
done = done || {};
var src = done.source;
if ((src === 'limit' || src === 'error') && !full) {
_chat.pop();
if (msgD) msgD.remove(); if (ph.parentNode) ph.remove();
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = done.answer || 'Сейчас не получилось. Попробуй ещё раз.';
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('sad'); return;
}
var isModel = src === 'model' && (full || done.answer);
setNameFace(isModel ? 'happy' : 'neutral');
searchP.then(function (sres) {
var found = (sres && sres.results) || [];
var ansArr = (done.answers && done.answers.length ? done.answers : meta.answers) || [];
var sources = done.sources || meta.sources || [];
var content = isModel ? (full || done.answer) : ((ansArr[0] && (ansArr[0].q + '\n' + ansArr[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).');
ensureMsg(); richEl.classList.remove('asst-streaming');
_chat.push({ role: 'assistant', content: content }); saveChat();
renderRich(richEl, content);
if (isModel && sources.length) {
var sc = document.createElement('div'); sc.className = 'asst-src';
sc.innerHTML = 'Источник: ' + sources.map(function (s) { return '<a href="' + esc(safeUrl(srcUrl(s))) + '">' + esc(s.title) + (s.section ? ', ' + esc(s.section) : '') + '</a>'; }).join('; ');
chatEl.appendChild(sc);
}
var links = '';
if (!isModel && ansArr.length) links += ansArr.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '<a class="asst-ans-link" href="' + esc(safeUrl(a.url)) + '">' + esc(a.q) + '</a>'; }).join(' · ');
if (found.length) links += (links ? '<br>' : '') + '<span style="color:#8a94a6">На платформе: </span>' + found.slice(0, 3).map(function (f) { return '<a class="asst-ans-link" href="' + esc(safeUrl(f.url)) + '">' + esc(f.title || '…') + '</a>'; }).join(' · ');
if (links) { var l = document.createElement('div'); l.className = 'asst-msg-links'; l.innerHTML = links; chatEl.appendChild(l); }
if (isModel) {
var fb = document.createElement('div'); fb.className = 'asst-fb';
fb.innerHTML = '<button data-r="1" title="Полезно">' + FB_UP + '</button><button data-r="-1" title="Не помогло">' + FB_DOWN + '</button>';
fb.querySelectorAll('button').forEach(function (b) {
b.addEventListener('click', function () { if (fb.dataset.done) return; fb.dataset.done = '1'; b.classList.add('on'); try { LS.assistantFeedback(Number(b.getAttribute('data-r')), q); } catch (e) {} });
});
chatEl.appendChild(fb);
}
chatEl.scrollTop = chatEl.scrollHeight;
});
}
LS.assistantAskStream(q, context, history, mode, {
onMeta: function (m) { if (m.answers) meta.answers = m.answers; if (m.sources) meta.sources = m.sources; },
onDelta: function (t) { streamed = true; ensureMsg(); full += t; richEl.textContent = full; chatEl.scrollTop = chatEl.scrollHeight; },
onDone: function (o) { finalize(o); },
}).then(function () { if (!finalized) finalize({ source: full ? 'model' : 'faq' }); })
.catch(function () {
if (finalized) return;
if (!streamed) { if (ph.parentNode) ph.remove(); _chat.pop(); sendNonStream(q, context, chatEl, mode); }
else finalize({ source: 'model' });
});
}
function sendNonStream(q, context, chatEl, mode) {
q = (q || '').trim();
if (q.length < 2) return;
if (mode === 'draw') return drawInChat(q, chatEl);
if (mode === 'quiz') return makeQuiz(q, chatEl);
var history = _chat.slice(-14);
_chat.push({ role: 'user', content: q });
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.innerHTML = '<span class="asst-typing"><span></span><span></span><span></span></span>'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight;
setNameFace('thinking');
Promise.all([ Promise.all([
LS.assistantAsk(q, context, history, mode).catch(function () { return { answers: [] }; }), LS.assistantAsk(q, context, history, mode).catch(function () { return { answers: [] }; }),
(LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }), (LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
@@ -622,14 +795,15 @@
if (r0.source === 'limit' || r0.source === 'error') { if (r0.source === 'limit' || r0.source === 'error') {
_chat.pop(); _chat.pop();
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = r0.answer || 'Сейчас не получилось. Попробуй ещё раз.'; var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = r0.answer || 'Сейчас не получилось. Попробуй ещё раз.';
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; return; chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('sad'); return;
} }
var model = r0.source === 'model' ? r0.answer : null; var model = r0.source === 'model' ? r0.answer : null;
setNameFace(model ? 'happy' : 'neutral');
var ans = r0.answers || []; var ans = r0.answers || [];
var sources = r0.sources || []; var sources = r0.sources || [];
var found = (res[1] && res[1].results) || []; var found = (res[1] && res[1].results) || [];
var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).'; var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).';
_chat.push({ role: 'assistant', content: content }); _chat.push({ role: 'assistant', content: content }); saveChat();
var d = msgEl('assistant'); d.innerHTML = '<div class="asst-rich"></div>'; chatEl.appendChild(d); var d = msgEl('assistant'); d.innerHTML = '<div class="asst-rich"></div>'; chatEl.appendChild(d);
renderRich(d.querySelector('.asst-rich'), content); renderRich(d.querySelector('.asst-rich'), content);
// источники (RAG) // источники (RAG)
@@ -680,6 +854,63 @@
}).catch(function () { note.innerHTML = '<div class="asst-rich">Не удалось сделать карточки. Попробуй позже.</div>'; }); }).catch(function () { note.innerHTML = '<div class="asst-rich">Не удалось сделать карточки. Попробуй позже.</div>'; });
} }
/* ── «Тест в банк» (учитель): модель → вопросы → банк вопросов ─────────── */
function makeQuiz(topic, chatEl) {
topic = (topic || '').trim();
var note = msgEl('assistant');
note.innerHTML = '<div class="asst-rich">Составляю тестовые вопросы…</div>';
chatEl.appendChild(note); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('thinking');
Promise.all([
LS.assistantQuestions(topic, 5),
(LS.getSubjects ? LS.getSubjects() : Promise.resolve([])).catch(function () { return []; }),
]).then(function (res) {
var qs = (res[0] && res[0].questions) || [];
var subjects = Array.isArray(res[1]) ? res[1] : ((res[1] && res[1].subjects) || []);
if (!qs.length) { note.innerHTML = '<div class="asst-rich">Не получилось сгенерировать вопросы. Уточни тему и попробуй ещё.</div>'; setNameFace('sad'); return; }
note.remove();
var wrap = msgEl('assistant'); wrap.style.maxWidth = '100%';
var box = document.createElement('div'); box.className = 'asst-rich'; wrap.appendChild(box);
var head = document.createElement('div'); head.style.cssText = 'font-weight:800;margin-bottom:6px'; head.textContent = 'Вопросы (' + qs.length + ') — проверь и сохрани:'; box.appendChild(head);
qs.forEach(function (it, i) {
var qd = document.createElement('div'); qd.style.cssText = 'margin:8px 0;padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:10px';
var qt = document.createElement('div'); qt.style.cssText = 'font-weight:700;margin-bottom:4px'; qt.appendChild(document.createTextNode((i + 1) + '. '));
var qr = document.createElement('span'); qt.appendChild(qr); renderRich(qr, it.q); qd.appendChild(qt);
(it.options || []).forEach(function (op, oi) {
var li = document.createElement('div'); li.style.cssText = 'padding:2px 0 2px 14px;font-size:.84rem' + (oi === it.correct ? ';color:#059652;font-weight:700' : '');
var os = document.createElement('span'); renderRich(os, op); li.appendChild(os);
if (oi === it.correct) { var okm = document.createElement('span'); okm.textContent = ' — верно'; okm.style.color = '#059652'; li.appendChild(okm); }
qd.appendChild(li);
});
if (it.explanation) { var ex = document.createElement('div'); ex.style.cssText = 'margin-top:4px;font-size:.8rem;color:#8a94a6'; ex.appendChild(document.createTextNode('Пояснение: ')); var exs = document.createElement('span'); renderRich(exs, it.explanation); ex.appendChild(exs); qd.appendChild(ex); }
box.appendChild(qd);
});
var bar = document.createElement('div'); bar.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:8px';
var sel = document.createElement('select'); sel.style.cssText = 'padding:6px 8px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.82rem';
sel.innerHTML = '<option value="">Предмет…</option>' + subjects.map(function (s) { return '<option value="' + esc(s.slug) + '">' + esc(s.name || s.slug) + '</option>'; }).join('');
var topicIn = document.createElement('input'); topicIn.type = 'text'; topicIn.placeholder = 'Тема (необязательно)'; topicIn.style.cssText = 'padding:6px 8px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.82rem;flex:1;min-width:110px';
var saveB = document.createElement('button'); saveB.className = 'asst-chip'; saveB.type = 'button'; saveB.textContent = 'Сохранить в банк';
var st = document.createElement('span'); st.style.cssText = 'font-size:.8rem;color:#8a94a6';
bar.appendChild(sel); bar.appendChild(topicIn); bar.appendChild(saveB); bar.appendChild(st); box.appendChild(bar);
saveB.addEventListener('click', function () {
var slug = sel.value; if (!slug) { st.textContent = 'Выбери предмет'; return; }
saveB.disabled = true; st.textContent = 'Сохраняю…';
var topicName = topicIn.value.trim() || (topic.length <= 60 ? topic : '');
var done = 0;
qs.reduce(function (p, it) {
return p.then(function () {
return LS.createQuestion({ subject_slug: slug, topic_name: topicName || undefined, type: 'single', text: it.q, explanation: it.explanation || undefined, difficulty: 1, options: (it.options || []).map(function (t, i) { return { text: t, is_correct: i === it.correct }; }) }).then(function () { done++; }).catch(function () {});
});
}, Promise.resolve()).then(function () {
st.innerHTML = 'Сохранено ' + done + ' из ' + qs.length + '. <a class="asst-ans-link" href="/question-bank">Открыть банк вопросов</a>';
saveB.style.display = 'none'; sel.disabled = true; topicIn.disabled = true;
});
});
chatEl.appendChild(wrap); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('ecstatic');
}).catch(function (e) {
note.innerHTML = '<div class="asst-rich">' + ((e && e.data && e.data.error) ? esc(e.data.error) : 'Не удалось сгенерировать вопросы.') + '</div>'; setNameFace('sad');
});
}
/* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */ /* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */
var TOUR = [ var TOUR = [
{ sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' }, { sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' },
@@ -844,6 +1075,7 @@
SRV = ctx || {}; SRV = ctx || {};
_role = (SRV && SRV.role) || 'student'; _role = (SRV && SRV.role) || 'student';
if (SRV.enabled === false) return; // выключено пользователем if (SRV.enabled === false) return; // выключено пользователем
loadChat(); // восстановить диалог прошлой сессии (per-user)
return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) { return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) {
PET = pet || null; PET = pet || null;
ensurePet(mount); ensurePet(mount);
+4 -1
View File
@@ -197,7 +197,10 @@
sev: 'amber', kind: 'stuck', kindLabel: 'Зависла', sev: 'amber', kind: 'stuck', kindLabel: 'Зависла',
title: s.user_name || '—', title: s.user_name || '—',
meta: `${e(s.subject_name || '—')} · висит <span class="acc-mono">${fmtSince(s.started_at)}</span>`, meta: `${e(s.subject_name || '—')} · висит <span class="acc-mono">${fmtSince(s.started_at)}</span>`,
act: 'Открыть', actHash: '/admin#sessions', solid: true, // Глубокая ссылка на ДЕТАЛИ конкретной сессии (открывается при любом статусе):
// список /admin#sessions показывает только completed, поэтому зависшая (in_progress)
// там не находилась. На странице деталей её можно посмотреть и удалить.
act: 'Открыть', actHash: '/admin#sessions/' + s.id, solid: true,
}); });
}); });
const ab = d.abandonedSessions24h || 0; const ab = d.abandonedSessions24h || 0;
+9 -3
View File
@@ -34,6 +34,12 @@
const pickerOver = document.getElementById('vp-overlay'); const pickerOver = document.getElementById('vp-overlay');
const pickerGrid = document.getElementById('vp-grid'); const pickerGrid = document.getElementById('vp-grid');
/* Человекочитаемая метка варианта (ЦТ-2015 и т.п.); фолбэк — «Вариант N». */
const labelOf = (n) => {
const v = variants.find(x => x.n === n);
return (v && v.label) || `Вариант ${n}`;
};
/* ── Picker overlay ─────────────────────────────────────────── */ /* ── Picker overlay ─────────────────────────────────────────── */
function buildGrid() { function buildGrid() {
pickerGrid.innerHTML = variants.map(v => { pickerGrid.innerHTML = variants.map(v => {
@@ -45,7 +51,7 @@
const active = v.n === currentN ? ' active' : ''; const active = v.n === currentN ? ' active' : '';
const title = `${v.label} · решено ${v.solved}/${v.total}` + const title = `${v.label} · решено ${v.solved}/${v.total}` +
(v.viewed_sol ? ` · решений открыто ${v.viewed_sol}` : ''); (v.viewed_sol ? ` · решений открыто ${v.viewed_sol}` : '');
return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.n}</button>`; return `<button class="vg-btn${cls}${active}" data-n="${v.n}" title="${title}">${v.label || v.n}</button>`;
}).join(''); }).join('');
pickerGrid.querySelectorAll('button[data-n]').forEach(b => { pickerGrid.querySelectorAll('button[data-n]').forEach(b => {
b.onclick = () => { selectVariant(Number(b.dataset.n)); closePicker(); }; b.onclick = () => { selectVariant(Number(b.dataset.n)); closePicker(); };
@@ -74,7 +80,7 @@
/* ── Variant rendering ──────────────────────────────────────── */ /* ── Variant rendering ──────────────────────────────────────── */
async function selectVariant(n) { async function selectVariant(n) {
currentN = n; currentN = n;
pickerLabel.textContent = `Вариант ${n}`; pickerLabel.textContent = labelOf(n);
try { localStorage.setItem(`exam_prep_${examKey}_last_variant`, String(n)); } catch {} try { localStorage.setItem(`exam_prep_${examKey}_last_variant`, String(n)); } catch {}
if (!tasksCache.has(n)) { if (!tasksCache.has(n)) {
@@ -94,7 +100,7 @@
} }
function renderVariant(n, tasks) { function renderVariant(n, tasks) {
main.innerHTML = `<div class="vp-title">Вариант ${n}<small>${tasks.length} заданий</small></div>`; main.innerHTML = `<div class="vp-title">${labelOf(n)}<small>${tasks.length} заданий</small></div>`;
const variantMeta = variants.find(v => v.n === n); const variantMeta = variants.find(v => v.n === n);
const solvedTracked = new Set(); // tasks already solved this session const solvedTracked = new Set(); // tasks already solved this session
+13
View File
@@ -760,8 +760,21 @@ window.ChemVisuals = (() => {
return hex; return hex;
} }
/* Replace inline icon-SVG (reaction arrows) with Unicode for canvas fillText.
Canvas draws plain strings embedded <svg> markup would show as raw text. */
function cleanIcons(s) {
if (!s || !s.includes('<svg')) return s;
return s.replace(/<svg[\s\S]*?<\/svg>/g, m => {
if (m.includes('x1="5" y1="12" x2="19"')) return '→'; // right arrow
if (m.includes('x1="12" y1="5" x2="12" y2="19"')) return '↓'; // down (precipitate)
if (m.includes('x1="12" y1="19" x2="12" y2="5"')) return '↑'; // up (gas)
return '';
});
}
/* ── Public API ─────────────────────────────────────────────── */ /* ── Public API ─────────────────────────────────────────────── */
return { return {
cleanIcons,
drawErlenmeyer, drawErlenmeyer,
drawBeaker, drawBeaker,
drawBurette, drawBurette,
+2 -2
View File
@@ -17,7 +17,7 @@
if (document.getElementById('lm-style')) return; if (document.getElementById('lm-style')) return;
var s = document.createElement('style'); s.id = 'lm-style'; var s = document.createElement('style'); s.id = 'lm-style';
s.textContent = [ s.textContent = [
'#lm-svg{position:fixed;inset:0;z-index:60;pointer-events:none;display:none;}', '#lm-svg{position:fixed;inset:0;width:100vw;height:100vh;z-index:60;pointer-events:none;display:none;}',
'#lm-svg.on{display:block;}', '#lm-svg.on{display:block;}',
'#lm-svg .lm-hit{pointer-events:auto;cursor:grab;}', '#lm-svg .lm-hit{pointer-events:auto;cursor:grab;}',
'#lm-svg .lm-hit:active{cursor:grabbing;}', '#lm-svg .lm-hit:active{cursor:grabbing;}',
@@ -66,7 +66,7 @@
function setTool(t) { function setTool(t) {
ensure(); ensure();
if (t === 'off') { mode = null; svg.classList.remove('on'); paintBar(); render(); return; } if (t === 'off') { mode = null; svg.classList.remove('on'); bar.classList.remove('on'); paintBar(); render(); return; }
mode = t; mode = t;
var c = center(); var c = center();
if (t === 'ruler' && !ruler) ruler = { ax: c.x - 110, ay: c.y, bx: c.x + 110, by: c.y }; if (t === 'ruler' && !ruler) ruler = { ax: c.x - 110, ay: c.y, bx: c.x + 110, by: c.y };
+16 -2
View File
@@ -38,7 +38,8 @@
asin: 1, acos: 1, atan: 1, arcsin: 1, arccos: 1, arctan: 1, arctg: 1, asin: 1, acos: 1, atan: 1, arcsin: 1, arccos: 1, arctan: 1, arctg: 1,
sqrt: 1, abs: 1, exp: 1, ln: 1, log: -2, log2: 1, log10: 1, sqrt: 1, abs: 1, exp: 1, ln: 1, log: -2, log2: 1, log10: 1,
floor: 1, ceil: 1, round: 1, sign: 1, floor: 1, ceil: 1, round: 1, sign: 1,
min: -1, max: -1, mod: 2, atan2: 2, pow: 2, hypot: -1 min: -1, max: -1, mod: 2, atan2: 2, pow: 2, hypot: -1,
gcd: 2, lcm: 2
}; };
// Реализации. Все защищены от исключений на уровне evaluate (домены проверяются // Реализации. Все защищены от исключений на уровне evaluate (домены проверяются
@@ -60,7 +61,20 @@
floor: Math.floor, ceil: Math.ceil, round: Math.round, sign: Math.sign, floor: Math.floor, ceil: Math.ceil, round: Math.round, sign: Math.sign,
min: Math.min, max: Math.max, min: Math.min, max: Math.max,
mod: function (a, b) { return b === 0 ? 0 : a % b; }, mod: function (a, b) { return b === 0 ? 0 : a % b; },
atan2: Math.atan2, pow: Math.pow, hypot: Math.hypot atan2: Math.atan2, pow: Math.pow, hypot: Math.hypot,
// НОД (алгоритм Евклида) и НОК — целочисленные, защищены от NaN/0/отрицательных
gcd: function (a, b) {
a = Math.abs(Math.round(a)); b = Math.abs(Math.round(b));
if (!isFinite(a) || !isFinite(b)) return 0;
while (b) { var t = a % b; a = b; b = t; }
return a;
},
lcm: function (a, b) {
a = Math.abs(Math.round(a)); b = Math.abs(Math.round(b));
if (!a || !b || !isFinite(a) || !isFinite(b)) return 0;
var x = a, y = b; while (y) { var t = x % y; x = y; y = t; }
return a / x * b;
}
}; };
var CONSTANTS = { pi: Math.PI, PI: Math.PI, e: Math.E, E: Math.E, tau: Math.PI * 2 }; var CONSTANTS = { pi: Math.PI, PI: Math.PI, e: Math.E, E: Math.E, tau: Math.PI * 2 };
+1 -1
View File
@@ -1562,7 +1562,7 @@ class ChemSandboxSim {
if (!isOk && this._quizTask) { if (!isOk && this._quizTask) {
ctx.font = '10px "JetBrains Mono", monospace'; ctx.font = '10px "JetBrains Mono", monospace';
ctx.fillStyle = `rgba(255,255,255,${alpha * 0.45})`; ctx.fillStyle = `rgba(255,255,255,${alpha * 0.45})`;
ctx.fillText('Ответ: ' + this._quizTask.rx.eq, W / 2, bannerY + 65); ctx.fillText('Ответ: ' + _csClean(this._quizTask.rx.eq), W / 2, bannerY + 65);
} }
} }
+1 -1
View File
@@ -1146,7 +1146,7 @@ class FlaskSim {
const eqY = g.cy + g.r + 26; const eqY = g.cy + g.r + 26;
ctx.font = '12.5px monospace'; ctx.fillStyle = 'rgba(185,215,255,0.78)'; ctx.font = '12.5px monospace'; ctx.fillStyle = 'rgba(185,215,255,0.78)';
ctx.textAlign = 'center'; ctx.fillText(eq, W * 0.44, eqY); ctx.textAlign = 'left'; ctx.textAlign = 'center'; ctx.fillText(ChemVisuals.cleanIcons(eq), W * 0.44, eqY); ctx.textAlign = 'left';
if (this._passiv) { if (this._passiv) {
ctx.font = 'bold 11px sans-serif'; ctx.fillStyle = '#FFD166'; ctx.font = 'bold 11px sans-serif'; ctx.fillStyle = '#FFD166';
+213 -23
View File
@@ -16,6 +16,8 @@ class GraphSim {
this.oy = 0; // viewport centre y (math units) this.oy = 0; // viewport centre y (math units)
this.scl = 50; // px per unit this.scl = 50; // px per unit
this.fns = []; // [{ color, fn } | null] this.fns = []; // [{ color, fn } | null]
this._hidden = [false, false, false]; // показ/скрытие функции
this.showPts = false; // особые точки (нули/пересечения/y-перехват)
this.hx = null; // hovered x (math) or null this.hx = null; // hovered x (math) or null
this._dg = null; // drag state this._dg = null; // drag state
this.onHover = null; // callback(mx, [y0,y1,…]) or (null, null) this.onHover = null; // callback(mx, [y0,y1,…]) or (null, null)
@@ -62,6 +64,8 @@ class GraphSim {
resetView() { this.ox = 0; this.oy = 0; this.scl = 50; this.draw(); } resetView() { this.ox = 0; this.oy = 0; this.scl = 50; this.draw(); }
zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); } zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); } zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); }
setHidden(idx, v) { this._hidden[idx] = !!v; this.draw(); }
setShowPoints(v) { this.showPts = !!v; this.draw(); }
/* ── formula compiler (CSP-safe: no eval / new Function) ── */ /* ── formula compiler (CSP-safe: no eval / new Function) ── */
@@ -271,10 +275,14 @@ class GraphSim {
this._drawGrid(c, W, H); this._drawGrid(c, W, H);
this._drawAxes(c, W, H); this._drawAxes(c, W, H);
for (const f of this.fns) if (f) this._drawCurve(c, W, H, f); this.fns.forEach((f, i) => { if (f && !this._hidden[i]) this._drawCurve(c, W, H, f); });
if (this.showPts) this._drawPoints(c, W, H);
if (this.hx !== null) this._drawHover(c, W, H); if (this.hx !== null) this._drawHover(c, W, H);
} }
/* видимые функции (для hover/особых точек) */
_visible() { return this.fns.map((f, i) => (f && !this._hidden[i]) ? f : null); }
/* ── grid ──────────────────────────────────── */ /* ── grid ──────────────────────────────────── */
_niceStep() { _niceStep() {
@@ -414,21 +422,90 @@ class GraphSim {
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke(); c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke();
c.setLineDash([]); c.setLineDash([]);
for (const f of this.fns) { this.fns.forEach((f, i) => {
if (!f) continue; if (!f || this._hidden[i]) return;
let my; let my;
try { my = f.fn(this.hx); } catch { continue; } try { my = f.fn(this.hx); } catch { return; }
if (!isFinite(my) || isNaN(my)) continue; if (!isFinite(my) || isNaN(my)) return;
const [, py] = this._toPx(this.hx, my); const [, py] = this._toPx(this.hx, my);
if (py < -20 || py > H + 20) continue; if (py < -20 || py > H + 20) return;
c.fillStyle = f.color; c.fillStyle = f.color;
c.beginPath(); c.arc(px, py, 5.5, 0, 2 * Math.PI); c.fill(); c.beginPath(); c.arc(px, py, 5.5, 0, 2 * Math.PI); c.fill();
c.strokeStyle = 'rgba(255,255,255,0.8)'; c.strokeStyle = 'rgba(255,255,255,0.8)';
c.lineWidth = 1.5; c.stroke(); c.lineWidth = 1.5; c.stroke();
});
}
/* ── особые точки: нули, y-перехват, пересечения ─── */
_findZeros(g, a, b, samples) {
const zeros = []; const dx = (b - a) / samples; const eps = Math.abs(dx) * 0.25;
const push = (r) => { if (!zeros.length || Math.abs(r - zeros[zeros.length - 1]) > eps) zeros.push(r); };
let pmx = a, pv; try { pv = g(a); } catch { pv = NaN; }
if (isFinite(pv) && pv === 0) push(a);
for (let i = 1; i <= samples && zeros.length < 60; i++) {
const mx = a + i * dx;
let v; try { v = g(mx); } catch { v = NaN; }
if (isFinite(pv) && isFinite(v)) {
if (v === 0) { push(mx); } // точный ноль на узле сетки
else if (pv !== 0 && pv * v < 0) { // смена знака — бисекция
let lo = pmx, hi = mx, flo = pv;
for (let k = 0; k < 50; k++) {
const mid = (lo + hi) / 2; let fm; try { fm = g(mid); } catch { fm = NaN; }
if (!isFinite(fm)) { lo = hi = mid; break; }
if (flo * fm <= 0) hi = mid; else { lo = mid; flo = fm; }
}
push((lo + hi) / 2);
}
}
pmx = mx; pv = v;
}
return zeros;
}
_drawPoints(c, W, H) {
const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0);
const samples = Math.min(Math.round(W), 800);
const vis = this._visible();
const pts = []; // { mx, my, color, kind }
vis.forEach(f => {
if (!f) return;
// нули функции
this._findZeros(f.fn, x0, x1, samples).forEach(rx => pts.push({ mx: rx, my: 0, color: f.color, kind: 'root' }));
// y-перехват
if (x0 <= 0 && x1 >= 0) { let v; try { v = f.fn(0); } catch { v = NaN; } if (isFinite(v)) pts.push({ mx: 0, my: v, color: f.color, kind: 'yint' }); }
});
// пересечения пар
for (let i = 0; i < vis.length; i++) for (let j = i + 1; j < vis.length; j++) {
if (!vis[i] || !vis[j]) continue;
const fi = vis[i].fn, fj = vis[j].fn;
this._findZeros(x => fi(x) - fj(x), x0, x1, samples).forEach(ix => {
let v; try { v = fi(ix); } catch { v = NaN; }
if (isFinite(v)) pts.push({ mx: ix, my: v, color: '#ffffff', kind: 'cross' });
});
}
const labels = pts.length <= 22; // не подписываем при «частоколе» (sin на широком диапазоне)
c.font = '600 10.5px Manrope, system-ui, sans-serif';
for (const p of pts) {
const [px, py] = this._toPx(p.mx, p.my);
if (px < -8 || px > W + 8 || py < -8 || py > H + 8) continue;
c.beginPath(); c.arc(px, py, 4.5, 0, 2 * Math.PI);
c.fillStyle = p.color; c.fill();
c.lineWidth = 1.5; c.strokeStyle = '#0D0D1A'; c.stroke();
if (labels) {
const tx = '(' + this._fmtP(p.mx) + '; ' + this._fmtP(p.my) + ')';
c.textAlign = 'left'; c.textBaseline = 'bottom';
const lx = Math.min(px + 7, W - c.measureText(tx).width - 4), ly = Math.max(12, py - 6);
c.fillStyle = 'rgba(13,13,26,0.78)';
const tw = c.measureText(tx).width;
c.fillRect(lx - 3, ly - 12, tw + 6, 14);
c.fillStyle = p.kind === 'cross' ? 'rgba(255,255,255,0.92)' : p.color;
c.fillText(tx, lx, ly);
}
} }
} }
_fmtP(n) { if (Math.abs(n) < 1e-9) return '0'; const r = Math.round(n * 100) / 100; return Number.isInteger(r) ? String(r) : r.toFixed(2); }
/* ── events ─────────────────────────────────── */ /* ── events ─────────────────────────────────── */
@@ -475,27 +552,41 @@ class GraphSim {
}); });
cv.style.cursor = 'crosshair'; cv.style.cursor = 'crosshair';
/* touch drag */ /* touch: 1 палец — панорама, 2 пальца — пинч-зум к центру жеста */
let t0 = null; let t0 = null, pinch = null;
const tDist = ts => Math.hypot(ts[0].clientX - ts[1].clientX, ts[0].clientY - ts[1].clientY);
cv.addEventListener('touchstart', e => { cv.addEventListener('touchstart', e => {
if (e.touches.length === 1) if (e.touches.length === 1) {
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy }; t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy }; pinch = null;
} else if (e.touches.length === 2) {
const r = cv.getBoundingClientRect();
pinch = { d: tDist(e.touches), scl: this.scl,
cx: (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left,
cy: (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top };
t0 = null;
}
}, { passive: true }); }, { passive: true });
cv.addEventListener('touchmove', e => { cv.addEventListener('touchmove', e => {
e.preventDefault(); e.preventDefault();
if (e.touches.length === 1 && t0) { if (e.touches.length === 2 && pinch) {
const [mx, my] = this._toMx(pinch.cx, pinch.cy);
this.scl = Math.max(4, Math.min(800, pinch.scl * (tDist(e.touches) / (pinch.d || 1))));
const [nx, ny] = this._toMx(pinch.cx, pinch.cy);
this.ox -= nx - mx; this.oy -= ny - my;
this.draw();
} else if (e.touches.length === 1 && t0) {
this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl; this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl; this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
this.draw(); this.draw();
} }
}, { passive: false }); }, { passive: false });
cv.addEventListener('touchend', () => { t0 = null; }); cv.addEventListener('touchend', e => { if (e.touches.length === 0) { t0 = null; pinch = null; } });
} }
_emitHover() { _emitHover() {
if (!this.onHover) return; if (!this.onHover) return;
const vals = this.fns.map(f => { const vals = this.fns.map((f, i) => {
if (!f) return null; if (!f || this._hidden[i]) return null;
try { const v = f.fn(this.hx); return isFinite(v) ? v : null; } catch { return null; } try { const v = f.fn(this.hx); return isFinite(v) ? v : null; } catch { return null; }
}); });
this.onHover(this.hx, vals); this.onHover(this.hx, vals);
@@ -507,6 +598,7 @@ class GraphSim {
document.getElementById('sim-topbar-title').textContent = 'График функции'; document.getElementById('sim-topbar-title').textContent = 'График функции';
_simShow('sim-graph'); _simShow('sim-graph');
_simShow('ctrl-graph'); _simShow('ctrl-graph');
_initGraphPanel();
_registerSimState('graph', _registerSimState('graph',
() => ({ () => ({
@@ -518,18 +610,20 @@ class GraphSim {
const el = document.getElementById(`fn${i}`); const el = document.getElementById(`fn${i}`);
if (el) { el.value = fn.expr; } if (el) { el.value = fn.expr; }
if (gSim) gSim.setFn(i, fn.expr, FN_COLORS[i]); if (gSim) gSim.setFn(i, fn.expr, FN_COLORS[i]);
_fnDisplay(i);
}); });
} }
); );
if (_embedMode) _startStateEmit('graph'); if (_embedMode) _startStateEmit('graph');
requestAnimationFrame(() => requestAnimationFrame(() => { requestAnimationFrame(() => requestAnimationFrame(() => {
_initGraphPanel(); // KaTeX к этому моменту точно загружен
if (!gSim) { if (!gSim) {
gSim = new GraphSim(document.getElementById('graph-canvas')); gSim = new GraphSim(document.getElementById('graph-canvas'));
gSim.onHover = updateInfoBar; gSim.onHover = updateInfoBar;
if (!document.getElementById('fn0').value.trim()) { if (!document.getElementById('fn0').value.trim()) {
document.getElementById('fn0').value = 'sin(x)'; document.getElementById('fn0').value = 'sin(x)';
renderPreview(0); renderPreview(0); _fnDisplay(0);
gSim.fit(); gSim.fit();
gSim.setFn(0, 'sin(x)', FN_COLORS[0]); gSim.setFn(0, 'sin(x)', FN_COLORS[0]);
return; return;
@@ -572,19 +666,83 @@ class GraphSim {
.replace(/\*/g, '\\cdot '); .replace(/\*/g, '\\cdot ');
} }
function _katexInto(el, latex) {
try { el.innerHTML = katex.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }); return true; }
catch { el.innerHTML = ''; return false; }
}
/* Живое превью формулы под полем — показывается ТОЛЬКО пока строка в правке. */
function renderPreview(idx) { function renderPreview(idx) {
const inp = document.getElementById('fn' + idx); const inp = document.getElementById('fn' + idx);
const prev = document.getElementById('fn' + idx + '-prev'); const prev = document.getElementById('fn' + idx + '-prev');
if (!prev) return;
const raw = inp?.value?.trim() || ''; const raw = inp?.value?.trim() || '';
if (!raw || typeof katex === 'undefined') { if (!raw || typeof katex === 'undefined') { prev.innerHTML = ''; prev.classList.remove('has-content'); return; }
prev.innerHTML = ''; prev.classList.remove('has-content'); return; if (_katexInto(prev, toLatex(raw))) prev.classList.add('has-content');
} else prev.classList.remove('has-content');
try { }
prev.innerHTML = katex.renderToString(toLatex(raw), {
throwOnError: false, strict: false, displayMode: false, /* Введённая функция — отрисованной формулой KaTeX прямо в строке. */
function renderFnMath(idx) {
const inp = document.getElementById('fn' + idx);
const m = document.getElementById('fn' + idx + '-math');
if (!m || !inp) return;
const raw = inp.value.trim();
if (!raw || typeof katex === 'undefined') { m.innerHTML = ''; return; }
_katexInto(m, toLatex(raw));
}
/* Режим строки: не в фокусе и есть формула → показываем KaTeX; иначе — поле ввода. */
function _fnDisplay(idx) {
const inp = document.getElementById('fn' + idx);
const field = inp && inp.closest('.fn-field');
if (!field) return;
const showMath = (document.activeElement !== inp) && !!inp.value.trim() && typeof katex !== 'undefined';
if (showMath) { renderFnMath(idx); field.classList.add('has-math'); }
else field.classList.remove('has-math');
}
/* Вставка структуры формулы в активное поле (как редактор формул).
В токене символ | помечает позицию каретки, напр. 'sin(|)'. */
let _activeFnIdx = 0, _graphPanelInit = false;
function graphInsert(token) {
const el = document.getElementById('fn' + _activeFnIdx) || document.getElementById('fn0');
if (!el) return;
const f = el.closest('.fn-field'); if (f) f.classList.remove('has-math'); // в режим правки
el.focus();
let ins = String(token || ''); let caretInTok = -1;
const bar = ins.indexOf('|');
if (bar >= 0) { caretInTok = bar; ins = ins.slice(0, bar) + ins.slice(bar + 1); }
const s = (el.selectionStart != null) ? el.selectionStart : el.value.length;
const e = (el.selectionEnd != null) ? el.selectionEnd : el.value.length;
el.value = el.value.slice(0, s) + ins + el.value.slice(e);
const pos = s + (caretInTok >= 0 ? caretInTok : ins.length);
try { el.setSelectionRange(pos, pos); } catch (_) {}
updateFn(_activeFnIdx);
}
/* KaTeX на чипах/клавиатуре + math-поля + слежение за активным полем (идемпотентно). */
function _initGraphPanel() {
const root = document.getElementById('sim-graph');
if (!root || typeof katex === 'undefined') return;
root.querySelectorAll('.preset-btn[data-tex], .kp-btn[data-tex]').forEach(b => {
if (b.dataset.rendered) return;
if (_katexInto(b, b.dataset.tex)) b.dataset.rendered = '1';
});
if (!_graphPanelInit) {
_graphPanelInit = true;
[0, 1, 2].forEach(i => {
const el = document.getElementById('fn' + i);
const m = document.getElementById('fn' + i + '-math');
if (el) {
el.addEventListener('focus', () => { _activeFnIdx = i; _fnDisplay(i); });
el.addEventListener('blur', () => { _fnDisplay(i); });
}
// клик по формуле → правка текста на месте
if (m) m.addEventListener('mousedown', (ev) => { ev.preventDefault(); const f = m.closest('.fn-field'); if (f) f.classList.remove('has-math'); el && el.focus(); });
}); });
prev.classList.add('has-content'); }
} catch { prev.innerHTML = ''; prev.classList.remove('has-content'); } [0, 1, 2].forEach(i => { renderPreview(i); _fnDisplay(i); });
} }
/* debounced formula update */ /* debounced formula update */
@@ -623,11 +781,43 @@ class GraphSim {
document.getElementById('fn' + i).value = ''; document.getElementById('fn' + i).value = '';
document.getElementById('fn' + i + '-prev').innerHTML = ''; document.getElementById('fn' + i + '-prev').innerHTML = '';
document.getElementById('fn' + i + '-prev').classList.remove('has-content'); document.getElementById('fn' + i + '-prev').classList.remove('has-content');
const m = document.getElementById('fn' + i + '-math'); if (m) m.innerHTML = '';
const f = document.getElementById('fn' + i)?.closest('.fn-field'); if (f) f.classList.remove('has-math');
document.getElementById('fn' + i + '-err').classList.remove('show'); document.getElementById('fn' + i + '-err').classList.remove('show');
if (gSim) gSim.setFn(i, '', FN_COLORS[i]); if (gSim) gSim.setFn(i, '', FN_COLORS[i]);
} }
} }
/* ── per-function controls + view controls ── */
const _EYE_ON = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>';
const _EYE_OFF = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.9 4.2A10 10 0 0 1 12 4c6.5 0 10 7 10 7a17 17 0 0 1-3 3.7M6.6 6.6A17 17 0 0 0 2 11s3.5 7 10 7a10 10 0 0 0 4.1-.9"/><line x1="3" y1="3" x2="21" y2="21"/></svg>';
function toggleFn(idx) {
if (!gSim) return;
const hidden = !gSim._hidden[idx];
gSim.setHidden(idx, hidden);
const row = document.getElementById('fn' + idx)?.closest('.fn-row');
if (row) row.classList.toggle('fn-hidden', hidden);
const eye = document.getElementById('fn' + idx + '-eye');
if (eye) { eye.innerHTML = hidden ? _EYE_OFF : _EYE_ON; eye.classList.toggle('off', hidden); }
}
function clearFn(idx) {
const el = document.getElementById('fn' + idx); if (!el) return;
el.value = ''; updateFn(idx);
const m = document.getElementById('fn' + idx + '-math'); if (m) m.innerHTML = '';
const f = el.closest('.fn-field'); if (f) f.classList.remove('has-math');
// снять скрытие, если было
if (gSim && gSim._hidden[idx]) toggleFn(idx);
el.focus();
}
function graphZoom(dir) { if (gSim) { dir > 0 ? gSim.zoomIn() : gSim.zoomOut(); } }
function graphFit() { if (gSim) gSim.resetView(); }
function toggleGraphPoints() {
if (!gSim) return;
const on = !gSim.showPts; gSim.setShowPoints(on);
const b = document.getElementById('graph-pts-btn'); if (b) b.classList.toggle('active', on);
}
/* hover info bar */ /* hover info bar */
function fmtVal(v) { function fmtVal(v) {
if (v === null || v === undefined) return '—'; if (v === null || v === undefined) return '—';
+32 -14
View File
@@ -1252,10 +1252,16 @@ class OrganicSim {
center.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative'; center.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative';
panel.appendChild(center); panel.appendChild(center);
// wrapper makes the canvas position:absolute so its intrinsic pixel size
// (set in _drawQual) can't feed back into the flex layout and inflate height
const canvasWrap = document.createElement('div');
canvasWrap.style.cssText = 'flex:1;position:relative;overflow:hidden';
center.appendChild(canvasWrap);
const qualCanvas = document.createElement('canvas'); const qualCanvas = document.createElement('canvas');
qualCanvas.style.cssText = 'width:100%;flex:1;display:block'; qualCanvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%';
this._qualCanvas = qualCanvas; this._qualCanvas = qualCanvas;
center.appendChild(qualCanvas); canvasWrap.appendChild(qualCanvas);
// compounds area // compounds area
const compArea = document.createElement('div'); const compArea = document.createElement('div');
@@ -1376,15 +1382,21 @@ class OrganicSim {
const comp = this._qualCompound; const comp = this._qualCompound;
const anim = this._qualAnim; const anim = this._qualAnim;
// draw multiple test tubes // draw multiple test tubes — sized to fill the available height (bigger flasks)
const tubes = rxn.compounds; const tubes = rxn.compounds;
const tubeW = 56, tubeH = 150, gap = 20; const badgePad = 48; // room above tubes for the +//? badge
const labelPad = 62; // room below tubes for the name + reagent labels
const tubeH = Math.max(190, Math.min(H - badgePad - labelPad, 340));
const tubeW = Math.round(tubeH * 0.46);
const gap = Math.round(tubeW * 0.6);
const totalW = tubes.length * (tubeW + gap) - gap; const totalW = tubes.length * (tubeW + gap) - gap;
let startX = (W - totalW) / 2; const startX = (W - totalW) / 2;
// centre vertically, but never push the tubes so low they leave the viewport
let ty = badgePad + Math.max(0, (H - badgePad - labelPad - tubeH) / 2);
ty = Math.min(ty, 210);
tubes.forEach((tube, i) => { tubes.forEach((tube, i) => {
const tx = startX + i * (tubeW + gap); const tx = startX + i * (tubeW + gap);
const ty = (H - tubeH) / 2 - 10;
const isActive = comp && comp === tube; const isActive = comp && comp === tube;
const progress = (isActive && anim) ? Math.min(anim.t / anim.maxT, 1) : 0; const progress = (isActive && anim) ? Math.min(anim.t / anim.maxT, 1) : 0;
@@ -1392,19 +1404,19 @@ class OrganicSim {
// label // label
ctx.fillStyle = isActive ? '#C9A0FF' : 'rgba(255,255,255,0.5)'; ctx.fillStyle = isActive ? '#C9A0FF' : 'rgba(255,255,255,0.5)';
ctx.font = `${isActive ? '700' : '400'} 10px Manrope,sans-serif`; ctx.font = `${isActive ? '700' : '400'} 11px Manrope,sans-serif`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
const label = tube.name.length > 12 ? tube.name.substring(0,11)+'…' : tube.name; const label = tube.name.length > 14 ? tube.name.substring(0,13)+'…' : tube.name;
ctx.fillText(label, tx + tubeW/2, ty + tubeH + 8); ctx.fillText(label, tx + tubeW/2, ty + tubeH + 10);
}); });
// reagent label // reagent label
ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = '11px Manrope,sans-serif'; ctx.font = '12px Manrope,sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'bottom';
ctx.fillText(`Реагент: ${rxn.reagent}`, W/2, H - 4); ctx.fillText(`Реагент: ${rxn.reagent}`, W/2, Math.min(H - 6, ty + tubeH + 44));
} }
_drawTestTube(ctx, x, y, w, h, rxn, comp, progress, isActive) { _drawTestTube(ctx, x, y, w, h, rxn, comp, progress, isActive) {
@@ -1425,10 +1437,16 @@ class OrganicSim {
ctx.lineTo(x + w - 4, y); ctx.lineTo(x + w - 4, y);
ctx.stroke(); ctx.stroke();
// clip to tube for liquid // clip to the tube interior (straight sides + rounded bottom) so the
// liquid never spills past the glass outline
const rBot = w/2 - 4;
ctx.beginPath(); ctx.beginPath();
ctx.rect(x + 4, liqY, w - 8, liqH - 8); ctx.moveTo(x + 4, liqY);
ctx.arc(x + w/2, y + h - (w/2 - 4), w/2 - 4, 0, Math.PI); ctx.lineTo(x + 4, y + h - rBot);
ctx.arcTo(x + 4, y + h, x + w/2, y + h, rBot);
ctx.arcTo(x + w - 4, y + h, x + w - 4, y + h - rBot, rBot);
ctx.lineTo(x + w - 4, liqY);
ctx.closePath();
ctx.clip(); ctx.clip();
// base liquid (reagent color) // base liquid (reagent color)
+1 -1
View File
@@ -547,7 +547,7 @@ class RedoxSim {
ctx.fillText(s.lbl, 14, y); ctx.fillText(s.lbl, 14, y);
ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)'; ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)';
ctx.font = '9.5px monospace'; ctx.font = '9.5px monospace';
ctx.fillText(s.txt, 14 + ctx.measureText(s.lbl).width + 8, y); ctx.fillText(ChemVisuals.cleanIcons(s.txt), 14 + ctx.measureText(s.lbl).width + 8, y);
ctx.restore(); ctx.restore();
} }
+416 -65
View File
@@ -53,6 +53,8 @@ class TrigCircleSim {
this.graphFn = 'sin'; this.graphFn = 'sin';
this.snapToNotable = true; this.snapToNotable = true;
this.animating = false; this.animating = false;
this.eq = null; // режим уравнения: { fn:'sin'|'cos'|'tg', a:Number, sols:[рад] } | null
this.showParity = false; // показать зеркальную точку −α (чётность/нечётность)
this._cx = 0; this._cy = 0; this._r = 0; this._cx = 0; this._cy = 0; this._r = 0;
this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0; this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0;
@@ -96,11 +98,14 @@ class TrigCircleSim {
this._drawBg(c); this._drawBg(c);
this._drawCircle(c); this._drawCircle(c);
if (this.eq) this._drawEquation(c);
if (this.showParity) this._drawParity(c);
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); } if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
this._drawParticles(c); this._drawParticles(c);
if (window.LabFX) LabFX.particles.draw(c); if (window.LabFX) LabFX.particles.draw(c);
c.restore(); c.restore();
this._ovClearUnused();
this._fireUpdate(); this._fireUpdate();
} }
@@ -116,6 +121,103 @@ class TrigCircleSim {
this._layout(); this.draw(); this._layout(); this.draw();
} }
/* Режим уравнения: подсветить на окружности все решения fn(x)=a. */
setEquation(fn, a, sols) {
this.eq = { fn, a, sols: sols || [] };
if (this.eq.sols.length) this.angle = this.eq.sols[0]; // встать на первое решение
this.draw();
}
clearEquation() { this.eq = null; this.draw(); }
_drawEquation(c) {
const cx = this._cx, cy = this._cy, r = this._r;
const { fn, a, sols } = this.eq;
const accent = fn === 'sin' ? _TC.sin : fn === 'cos' ? _TC.cos : _TC.tan;
c.save();
/* направляющая линия значения */
c.strokeStyle = _tcRgba(accent, 0.55); c.lineWidth = 1.5; c.setLineDash([6, 5]);
c.beginPath();
if (fn === 'sin') { const y = cy - r * a; c.moveTo(cx - r - 22, y); c.lineTo(cx + r + 22, y); }
else if (fn === 'cos') { const x = cx + r * a; c.moveTo(x, cy - r - 22); c.lineTo(x, cy + r + 22); }
else { const ang = sols.length ? sols[0] : Math.atan(a); const dx = Math.cos(ang), dy = Math.sin(ang), L = r + 24;
c.moveTo(cx - L * dx, cy + L * dy); c.lineTo(cx + L * dx, cy - L * dy); }
c.stroke(); c.setLineDash([]);
/* точки-решения + подписи градусов */
c.font = 'bold 11px Manrope,sans-serif';
sols.forEach(ang => {
const x = cx + r * Math.cos(ang), y = cy - r * Math.sin(ang);
c.fillStyle = accent; c.shadowColor = accent; c.shadowBlur = 12;
c.beginPath(); c.arc(x, y, 6, 0, Math.PI * 2); c.fill(); c.shadowBlur = 0;
c.fillStyle = 'rgba(255,255,255,0.92)'; c.beginPath(); c.arc(x, y, 2.2, 0, Math.PI * 2); c.fill();
const lr = r + 18, lx = cx + lr * Math.cos(ang), ly = cy - lr * Math.sin(ang);
c.fillStyle = accent; c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText(Math.round(ang * 180 / Math.PI) + '°', lx, ly);
});
c.restore();
}
/* Зеркальная точка −α (отражение через ось Ox): наглядно чётность cos и нечётность sin. */
_drawParity(c) {
const cx = this._cx, cy = this._cy, r = this._r, a = this.angle;
const px = cx + r * Math.cos(a), py = cy - r * Math.sin(a);
const mx = cx + r * Math.cos(-a), my = cy - r * Math.sin(-a);
c.save();
c.strokeStyle = _tcRgba(_TC.violet, 0.4); c.setLineDash([4, 4]); c.lineWidth = 1;
c.beginPath(); c.moveTo(px, py); c.lineTo(mx, my); c.stroke(); c.setLineDash([]);
c.strokeStyle = _TC.violet; c.lineWidth = 2; c.fillStyle = 'rgba(155,93,229,0.15)';
c.beginPath(); c.arc(mx, my, 6, 0, Math.PI * 2); c.fill(); c.stroke();
c.font = 'bold 11px Manrope,sans-serif'; c.fillStyle = _TC.violet;
c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText('-α', mx + (Math.cos(-a) >= 0 ? 14 : -14), my);
c.restore();
}
/* ═══ KaTeX-оверлей: HTML-подписи поверх canvas (на canvas KaTeX не рисуется) ══════ */
_ov() {
if (this._ovEl === undefined) this._ovEl = (typeof document !== 'undefined' && document.getElementById) ? document.getElementById('trig-overlay') : null;
return this._ovEl;
}
/* key стабильный id подписи; latex LaTeX (дробь/корень KaTeX, иначе текст);
x,y CSS-px над canvas; anchor: c|l|r|t|b; boxed тёмная плашка (для координат). */
_ovLabel(key, latex, x, y, color, anchor, boxed) {
const ov = this._ov(); if (!ov) return;
this._ovMap = this._ovMap || {};
this._ovUsed = this._ovUsed || {};
let rec = this._ovMap[key];
if (!rec) {
const el = document.createElement('div');
el.style.position = 'absolute'; el.style.whiteSpace = 'nowrap'; el.style.pointerEvents = 'none';
el.style.willChange = 'transform';
ov.appendChild(el);
rec = this._ovMap[key] = { el, last: null, boxed: null };
}
if (rec.last !== latex) {
// Любая LaTeX-команда (\pi, \tfrac, \sin…) → KaTeX; простой текст/число — быстро текстом.
const useK = /\\/.test(latex) && (typeof window !== 'undefined' && window.katex);
if (useK) rec.el.innerHTML = window.katex.renderToString(latex, { throwOnError: false, strict: false, displayMode: false });
else rec.el.textContent = latex;
rec.last = latex;
}
if (rec.boxed !== !!boxed) {
rec.el.style.cssText += boxed
? ';background:rgba(12,12,22,0.82);border:1px solid rgba(155,93,229,0.3);border-radius:8px;padding:3px 9px'
: ';background:none;border:none;padding:0';
rec.boxed = !!boxed;
}
rec.el.style.color = color || '#fff';
const a = anchor || 'c';
const tr = a === 'r' ? 'translate(-100%,-50%)' : a === 'l' ? 'translate(0,-50%)'
: a === 't' ? 'translate(-50%,0)' : a === 'b' ? 'translate(-50%,-100%)' : 'translate(-50%,-50%)';
rec.el.style.transform = `translate(${x}px,${y}px) ${tr}`;
rec.el.style.display = '';
this._ovUsed[key] = true;
}
_ovClearUnused() {
if (!this._ovMap) return;
for (const k in this._ovMap) if (!(this._ovUsed && this._ovUsed[k])) this._ovMap[k].el.style.display = 'none';
this._ovUsed = {};
}
goToAngle(rad) { goToAngle(rad) {
this._animTarget = this._norm(rad); this._animTarget = this._norm(rad);
if (!this.animating) this._startAnim(); if (!this.animating) this._startAnim();
@@ -130,7 +232,16 @@ class TrigCircleSim {
const ct = Math.abs(s) > 1e-9 ? co / s : undefined; const ct = Math.abs(s) > 1e-9 ? co / s : undefined;
const deg = a * 180 / Math.PI; const deg = a * 180 / Math.PI;
const q = a < Math.PI/2 ? 1 : a < Math.PI ? 2 : a < 3*Math.PI/2 ? 3 : 4; const q = a < Math.PI/2 ? 1 : a < Math.PI ? 2 : a < 3*Math.PI/2 ? 3 : 4;
return { angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q }; // Опорный (острый) угол — к ближайшей оси Ox: основа формул приведения.
let ref;
if (a <= Math.PI / 2) ref = a;
else if (a <= Math.PI) ref = Math.PI - a;
else if (a <= 3 * Math.PI/2) ref = a - Math.PI;
else ref = 2 * Math.PI - a;
return {
angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q,
refAngle: ref, refDeg: ref * 180 / Math.PI,
};
} }
/* ═══ Layout ═══════════════════════════════════════════════════════ */ /* ═══ Layout ═══════════════════════════════════════════════════════ */
@@ -290,11 +401,10 @@ class TrigCircleSim {
ag.addColorStop(1, _tcRgba(_TC.violet, 0.0)); ag.addColorStop(1, _tcRgba(_TC.violet, 0.0));
c.strokeStyle = ag; c.lineWidth = 2.5; c.strokeStyle = ag; c.lineWidth = 2.5;
c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke(); c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke();
/* label */ /* label (KaTeX overlay: π-доля для табличных, иначе текст) */
const mid = a / 2, lr = ar + 18; const mid = a / 2, lr = ar + 20;
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.violet; this._ovLabel('angle', _angleLatex(a) || this._radLbl(a),
c.textAlign = 'center'; c.textBaseline = 'middle'; cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid), _TC.violet, 'c');
c.fillText(this._radLbl(a), cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid));
} }
/* ── radius ── */ /* ── radius ── */
@@ -388,9 +498,9 @@ class TrigCircleSim {
/* ── axis value badges ── */ /* ── axis value badges ── */
if (this.showSin && Math.abs(sinA) > 0.04) if (this.showSin && Math.abs(sinA) > 0.04)
this._badge(c, cx - 12, py, this._fmt(sinA), _TC.sin, 'right', 'middle'); this._ovLabel('vsin', _latexVal(sinA), cx - 14, py, _TC.sin, 'r');
if (this.showCos && Math.abs(cosA) > 0.04) if (this.showCos && Math.abs(cosA) > 0.04)
this._badge(c, projX, cy + 17, this._fmt(cosA), _TC.cos, 'center', 'top'); this._ovLabel('vcos', _latexVal(cosA), projX, cy + 20, _TC.cos, 't');
/* ── main point ── */ /* ── main point ── */
const ps = this._hover || this._drag ? 10 : 8; const ps = this._hover || this._drag ? 10 : 8;
@@ -406,8 +516,12 @@ class TrigCircleSim {
c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2; c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2;
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke(); c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke();
/* ── coordinate tooltip ── */ /* coordinate tooltip (KaTeX overlay) выносим РАДИАЛЬНО НАРУЖУ за точку,
this._tooltip(c, px, py, cosA, sinA); чтобы не перекрывать центральную дугу угла и её подпись */
const _odx = Math.cos(a), _ody = -Math.sin(a);
this._ovLabel('coord', `\\left(${_latexVal(cosA)};\\ ${_latexVal(sinA)}\\right)`,
px + _odx * 20 + (cosA >= 0 ? 6 : -6), py + _ody * 20 + (sinA >= 0 ? -8 : 8),
'#fff', cosA >= 0 ? 'l' : 'r', true);
/* ── quadrant roman numeral ── */ /* ── quadrant roman numeral ── */
const qOff = r * 0.46; const qOff = r * 0.46;
@@ -538,7 +652,6 @@ class TrigCircleSim {
const fn = this.graphFn; const fn = this.graphFn;
const col = _TC[fn] || _TC.sin; const col = _TC[fn] || _TC.sin;
const lbl = fn==='sin'?'y = sin x':fn==='cos'?'y = cos x':fn==='tan'?'y = tg x':'y = ctg x';
const evFn = fn==='sin'?Math.sin:fn==='cos'?Math.cos:fn==='tan'?Math.tan:(x=>1/Math.tan(x)); const evFn = fn==='sin'?Math.sin:fn==='cos'?Math.cos:fn==='tan'?Math.tan:(x=>1/Math.tan(x));
const yR = (fn==='tan'||fn==='cot') ? 4 : 1.5; const yR = (fn==='tan'||fn==='cot') ? 4 : 1.5;
const xMin = -0.25*Math.PI, xMax = 2.25*Math.PI; const xMin = -0.25*Math.PI, xMax = 2.25*Math.PI;
@@ -575,29 +688,30 @@ class TrigCircleSim {
c.beginPath(); c.moveTo(x0, gy); c.lineTo(x0, gy+gh); c.stroke(); c.beginPath(); c.moveTo(x0, gy); c.lineTo(x0, gy+gh); c.stroke();
} }
/* ±1 lines */ /* ── шкала значений по оси Y (значения на координатной плоскости) ── */
if (fn==='sin'||fn==='cos') { const yVals = (fn==='tan'||fn==='cot')
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.setLineDash([4, 4]); ? [[3,'3'],[2,'2'],[1,'1'],[0,'0'],[-1,'-1'],[-2,'-2'],[-3,'-3']]
[1,-1].forEach(v => { c.beginPath(); c.moveTo(gx, sy(v)); c.lineTo(gx+gw, sy(v)); c.stroke(); }); : [[1,'1'],[0.5,'\\tfrac{1}{2}'],[0,'0'],[-0.5,'-\\tfrac{1}{2}'],[-1,'-1']];
c.setLineDash([]); yVals.forEach(([v, lx], i) => {
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.22)'; const yy = sy(v);
c.textAlign='right'; c.textBaseline='middle'; if (yy < gy + 6 || yy > gy + gh - 6) return;
c.fillText('1', gx-5, sy(1)); c.fillText('1', gx-5, sy(-1)); if (v !== 0) {
} c.strokeStyle = 'rgba(255,255,255,0.05)'; c.lineWidth = 1; c.setLineDash([4, 4]);
c.beginPath(); c.moveTo(gx, yy); c.lineTo(gx+gw, yy); c.stroke(); c.setLineDash([]);
}
this._ovLabel('gy' + i, lx, gx - 6, yy, 'rgba(255,255,255,0.55)', 'r');
});
/* x ticks */ /* x ticks — линии на canvas, подписи KaTeX-оверлеем */
const ticks = [[0,'0'],[Math.PI/2,'π/2'],[Math.PI,'π'],[3*Math.PI/2,'3π/2'],[2*Math.PI,'2π']]; const ticks = [[0, '0'], [Math.PI/2, '\\tfrac{\\pi}{2}'], [Math.PI, '\\pi'],
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.20)'; [3*Math.PI/2, '\\tfrac{3\\pi}{2}'], [2*Math.PI, '2\\pi']];
c.textAlign='center'; c.textBaseline='top'; ticks.forEach(([v, lx], i) => {
for (const [v,l] of ticks) {
const xx = sx(v); const xx = sx(v);
if (xx < gx+6 || xx > gx+gw-6) continue; if (xx < gx+6 || xx > gx+gw-6) return;
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1; c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1; c.setLineDash([3,3]);
c.setLineDash([3,3]); c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke(); c.setLineDash([]);
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke(); this._ovLabel('gtick' + i, lx, xx, gy + gh + 9, 'rgba(255,255,255,0.55)', 't');
c.setLineDash([]); });
c.fillText(l, xx, gy+gh+6);
}
/* ── ghost curves (other functions, dimmed) ── */ /* ── ghost curves (other functions, dimmed) ── */
c.save(); c.save();
@@ -669,6 +783,21 @@ class TrigCircleSim {
} }
c.stroke(); c.stroke();
/* ── развёртка: ярче выделяем кривую на [0, α] — как угол «разворачивается» в график ── */
{
const aMax = Math.min(Math.max(this.angle, 0), xMax);
c.strokeStyle = col; c.lineWidth = 4.5; c.lineCap = 'round'; c.lineJoin = 'round';
c.shadowColor = col; c.shadowBlur = 6;
c.beginPath(); let onS = false;
for (let x = 0; x <= aMax + 1e-9; x += step) {
const yv = evFn(x);
if (!isFinite(yv) || Math.abs(yv) > yR * 2) { onS = false; continue; }
const spx = sx(x), spy = sy(yv);
if (!onS) { c.moveTo(spx, spy); onS = true; } else c.lineTo(spx, spy);
}
c.stroke(); c.shadowBlur = 0;
}
/* ── current angle marker ── */ /* ── current angle marker ── */
const curY = evFn(this.angle); const curY = evFn(this.angle);
if (isFinite(curY) && Math.abs(curY) <= yR*2) { if (isFinite(curY) && Math.abs(curY) <= yR*2) {
@@ -687,31 +816,21 @@ class TrigCircleSim {
c.shadowBlur = 0; c.shadowBlur = 0;
c.fillStyle = 'rgba(255,255,255,0.7)'; c.fillStyle = 'rgba(255,255,255,0.7)';
c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill(); c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill();
/* value badge */ /* value badge (KaTeX overlay) */
const txt = this._fmt(curY); this._ovLabel('gval', _latexVal(curY), mx + 12, my - 20, col, 'l', true);
c.font = 'bold 11px Manrope,sans-serif'; /* подпись угла на оси X (развёртка: где текущий угол на графике) */
const tm = c.measureText(txt); this._ovLabel('gangle', _angleLatex(this.angle) || this._radLbl(this.angle),
const bx2 = mx+10, by2 = my-22, bw2 = tm.width+14, bh2 = 20; mx, gy + 5, _TC.violet, 't', true);
c.fillStyle='rgba(12,12,22,0.85)';
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.fill();
c.strokeStyle = _tcRgba(col, 0.4); c.lineWidth = 1;
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.stroke();
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
c.fillText(txt, bx2+7, by2);
} }
c.restore(); c.restore();
/* fn name badge */ /* fn name badge (KaTeX-оверлей) */
c.font='bold 13px Manrope,sans-serif'; const _glblTex = fn === 'sin' ? 'y = \\sin x'
const tm2 = c.measureText(lbl); : fn === 'cos' ? 'y = \\cos x'
const bw3 = tm2.width+18, bh3 = 26; : fn === 'tan' ? 'y = \\operatorname{tg} x'
c.fillStyle='rgba(12,12,22,0.7)'; : 'y = \\operatorname{ctg} x';
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.fill(); this._ovLabel('glabel', _glblTex, gx + 16, gy + 21, col, 'l', true);
c.strokeStyle = _tcRgba(col, 0.25); c.lineWidth = 1;
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.stroke();
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
c.fillText(lbl, gx+17, gy+21);
} }
/* ═══ Snap particles ═══════════════════════════════════════════════ */ /* ═══ Snap particles ═══════════════════════════════════════════════ */
@@ -1029,6 +1148,144 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
if (window.LabFX) LabFX.sound.play('click'); if (window.LabFX) LabFX.sound.play('click');
} }
/* Ввод угла в градусах (поле + Enter/кнопка). Принимает любое число (включая <0 и >360),
goToAngle нормализует заодно демонстрирует котерминальность. */
function trigSetAngleDeg(inp) {
if (!trigSim || !inp) return;
const v = parseFloat(String(inp.value || '').replace(',', '.'));
if (!isFinite(v)) return;
trigSim.goToAngle(v * Math.PI / 180);
}
function trigAngleKey(e, inp) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSetAngleDeg(inp); }
/* Показать/скрыть график функций (тема «функции» по умолчанию можно убрать,
круг займёт всю ширину). Переиспользует существующий слой 'graph'. */
function trigToggleGraph(rowEl) {
if (!trigSim) return;
const on = rowEl.classList.toggle('active');
trigSim.toggleLayer('graph', on);
const fns = document.getElementById('trig-graph-fns');
if (fns) fns.style.display = on ? '' : 'none';
}
/* ── Уравнения: решения fn(x)=a на [0,2π) ── */
function _trigSolveAngles(fn, a) {
const TAU = 2 * Math.PI, norm = x => ((x % TAU) + TAU) % TAU;
let raw;
if (fn === 'sin') { if (Math.abs(a) > 1) return []; const b = Math.asin(a); raw = [b, Math.PI - b]; }
else if (fn === 'cos') { if (Math.abs(a) > 1) return []; const b = Math.acos(a); raw = [b, -b]; }
else { const b = Math.atan(a); raw = [b, b + Math.PI]; } // tg — всегда есть решения
const out = [];
raw.map(norm).forEach(x => { if (!out.some(y => Math.abs(y - x) < 1e-6 || Math.abs(y - x - TAU) < 1e-6)) out.push(x); });
return out.sort((p, q) => p - q);
}
/* Радиан → LaTeX красивой π-доли (или null). Покрывает главные значения arcsin/arccos/arctg. */
function _radLatex(rad) {
const P = Math.PI;
const T = [[0, '0'], [P/6, '\\tfrac{\\pi}{6}'], [P/4, '\\tfrac{\\pi}{4}'], [P/3, '\\tfrac{\\pi}{3}'],
[P/2, '\\tfrac{\\pi}{2}'], [2*P/3, '\\tfrac{2\\pi}{3}'], [3*P/4, '\\tfrac{3\\pi}{4}'],
[5*P/6, '\\tfrac{5\\pi}{6}'], [P, '\\pi']];
for (const [v, l] of T) {
if (Math.abs(rad - v) < 1e-6) return l;
if (v > 0 && Math.abs(rad + v) < 1e-6) return '-' + l;
}
return null;
}
/* Общая формула решения (LaTeX) или {none:true}. */
function _trigEqFormulaLatex(fn, a) {
if ((fn === 'sin' || fn === 'cos') && Math.abs(a) > 1) return { none: true };
if (fn === 'sin') {
const p = _radLatex(Math.asin(a)) || ('\\arcsin ' + _latexVal(a));
return { latex: `x = (-1)^{n}\\,${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
}
if (fn === 'cos') {
const p = _radLatex(Math.acos(a)) || ('\\arccos ' + _latexVal(a));
return { latex: `x = \\pm ${p} + 2\\pi n,\\ n\\in\\mathbb{Z}` };
}
const p = _radLatex(Math.atan(a)) || ('\\operatorname{arctg} ' + _latexVal(a));
return { latex: `x = ${p} + \\pi n,\\ n\\in\\mathbb{Z}` };
}
var trigEqFn = 'sin';
function trigSetEqFn(fn, btn) {
trigEqFn = fn;
document.querySelectorAll('.trig-eq-fn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function trigSolve() {
if (!trigSim) return;
const inp = document.getElementById('trig-eq-input');
const a = parseFloat(String(inp && inp.value || '').replace(',', '.'));
const fnTex = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}' }[trigEqFn];
const fEl = document.getElementById('trig-eq-formula');
const sEl = document.getElementById('trig-eq-sols');
if (!isFinite(a)) { if (fEl) fEl.innerHTML = '<span style="color:var(--text-3)">Введите значение a</span>'; if (sEl) sEl.textContent = ''; return; }
const sols = _trigSolveAngles(trigEqFn, a);
trigSim.setEquation(trigEqFn, a, sols);
const K = window.katex;
const tex = l => (K ? K.renderToString(l, { throwOnError: false, strict: false, displayMode: false }) : l);
const eqHead = tex(`${fnTex} x = ${_latexVal(a)}`);
const f = _trigEqFormulaLatex(trigEqFn, a);
if (fEl) {
fEl.innerHTML = `<div style="margin-bottom:5px;color:var(--violet)">${eqHead}</div>` +
(f.none ? '<div style="color:#EF476F">Нет решений (|a| > 1)</div>' : `<div>${tex(f.latex)}</div>`);
}
if (sEl) sEl.textContent = sols.length
? 'На [0, 2π): ' + sols.map(x => Math.round(x * 180 / Math.PI) + '°').join(', ')
: '';
}
function trigClearEq() {
if (!trigSim) return;
trigSim.clearEquation();
const fEl = document.getElementById('trig-eq-formula'); if (fEl) fEl.innerHTML = '';
const sEl = document.getElementById('trig-eq-sols'); if (sEl) sEl.textContent = '';
}
function trigEqKey(e) { if (e && (e.key === 'Enter' || e.keyCode === 13)) trigSolve(); }
/* ── Таблица значений (первая четверть) — строится один раз, KaTeX ── */
function _trigBuildValueTable() {
const el = document.getElementById('trig-table');
if (!el || el.dataset.built) return;
const cols = [['sin', '#EF476F'], ['cos', '#06D6E0'], ['tg', '#FFD166'], ['ctg', '#7BF5A4']];
const head = '<tr><th style="text-align:left;padding:2px 4px;color:var(--text-3);font-weight:700">α</th>' +
cols.map(([n, c]) => `<th style="padding:2px 4px;color:${c};font-weight:700">${n}</th>`).join('') + '</tr>';
const body = [0, 30, 45, 60, 90].map(deg => {
const a = deg * Math.PI / 180, sn = Math.sin(a), cs = Math.cos(a);
const tn = Math.abs(cs) > 1e-9 ? sn / cs : undefined;
const ct = Math.abs(sn) > 1e-9 ? cs / sn : undefined;
const cell = v => `<td style="padding:3px 4px;text-align:center">${_tex(_latexVal(v))}</td>`;
return `<tr data-deg="${deg}"><td style="padding:3px 4px;font-weight:700">${deg}°</td>${cell(sn)}${cell(cs)}${cell(tn)}${cell(ct)}</tr>`;
}).join('');
el.innerHTML = `<table style="width:100%;border-collapse:collapse;font-size:0.74rem">${head}${body}</table>`;
el.dataset.built = '1';
}
function trigToggleTable(rowEl) {
const on = rowEl.classList.toggle('active');
const el = document.getElementById('trig-table');
if (!el) return;
if (on) { _trigBuildValueTable(); el.style.display = ''; if (trigSim) _trigUpdateUI(trigSim.stats()); }
else el.style.display = 'none';
}
/* ── Чётность/нечётность + периоды (статический KaTeX-блок, строится один раз) ── */
function trigToggleParity(rowEl) {
if (!trigSim) return;
const on = rowEl.classList.toggle('active');
trigSim.showParity = on;
trigSim.draw();
const pEl = document.getElementById('trig-parity');
if (!pEl) return;
pEl.style.display = on ? '' : 'none';
if (on && !pEl.dataset.built) {
pEl.innerHTML =
`<div>${_tex('\\sin(-\\alpha) = -\\sin\\alpha')}</div>` +
`<div>${_tex('\\cos(-\\alpha) = \\cos\\alpha')}</div>` +
`<div>${_tex('\\operatorname{tg}(-\\alpha) = -\\operatorname{tg}\\alpha')}</div>` +
`<div style="margin-top:6px;color:var(--text-3);font-size:0.7rem">${_tex('T_{\\sin}=T_{\\cos}=2\\pi,\\quad T_{\\operatorname{tg}}=T_{\\operatorname{ctg}}=\\pi')}</div>`;
pEl.dataset.built = '1';
}
}
function _trigUpdateUI(s) { function _trigUpdateUI(s) {
const _f = v => { const _f = v => {
if (v === undefined) return '—'; if (v === undefined) return '—';
@@ -1044,25 +1301,119 @@ if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
}; };
const degStr = s.deg.toFixed(1) + '°'; const degStr = s.deg.toFixed(1) + '°';
// Panel values (nice fractions) // Значения — KaTeX для дробей/корней, текст для простых чисел (быстро при перетаскивании).
document.getElementById('trig-v-sin').textContent = _f(s.sin); const setMathVal = (id, v) => {
document.getElementById('trig-v-cos').textContent = _f(s.cos); const el = document.getElementById(id); if (!el) return;
document.getElementById('trig-v-tan').textContent = _f(s.tan); const lx = _latexVal(v);
document.getElementById('trig-v-cot').textContent = _f(s.cot); if (/\\tfrac|\\sqrt|\\text/.test(lx)) el.innerHTML = _tex(lx);
else el.textContent = lx;
};
setMathVal('trig-v-sin', s.sin);
setMathVal('trig-v-cos', s.cos);
setMathVal('trig-v-tan', s.tan);
setMathVal('trig-v-cot', s.cot);
// Angle badge // Угол: KaTeX (град = π-доля) + радианы + котерминальные (+360°·k)
const al = _angleLatex(s.angle);
const head = al ? `${Math.round(s.deg)}^\\circ = ${al}` : `${degStr}`;
document.getElementById('trig-angle-badge').innerHTML = document.getElementById('trig-angle-badge').innerHTML =
`${degStr} = ${s.radLabel}<br><span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>`; `<div>${_tex(head)}</div>` +
`<span style="font-size:0.72rem;opacity:0.6">${s.angle.toFixed(4)} рад</span>` +
`<br><span style="font-size:0.68rem;opacity:0.5">+ 360°·k (котерминальные)</span>`;
// Stats bar (nice fractions) // Опорный (острый) угол — guarded (панель может не иметь элемента)
const refEl = document.getElementById('trig-ref');
if (refEl) refEl.textContent = (Math.round(s.refDeg * 10) / 10) + '°';
// Знаки функций в текущей четверти
const signsEl = document.getElementById('trig-signs');
if (signsEl) {
const sg = v => (v > 1e-9 ? '+' : v < -1e-9 ? '' : '0');
signsEl.innerHTML =
`<b style="color:#EF476F">sin ${sg(s.sin)}</b> · <b style="color:#06D6E0">cos ${sg(s.cos)}</b> · ` +
`<b style="color:#FFD166">tg ${s.tan === undefined ? '—' : sg(s.tan)}</b>`;
}
// Точные значения + формула приведения (только для табличных углов)
const fEl = document.getElementById('trig-formula');
if (fEl) {
const beta = Math.round(s.refDeg);
const degR = Math.round(s.deg);
const isTable = [0, 30, 45, 60, 90].some(b => Math.abs(s.refDeg - b) < 0.5);
if (!isTable) {
fEl.innerHTML = '<span style="color:var(--text-3);font-size:0.72rem;line-height:1.5">Нетабличный угол — точных значений нет, см. приближённые выше.</span>';
} else {
const reduce = (s.quadrant !== 1) && (beta === 30 || beta === 45 || beta === 60);
const K = window.katex;
const tex = latex => (K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex);
const FN = { sin: '\\sin', cos: '\\cos', tg: '\\operatorname{tg}', ctg: '\\operatorname{ctg}' };
let html = '';
if (reduce) {
const wrap = s.quadrant === 2 ? `180^\\circ - ${beta}^\\circ`
: s.quadrant === 3 ? `180^\\circ + ${beta}^\\circ`
: `360^\\circ - ${beta}^\\circ`;
html += `<div style="color:var(--violet);margin-bottom:6px">${tex(`${degR}^\\circ = ${wrap}`)}</div>`;
}
const line = (nm, color, val) => {
const sgn = (val !== undefined && val < -1e-9) ? '-' : '';
const mid = reduce ? ` = ${sgn}${FN[nm]}\\,${beta}^\\circ` : '';
// KaTeX наследует CSS-цвет родителя → красим div, формулу не трогаем.
return `<div style="color:${color};line-height:1.95">${tex(`${FN[nm]}\\,${degR}^\\circ${mid} = ${_latexVal(val)}`)}</div>`;
};
fEl.innerHTML = html + line('sin', '#EF476F', s.sin) + line('cos', '#06D6E0', s.cos) +
line('tg', '#FFD166', s.tan) + line('ctg', '#7BF5A4', s.cot);
}
}
// Подсветка строки таблицы значений (по опорному острому углу)
const tbl = document.getElementById('trig-table');
if (tbl && tbl.dataset.built && typeof tbl.querySelectorAll === 'function') {
const beta = Math.round(s.refDeg);
tbl.querySelectorAll('tr[data-deg]').forEach(tr => {
tr.style.background = (Number(tr.dataset.deg) === beta) ? 'rgba(155,93,229,0.18)' : '';
});
}
// Stats bar — значения тоже KaTeX (дроби/корни)
document.getElementById('trigbar-angle').textContent = degStr; document.getElementById('trigbar-angle').textContent = degStr;
document.getElementById('trigbar-sin').textContent = _f(s.sin); setMathVal('trigbar-sin', s.sin);
document.getElementById('trigbar-cos').textContent = _f(s.cos); setMathVal('trigbar-cos', s.cos);
document.getElementById('trigbar-tan').textContent = _f(s.tan); setMathVal('trigbar-tan', s.tan);
document.getElementById('trigbar-cot').textContent = _f(s.cot); setMathVal('trigbar-cot', s.cot);
document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1]; document.getElementById('trigbar-quad').textContent = ['I', 'II', 'III', 'IV'][s.quadrant - 1];
} }
/* Точное значение → LaTeX (зеркалит _f, но для KaTeX). undefined → «—». */
function _latexVal(v) {
if (v === undefined) return '\\text{не опр.}';
const a = Math.abs(v), sg = v < -1e-9 ? '-' : '';
if (a < 5e-4) return '0';
if (Math.abs(a - 0.5) < 1e-3) return sg + '\\tfrac{1}{2}';
if (Math.abs(a - Math.SQRT2 / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{2}}{2}';
if (Math.abs(a - Math.sqrt(3) / 2) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{2}';
if (Math.abs(a - Math.sqrt(3) / 3) < 1e-3) return sg + '\\tfrac{\\sqrt{3}}{3}';
if (Math.abs(a - 1) < 1e-3) return sg + '1';
if (Math.abs(a - Math.sqrt(3)) < 1e-3) return sg + '\\sqrt{3}';
return v.toFixed(3);
}
/* Рендер LaTeX → HTML через KaTeX (с фолбэком на сырой LaTeX, если katex ещё не готов). */
function _tex(latex) {
const K = window.katex;
return K ? K.renderToString(latex, { throwOnError: false, strict: false, displayMode: false }) : latex;
}
/* Юникод-метка π-доли ('7π/6','π/4','π','0') → LaTeX. */
function _piLabelToLatex(l) {
if (l === '0') return '0';
const conv = s => s.replace('π', '\\pi');
if (l.indexOf('/') >= 0) { const p = l.split('/'); return `\\tfrac{${conv(p[0])}}{${p[1]}}`; }
return conv(l);
}
/* Радиан текущего угла → LaTeX красивой π-доли по таблице 16 углов (или null). */
function _angleLatex(rad) {
for (const n of _TC_NOTABLE) if (Math.abs(rad - n.a) < 1e-6) return _piLabelToLatex(n.l);
return null;
}
/* ── KaTeX live preview ── */ /* ── KaTeX live preview ── */
/** Convert user ascii expression <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> LaTeX string for KaTeX preview */ /** Convert user ascii expression <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> LaTeX string for KaTeX preview */
+691
View File
@@ -0,0 +1,691 @@
'use strict';
/*
TrainerEngine ядро ИИ-тренажёра (Фаза 0, прототип).
Идея (гибрид): задачи рождаются из ДАННЫХ «генераторов», а математика
считается ДЕТЕРМИНИРОВАННО через SimExpr (тот же безопасный вычислитель, что
у конструктора симуляций; без eval/new Function). LLM в этом ядре НЕ
участвует: его роль один раз сочинить генераторы (Уровень 0) либо позже
отдавать текстовые задачи, которые ЭТОТ ЖЕ слой верифицирует подстановкой
(Уровень 1). Любой источник задачи проходит один и тот же verifyRoot.
Генератор (данные):
{
id, skill, title,
pick: { a:[lo,hi], ... }, // целые параметры из диапазонов
constraint?: "c < a", // булево над pick (SimExpr) — иначе пересэмпл
derive?: { c: "a*root + b" }, // доп. параметры последовательно (SimExpr)
require?: "...", // булево после derive — иначе пересэмпл
lhs, rhs, // СТОРОНЫ уравнения как выражения с {param} и x
display?, // как показать (по умолч. "lhs = rhs")
answerVar?: "x", // имя неизвестной (деф. x)
answer: "root", // корень как формула над параметрами
integerAnswer?: true, // требовать целый корень
solution?: ["шаг … {ans}", ] // шаблоны шагов (доступен {ans})
}
Гарантия КОРРЕКТНОСТИ: после материализации движок ПОДСТАВЛЯЕТ заявленный
корень в уравнение (verifyRoot). Не сходится экземпляр отбрасывается (в
strict-режиме исключение). Та же подстановка проверяет ответ ученика
(checkStudentAnswer) и автоматически принимает эквивалентные формы
(5, 5.0, 10/2, "x=15/3", "2+3").
API (window.TrainerEngine):
instantiate(gen, opts) -> problem | null
generateBatch(gen, n, opts) -> problem[]
verifyRoot(problem, value) -> { ok, residual, lhs, rhs }
checkStudentAnswer(problem, input)-> { ok, value, residual, message, reason? }
makeRng(seed) -> () => [0,1) (детерминизм для тестов/пула)
problem:
{ genId, skill, title, lhsExpr, rhsExpr, display, answerVar, answer,
params, solution }
*/
(function (global) {
function SE() {
var s = global.SimExpr;
if (!s) throw new Error('TrainerEngine требует SimExpr (подключите _sim_expr.js раньше).');
return s;
}
// Допуск подстановки: масштабируется величиной сторон, чтобы крупные
// коэффициенты не давали ложного «не сходится» из-за плавающей арифметики.
var EPS = 1e-7;
/* Детерминированный ГПСЧ (mulberry32) тот же, что в game/map.js
Нужен, чтобы предгенерация пула и тесты были воспроизводимы. В рантайме
можно не передавать seed (тогда берётся внутренний инкремент от Date нельзя
поэтому дефолт фиксирован, а вариативность даёт сам диапазон pick). */
function makeRng(seed) {
var s = (seed >>> 0) || 1;
return function () {
s |= 0; s = (s + 0x6D2B79F5) | 0;
var t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function randInt(rng, lo, hi) { return lo + Math.floor(rng() * (hi - lo + 1)); }
/* Разложение числа на простые множители как строка «2*2*3» (без степеней, по
возрастанию). Для отображения в шагах решения (НОД/НОК и т.п.). */
function primeFactorString(n) {
n = Math.abs(Math.round(n));
if (!isFinite(n) || n < 2) return String(n || 0);
var fs = [], d = 2;
while (d * d <= n) { while (n % d === 0) { fs.push(d); n = n / d; } d++; }
if (n > 1) fs.push(n);
return fs.join('*');
}
/* Уровни сложности: масштабирование диапазона pick
level 2 базовый (как задано); 1 легче (меньше магнитуды, меньше
отрицательных); 3 сложнее (шире магнитуды). Универсально для всех
генераторов; корректность держит «корень-вперёд» + самопроверка. */
function _scaleRange(r, level) {
var lo = r[0], hi = r[1];
if (!level || level === 2) return [lo, hi];
if (level === 1) {
var nlo = lo < 0 ? Math.ceil(lo / 2) : lo;
var nhi = hi > 0 ? Math.max(nlo + 1, Math.round(hi / 2)) : hi;
return [nlo, nhi];
}
var elo = lo < 0 ? Math.floor(lo * 1.8) : lo;
var ehi = Math.round(hi * 1.8);
if (ehi <= elo) ehi = elo + 1;
return [elo, ehi];
}
/* ── Кэш компиляции выражений (рендеренные строки часто повторяются) ── */
var _cache = Object.create(null);
function compileExpr(src) {
var key = String(src);
var c = _cache[key];
if (!c) { c = SE().compile(key); _cache[key] = c; }
return c;
}
function evalExpr(src, env) { return compileExpr(src).fn(env); }
function truthy(v) { return typeof v === 'number' && isFinite(v) && v !== 0; }
function isIntApprox(v) { return isFinite(v) && Math.abs(v - Math.round(v)) < 1e-9; }
function fmtNum(v) {
if (typeof v !== 'number') return String(v);
if (isIntApprox(v)) return String(Math.round(v));
return String(Math.round(v * 1e6) / 1e6);
}
/* Подстановка {name} -> значение (для выражений и подписей). */
function render(tpl, vals) {
return String(tpl).replace(/\{(\w+)\}/g, function (m, k) {
return Object.prototype.hasOwnProperty.call(vals, k) ? fmtNum(vals[k]) : m;
});
}
/* Лёгкая косметика ТОЛЬКО для показа (не для вычислений):
5*x -> 5x, «+ -» -> « », ведущий коэффициент 1 у x убираем. */
function prettyMath(s) {
return String(s)
.replace(/(\d)\s*\*\s*(\d)/g, '$1·$2') // 4*5 -> 4·5 (число·число)
.replace(/\s*\*\s*/g, '') // 7*x -> 7x (неявное умножение)
.replace(/\+\s*-\s*/g, ' ') // + -3 -> 3
.replace(/-\s*-\s*/g, '+ ')
.replace(/(^|[(=+\-\s])1(?=x)/g, '$1'); // ведущий 1·x -> x
}
function assign(base, extra) {
var o = {}, k;
for (k in base) if (Object.prototype.hasOwnProperty.call(base, k)) o[k] = base[k];
for (k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) o[k] = extra[k];
return o;
}
/* Выражение -> LaTeX (через AST SimExpr) для KaTeX-рендера
Возвращает строку LaTeX или null, если выражение не разобралось. Покрывает
наши нужды: дроби (\frac), степени, неявное умножение, скобки по приоритету,
сравнения (= ), sqrt/abs/тригонометрию. Один проход AST, без eval.
Reusable: тем же конвертером можно рендерить и задачи Уровня-1 (LLM). */
function _prec(n) {
if (!n) return 9;
if (n.k === 'cmp' || n.k === 'logic') return 0;
if (n.k === 'bin') {
if (n.op === '+' || n.op === '-') return 1;
if (n.op === '*' || n.op === '/' || n.op === '%') return 2;
if (n.op === '^') return 4;
}
if (n.k === 'un' || n.k === 'not') return 3;
return 5;
}
function _isNeg(n) {
return (n.k === 'num' && n.v < 0) || (n.k === 'un' && n.op === '-') ||
(n.k === 'bin' && n.op === '*' && _isNeg(n.a)); // (-5)*x — отрицательное слагаемое
}
function _negate(n) {
if (n.k === 'num') return { k: 'num', v: -n.v };
if (n.k === 'un' && n.op === '-') return n.a;
if (n.k === 'bin' && n.op === '*') return { k: 'bin', op: '*', a: _negate(n.a), b: n.b };
return { k: 'un', op: '-', a: n };
}
function _wrapL(node, minPrec) {
var s = _latex(node);
return _prec(node) < minPrec ? '\\left(' + s + '\\right)' : s;
}
// «числовой множитель»: число, константа или степень с числовым основанием (2, 7^2).
// Между двумя такими ставим ·, иначе адъяцентность «2·7²» прочитается как «27²».
function _isNumFactor(n) {
return n.k === 'num' || n.k === 'const' || (n.k === 'bin' && n.op === '^' && _isNumFactor(n.a));
}
// Операнд умножения: отрицательное/унарное/сумму берём в скобки, иначе
// соседство схлопнет смысл (7*(-5) -> «7-5», 6*(x+1) -> «6x+1»).
function _mulOperand(node) {
if (_isNeg(node) || _prec(node) < 2) return '\\left(' + _latex(node) + '\\right)';
return _latex(node);
}
function _latex(node) {
switch (node.k) {
case 'num': return fmtNum(node.v);
case 'const':
if (node.v === Math.PI) return '\\pi';
if (node.v === Math.PI * 2) return '\\tau';
if (node.v === Math.E) return 'e';
return fmtNum(node.v);
case 'var': return node.name;
case 'un': return '-' + _wrapL(node.a, 3);
case 'not': return '\\lnot ' + _wrapL(node.a, 3);
case 'cmp': {
var m = { '==': '=', '!=': '\\ne', '<': '<', '<=': '\\le', '>': '>', '>=': '\\ge' };
return _latex(node.a) + ' ' + (m[node.op] || node.op) + ' ' + _latex(node.b);
}
case 'logic':
return _latex(node.a) + (node.op === '&&' ? ' \\land ' : ' \\lor ') + _latex(node.b);
case 'cond':
return _wrapL(node.c, 1) + ' \\,?\\, ' + _latex(node.a) + ' : ' + _latex(node.b);
case 'call': {
if (node.name === 'sqrt') return '\\sqrt{' + _latex(node.args[0]) + '}';
if (node.name === 'abs') return '\\left|' + _latex(node.args[0]) + '\\right|';
var TRIG = { sin: '\\sin', cos: '\\cos', tan: '\\tan', tg: '\\tan', ln: '\\ln', log: '\\log', exp: '\\exp' };
var fn = TRIG[node.name] || ('\\operatorname{' + node.name + '}');
return fn + '\\left(' + node.args.map(_latex).join(',\\, ') + '\\right)';
}
case 'bin': {
var op = node.op;
if (op === '/') return '\\frac{' + _latex(node.a) + '}{' + _latex(node.b) + '}';
if (op === '^') {
var base = _prec(node.a) < 5 ? '\\left(' + _latex(node.a) + '\\right)' : _latex(node.a);
if (node.b.k === 'num' && node.b.v === 1) return base; // x^1 -> x
if (node.b.k === 'num' && node.b.v === 0) return '1'; // x^0 -> 1
return base + '^{' + _latex(node.b) + '}';
}
if (op === '*') {
// единичный коэффициент: 1*x -> x, (-1)*x -> -x (только при не-числовом множителе)
if (node.a.k === 'num' && Math.abs(node.a.v) === 1 && node.b.k !== 'num')
return (node.a.v < 0 ? '-' : '') + _mulOperand(node.b);
if (node.b.k === 'num' && Math.abs(node.b.v) === 1 && node.a.k !== 'num')
return (node.b.v < 0 ? '-' : '') + _mulOperand(node.a);
if (_isNeg(node.a)) return '-' + _latex({ k: 'bin', op: '*', a: _negate(node.a), b: node.b }); // -5*x -> «-5x»
// · между числами и числовыми множителями (2·3, 2·7²); иначе соседство (2x, 3(x+1))
var sep = ((node.b.k === 'num' && node.b.v >= 0) || (_isNumFactor(node.a) && _isNumFactor(node.b))) ? ' \\cdot ' : '';
return _mulOperand(node.a) + sep + _mulOperand(node.b);
}
if (op === '%') return _wrapL(node.a, 2) + ' \\bmod ' + _wrapL(node.b, 3);
// + или - (схлопываем a + (-b) -> a - b и a - (-b) -> a + b)
var right = node.b, rop = op;
if (op === '+' && _isNeg(right)) { rop = '-'; right = _negate(right); }
else if (op === '-' && _isNeg(right)) { rop = '+'; right = _negate(right); }
return _wrapL(node.a, 1) + ' ' + rop + ' ' + _wrapL(right, rop === '-' ? 2 : 1);
}
}
return '';
}
function exprToLatex(src) {
var ast;
try { ast = SE().parse(String(src)); } catch (e) { return null; }
try { return _latex(ast); } catch (e2) { return null; }
}
/* Подстановочная верификация корня
Истинно, если левая и правая части совпадают при answerVar = value. */
function verifyRoot(problem, value) {
var env = {};
env[problem.answerVar || 'x'] = value;
var L = evalExpr(problem.lhsExpr, env);
var R = evalExpr(problem.rhsExpr, env);
var residual = Math.abs(L - R);
var scale = Math.max(1, Math.abs(L), Math.abs(R));
return { ok: residual <= EPS * scale, residual: residual, lhs: L, rhs: R };
}
/* Эквивалентность выражений численным сэмплингом
Истинно, если exprA и exprB совпадают в нескольких точках по переменным vars
(для проверки упрощения/раскрытия: 3x+5x 8x, a(x+b) ax+ab). Точки
фиксированы детерминированно (без Math.random). */
var _EQUIV_PTS = [-3.7, -1.3, 0.5, 2.1, 4.9, -0.9, 3.3, 1.7];
function _sampleEquiv(exprA, exprB, vars) {
var ca = SE().compile(String(exprA)), cb = SE().compile(String(exprB));
if (ca.error || cb.error) return { ok: false, reason: 'parse' };
vars = (vars && vars.length) ? vars : ['x'];
for (var i = 0; i < _EQUIV_PTS.length; i++) {
var env = {};
for (var v = 0; v < vars.length; v++) env[vars[v]] = _EQUIV_PTS[(i + v * 3) % _EQUIV_PTS.length];
var a = ca.fn(env), b = cb.fn(env);
var scale = Math.max(1, Math.abs(a), Math.abs(b));
if (Math.abs(a - b) > 1e-6 * scale) return { ok: false };
}
return { ok: true };
}
/* Материализация одного экземпляра
Возвращает problem или null, если за maxTries не удалось выполнить
ограничения / целочисленность / самопроверку. */
function instantiate(gen, opts) {
opts = opts || {};
var rng = opts.rng || makeRng(opts.seed != null ? opts.seed : 1);
var maxTries = opts.maxTries || 300;
var answerVar = gen.answerVar || 'x';
for (var attempt = 0; attempt < maxTries; attempt++) {
var env = {};
var pk = gen.pick || {}, k;
var lvl = opts.level;
for (k in pk) if (Object.prototype.hasOwnProperty.call(pk, k)) {
var rk = (lvl && !gen.noScale) ? _scaleRange(pk[k], lvl) : pk[k];
env[k] = randInt(rng, rk[0], rk[1]);
}
if (gen.constraint && !truthy(evalExpr(gen.constraint, env))) continue;
if (gen.derive) {
for (k in gen.derive) if (Object.prototype.hasOwnProperty.call(gen.derive, k)) {
env[k] = evalExpr(gen.derive[k], env);
}
}
if (gen.require && !truthy(evalExpr(gen.require, env))) continue;
var kind = gen.kind || 'solve';
// корни: одиночный (answer) или множественный (answers — массив выражений)
var answers = null;
if (Array.isArray(gen.answers)) {
answers = gen.answers.map(function (a) { return evalExpr(a, env); });
if (gen.integerAnswer) answers = answers.map(function (x) { return Math.round(x); });
}
var answer = gen.answer ? evalExpr(gen.answer, env) : (answers ? answers[0] : 0);
if (gen.answer && gen.integerAnswer) {
if (!isIntApprox(answer)) continue;
answer = Math.round(answer);
}
// система уравнений (kind system): набор строк + пара-ответ {x,y,...}
var system = null, pair = null;
if (kind === 'system') {
system = (gen.eqs || []).map(function (e) { return { lhs: render(e.lhs, env), rhs: render(e.rhs, env) }; });
pair = {};
var avs = gen.answerVars || ['x', 'y'];
for (var ai = 0; ai < avs.length; ai++) {
var pv = evalExpr((gen.answers && gen.answers[avs[ai]]) || '0', env);
pair[avs[ai]] = gen.integerAnswer ? Math.round(pv) : pv;
}
answer = pair[avs[0]]; // запасной одиночный ответ
}
var lhsExpr = render(gen.lhs || 'x', env);
var rhsExpr = render(gen.rhs || 'x', env);
var sEnv = assign(env, { ans: answer });
// factorize: добавляет в шаги решения СТРОКУ разложения на простые множители
// (повторяющиеся простые, без степеней: «36» -> «2*2*3*3»). gen.factorize —
// массив { name, of }: name — ключ для {name} в шагах, of — выражение-число.
if (gen.factorize) {
for (var fzi = 0; fzi < gen.factorize.length; fzi++) {
var fz = gen.factorize[fzi];
sEnv[fz.name] = primeFactorString(evalExpr(fz.of, env));
}
}
var answerExpr = gen.answerExpr ? render(gen.answerExpr, env) : null;
var answerRel = (kind === 'inequality') ? { op: gen.relOp || '<', bound: evalExpr(gen.bound, env) } : null;
// latex: уравнение (solve/roots) | выражение (simplify) | неравенство (inequality)
// | null (compute → текстовый prompt из display).
var latex = null;
if (kind === 'solve' || kind === 'roots') {
var ll = exprToLatex(lhsExpr), rl = exprToLatex(rhsExpr);
if (ll != null && rl != null) latex = ll + ' = ' + rl;
} else if (kind === 'simplify' && gen.srcExpr) {
latex = exprToLatex(render(gen.srcExpr, env));
} else if (kind === 'inequality') {
latex = exprToLatex(lhsExpr + ' ' + (gen.dispOp || '<') + ' ' + rhsExpr);
} else if (kind === 'system' && system) {
var rows = [], okrows = true;
for (var si2 = 0; si2 < system.length; si2++) {
var l2 = exprToLatex(system[si2].lhs), r2 = exprToLatex(system[si2].rhs);
if (l2 == null || r2 == null) { okrows = false; break; }
rows.push(l2 + ' = ' + r2);
}
if (okrows) latex = '\\begin{cases} ' + rows.join(' \\\\ ') + ' \\end{cases}';
}
var problem = {
genId: gen.id,
skill: gen.skill || gen.id, // ключ прогресса = id генератора, если skill не задан
title: gen.title,
kind: kind,
figure: gen.figure || null, // спека чертежа (данные) — рисует TrainerFigures по params
lhsExpr: lhsExpr,
rhsExpr: rhsExpr,
display: (kind === 'system' && system)
? system.map(function (e) { return prettyMath(e.lhs + ' = ' + e.rhs); }).join('; ')
: prettyMath(render(gen.display || (gen.lhs + (kind === 'inequality' ? (' ' + (gen.dispOp || '<') + ' ') : ' = ') + gen.rhs), env)),
latex: latex,
answerVar: answerVar,
answer: answer,
answers: answers, // массив корней (kind roots)
answerExpr: answerExpr, // канон. выражение (kind simplify)
answerRel: answerRel, // { op, bound } (kind inequality)
system: system, // [{lhs,rhs},…] (kind system)
pair: pair, // эталонная пара {x,y,…} (kind system)
answerVars: gen.answerVars || [answerVar],
params: env,
// шаг решения -> { note(текст), tex(подпись), latex(для KaTeX, null если не разобрался) }
// строковый шаг (легаси) трактуется как чистая заметка без формулы.
solution: (gen.solution || []).map(function (st) {
if (typeof st === 'string') return { note: render(st, sEnv), tex: '', latex: null };
var texSrc = st.tex ? render(st.tex, sEnv) : '';
return {
note: st.note ? render(st.note, sEnv) : '',
tex: texSrc ? prettyMath(texSrc) : '',
latex: texSrc ? exprToLatex(texSrc) : null
};
})
};
// Самопроверка по типу: simplify → эквивалентность; roots → все корни; иначе → корень.
var okSelf, why;
if (kind === 'simplify') {
okSelf = _sampleEquiv(render(gen.srcExpr || gen.lhs || 'x', env), answerExpr, problem.answerVars).ok;
why = 'упрощение не эквивалентно ответу';
} else if (kind === 'inequality') {
var bnd = answerRel.bound, iop = answerRel.op;
var inside = (iop === '<' || iop === '<=') ? bnd - 1 : bnd + 1;
var outside = (iop === '<' || iop === '<=') ? bnd + 1 : bnd - 1;
okSelf = _origIneqHolds(lhsExpr, rhsExpr, gen.dispOp || '<', answerVar, inside) &&
!_origIneqHolds(lhsExpr, rhsExpr, gen.dispOp || '<', answerVar, outside);
why = 'неравенство не согласовано с ответом';
} else if (kind === 'system') {
okSelf = !!(system && system.length) && system.every(function (e) {
var L = evalExpr(e.lhs, pair), R = evalExpr(e.rhs, pair);
return Math.abs(L - R) <= EPS * Math.max(1, Math.abs(L), Math.abs(R));
});
why = 'пара не удовлетворяет системе';
} else if (answers) {
okSelf = answers.every(function (r) { return verifyRoot(problem, r).ok; });
why = 'не все корни удовлетворяют уравнению';
} else {
var v = verifyRoot(problem, answer);
okSelf = v.ok; why = 'корень ' + fmtNum(answer) + ' не удовлетворяет (невязка ' + v.residual + ')';
}
if (!okSelf) {
if (opts.strict) throw new Error('Генератор «' + gen.id + '»: ' + why + '.');
continue;
}
return problem;
}
return null;
}
/* ── Пакет из n различных по виду задач ── */
function generateBatch(gen, n, opts) {
opts = opts || {};
var rng = opts.rng || makeRng(opts.seed != null ? opts.seed : 1);
var out = [], seen = Object.create(null);
var guard = n * 20 + 50;
while (out.length < n && guard-- > 0) {
var p = instantiate(gen, { rng: rng, strict: opts.strict, maxTries: opts.maxTries, level: opts.level });
if (!p) break;
if (seen[p.display]) continue;
seen[p.display] = 1;
out.push(p);
}
return out;
}
/* Проверка ответа ученика
Принимает строку/число. SimExpr.compile сам срезает ведущее «x=», поэтому
"x = 5", "5", "10/2", "2+3" нормализуются к числу. Верно, если значение
удовлетворяет уравнению (эквивалентные формы проходят) ИЛИ совпадает с
эталонным корнем (страховка единственности для будущих многокорневых типов). */
function checkStudentAnswer(problem, input) {
var raw = String(input == null ? '' : input).trim();
if (!raw) return { ok: false, reason: 'empty', value: null, residual: null, message: 'Введите ответ.' };
if (problem.kind === 'simplify') return _checkEquiv(problem, raw);
if (problem.kind === 'roots') return _checkMultiRoot(problem, raw);
if (problem.kind === 'inequality') return _checkInequality(problem, raw);
if (problem.kind === 'system') return _checkSystem(problem, raw);
var c = SE().compile(raw);
if (c.error) {
return { ok: false, reason: 'parse', value: null, residual: null,
message: 'Не понял ответ: ' + c.error };
}
var val = c.fn({});
if (!isFinite(val)) {
return { ok: false, reason: 'nan', value: val, residual: null, message: 'Это не число.' };
}
var v = verifyRoot(problem, val);
var nearCanonical = Math.abs(val - problem.answer) <= 1e-6 * Math.max(1, Math.abs(problem.answer));
var ok = v.ok || nearCanonical;
return {
ok: ok, reason: ok ? null : 'wrong', value: val, residual: v.residual,
message: ok ? 'Верно!' : 'Пока неверно.'
};
}
/* Разбор типовой ошибки ученика (репетитор, направление C)
По неверному ЧИСЛОВОМУ ответу пытается распознать типовую ошибку и дать
адресную подсказку, НЕ выдавая правильный ответ. Работает для solve/compute.
Для solve уравнение восстанавливается как линейное f(x)=A·x+B по двум точкам
(без структуры генератора) ловим «забыл разделить на коэффициент». Плюс
общие эвристики: перепутан знак, близкая арифметическая ошибка.
Возвращает { type, hint } или null (ошибка не распознана / ответ верный). */
function _linAB(problem) {
var av = problem.answerVar || 'x';
var e0 = {}, e1 = {}; e0[av] = 0; e1[av] = 1;
var g0 = evalExpr(problem.lhsExpr, e0) - evalExpr(problem.rhsExpr, e0);
var g1 = evalExpr(problem.lhsExpr, e1) - evalExpr(problem.rhsExpr, e1);
if (!isFinite(g0) || !isFinite(g1)) return null;
return { A: g1 - g0, B: g0 }; // f(x) = A·x + B, корень = -B/A
}
function analyzeMistake(problem, value) {
if (!problem || !isFinite(value)) return null;
var kind = problem.kind || 'solve';
if (kind !== 'solve' && kind !== 'compute') return null; // пара/корни/неравенство — отдельно
var correct = problem.answer;
var tol = 1e-6 * Math.max(1, Math.abs(correct));
if (Math.abs(value - correct) <= tol) return null; // на самом деле верно
// структурно: линейное уравнение → «забыл разделить на коэффициент»
if (kind === 'solve') {
var ab = _linAB(problem);
if (ab && Math.abs(ab.A) > 1.5) {
var noDivide = -ab.B; // значение на шаге «A·x = -B», ещё не делённое на A (= A·correct)
if (Math.abs(value - noDivide) <= Math.max(tol, 1e-6 * Math.abs(noDivide)))
return { type: 'nodivide', hint: 'Похоже, ты не разделил обе части на коэффициент при переменной (' + fmtNum(ab.A) + '). Раздели — и получишь ответ.' };
}
}
// перепутан знак ответа
if (correct !== 0 && Math.abs(value + correct) <= Math.max(tol, 1e-6 * Math.abs(correct)))
return { type: 'sign', hint: 'Кажется, перепутан знак. Проверь знаки при переносе слагаемых через знак «=».' };
// близкая арифметическая ошибка
if (Math.abs(value - correct) <= Math.max(1, Math.abs(correct) * 0.2))
return { type: 'arith', hint: 'Очень близко — похоже на арифметическую ошибку в вычислениях. Пересчитай аккуратно.' };
return { type: 'generic', hint: 'Разбери решение по шагам и попробуй похожую задачу.' };
}
/* Система: ученик вводит пару «x = 2; y = 3» (или «2; 3»). Проверяем подстановкой в ОБА уравнения.
Метки переменных опциональны; без меток по порядку answerVars. */
function _checkSystem(problem, raw) {
var vars = problem.answerVars || ['x', 'y'];
var parts = raw.split(/[;,]/).map(function (s) { return s.trim(); }).filter(Boolean);
if (parts.length < vars.length) return { ok: false, reason: 'incomplete', message: 'Введите обе переменные, напр. x = 2; y = 3.' };
var vals = {}, pos = [];
for (var i = 0; i < parts.length; i++) {
var m = parts[i].match(/^([a-zA-Z]\w*)\s*=\s*(.+)$/);
var c = SE().compile(m ? m[2] : parts[i]);
if (c.error) return { ok: false, reason: 'parse', message: 'Не понял запись «' + parts[i] + '».' };
var num = c.fn({});
if (!isFinite(num)) return { ok: false, reason: 'nan', message: 'Это не число.' };
if (m) vals[m[1]] = num; else pos.push(num);
}
for (var j = 0; j < vars.length; j++) if (vals[vars[j]] === undefined && pos.length) vals[vars[j]] = pos.shift();
for (var j2 = 0; j2 < vars.length; j2++) if (vals[vars[j2]] === undefined) return { ok: false, reason: 'incomplete', message: 'Укажите ' + vars[j2] + '.' };
var sys = problem.system || [];
for (var e = 0; e < sys.length; e++) {
var L = evalExpr(sys[e].lhs, vals), R = evalExpr(sys[e].rhs, vals);
if (Math.abs(L - R) > EPS * Math.max(1, Math.abs(L), Math.abs(R)))
return { ok: false, reason: 'wrong', value: vals, message: 'Пара не подходит под уравнения системы.' };
}
return { ok: true, reason: null, value: vals, message: 'Верно!' };
}
/* Несколько корней: ученик вводит все через «;»/«,»/пробел; сверяем как мультимножество. */
function _checkMultiRoot(problem, raw) {
var parts = raw.split(/[;,\s]+/).filter(Boolean);
if (!parts.length) return { ok: false, reason: 'empty', message: 'Введите ответ.' };
var vals = [];
for (var i = 0; i < parts.length; i++) {
var c = SE().compile(parts[i]);
if (c.error) return { ok: false, reason: 'parse', message: 'Не понял ответ.' };
var x = c.fn({});
if (!isFinite(x)) return { ok: false, reason: 'nan', message: 'Это не число.' };
vals.push(x);
}
var want = (problem.answers || []).slice();
if (vals.length !== want.length) return { ok: false, reason: 'count', message: 'Укажите все корни через «;».' };
var used = want.map(function () { return false; });
for (var j = 0; j < vals.length; j++) {
var f = -1;
for (var w = 0; w < want.length; w++) {
if (!used[w] && Math.abs(vals[j] - want[w]) <= 1e-6 * Math.max(1, Math.abs(want[w]))) { f = w; break; }
}
if (f < 0) return { ok: false, reason: 'wrong', message: 'Пока неверно.' };
used[f] = true;
}
return { ok: true, reason: null, message: 'Верно!' };
}
/* Упрощение: ответ-выражение проверяем на эквивалентность сэмплингом. */
function _checkEquiv(problem, raw) {
var c = SE().compile(raw);
if (c.error) return { ok: false, reason: 'parse', message: 'Не понял выражение: ' + c.error };
var se = _sampleEquiv(raw, problem.answerExpr, problem.answerVars || ['x']);
return { ok: se.ok, reason: se.ok ? null : (se.reason || 'wrong'), value: raw, message: se.ok ? 'Верно!' : 'Пока неверно.' };
}
/* Неравенства: проверка ответа-отношения «x < c»
Парсим отношение ученика, нормализуем к виду «x op c» (переменная слева;
если справа отношение переворачивается), сравниваем op и границу. */
function _origIneqHolds(lhsExpr, rhsExpr, op, v, xv) {
var env = {}; env[v] = xv;
var L = evalExpr(lhsExpr, env), R = evalExpr(rhsExpr, env);
switch (op) {
case '<': return L < R; case '>': return L > R;
case '<=': return L <= R; case '>=': return L >= R;
}
return false;
}
function _parseRel(raw, v) {
var s = String(raw).replace(/≤/g, '<=').replace(/≥/g, '>=').replace(/\s+/g, '');
var m = s.match(/<=|>=|<|>/);
if (!m) return null;
var op = m[0], left = s.slice(0, m.index), right = s.slice(m.index + op.length);
if (!left || !right) return null;
var cl = SE().compile(left), cr = SE().compile(right);
if (cl.error || cr.error) return null;
var flip = { '<': '>', '>': '<', '<=': '>=', '>=': '<=' };
if (left === v && right !== v && _isConst(cr, v)) { var b = cr.fn({}); return isFinite(b) ? { op: op, bound: b } : null; }
if (right === v && left !== v && _isConst(cl, v)) { var b2 = cl.fn({}); return isFinite(b2) ? { op: flip[op], bound: b2 } : null; }
return null;
}
function _checkInequality(problem, raw) {
var v = problem.answerVar || 'x';
var rel = _parseRel(raw, v);
if (!rel) return { ok: false, reason: 'parse', message: 'Ответ — неравенство, напр. ' + v + ' < 3.' };
var want = problem.answerRel || {};
var ok = rel.op === want.op && Math.abs(rel.bound - want.bound) <= 1e-6 * Math.max(1, Math.abs(want.bound));
return { ok: ok, reason: ok ? null : 'wrong', value: raw, message: ok ? 'Верно!' : 'Пока неверно.' };
}
/* Пошаговое решение (репетитор): проверка одного шага-равенства
Шаг = равносильное уравнение (то же множество корней). Идея без решения
уравнений: уравнение L=R равносильно исходному выполняется во ВСЕХ корнях
и НЕ выполняется в точках-не-корнях (то есть сужает x именно до корней).
Ловит арифметику (не держится в корне), потерю корня и тождество «0=0». */
function _splitEq(s) {
var i = String(s).indexOf('=');
if (i <= 0 || i >= s.length - 1) return null;
if (s.indexOf('=', i + 1) !== -1) return null; // нет цепочек a=b=c и составных ==,<=,>=
return [s.slice(0, i).trim(), s.slice(i + 1).trim()];
}
function _isConst(c, v) {
var e1 = {}, e2 = {}; e1[v] = 1.3; e2[v] = 2.7;
return Math.abs(c.fn(e1) - c.fn(e2)) < 1e-9;
}
function _isVarOnly(s, v) { return String(s).replace(/\s+/g, '') === v; }
function _isSolvedForm(lhs, rhs, v, roots) {
var cl = SE().compile(lhs), cr = SE().compile(rhs);
if (cl.error || cr.error) return false;
var lv = _isVarOnly(lhs, v), rv = _isVarOnly(rhs, v);
if (lv && _isConst(cr, v)) { var a = cr.fn({}); return roots.some(function (r) { return Math.abs(a - r) <= 1e-6; }); }
if (rv && _isConst(cl, v)) { var b = cl.fn({}); return roots.some(function (r) { return Math.abs(b - r) <= 1e-6; }); }
return false;
}
function checkStep(problem, line) {
var raw = String(line == null ? '' : line).trim();
if (!raw) return { ok: false, status: 'empty', message: 'Введите шаг — равенство со знаком «=».' };
var parts = _splitEq(raw);
if (!parts) return { ok: false, status: 'parse', message: 'Шаг — это одно равенство со знаком «=».' };
var cl = SE().compile(parts[0]), cr = SE().compile(parts[1]);
if (cl.error || cr.error) return { ok: false, status: 'parse', message: 'Не понял выражение в шаге.' };
var v = problem.answerVar || 'x';
var roots = (problem.answers && problem.answers.length) ? problem.answers : [problem.answer];
// держится во всех корнях?
for (var i = 0; i < roots.length; i++) {
var env = {}; env[v] = roots[i];
var L = cl.fn(env), R = cr.fn(env);
if (Math.abs(L - R) > 1e-7 * Math.max(1, Math.abs(L), Math.abs(R)))
return { ok: false, status: 'wrong', message: 'Не равносильно: при ' + v + ' = ' + fmtNum(roots[i]) + ' равенство не выполняется.' };
}
// сужает x до корней? (в не-корнях должно НЕ выполняться)
var total = 0, holds = 0;
for (var j = 0; j < _EQUIV_PTS.length; j++) {
var x = _EQUIV_PTS[j];
if (roots.some(function (r) { return Math.abs(x - r) < 1e-6; })) continue;
total++; var e2 = {}; e2[v] = x;
var L2 = cl.fn(e2), R2 = cr.fn(e2);
if (Math.abs(L2 - R2) <= 1e-7 * Math.max(1, Math.abs(L2), Math.abs(R2))) holds++;
}
if (total > 0 && holds === total)
return { ok: false, status: 'identity', message: 'Это тождество — верно при любом ' + v + ' и не приближает к ответу.' };
var done = _isSolvedForm(parts[0], parts[1], v, roots);
return { ok: true, status: done ? 'solved' : 'equivalent', message: done ? 'Готово!' : 'Верный шаг.' };
}
global.TrainerEngine = {
instantiate: instantiate,
generateBatch: generateBatch,
verifyRoot: verifyRoot,
checkStudentAnswer: checkStudentAnswer,
analyzeMistake: analyzeMistake,
checkStep: checkStep,
makeRng: makeRng,
// мелочи наружу для билдера/тестов
render: render,
prettyMath: prettyMath,
exprToLatex: exprToLatex
};
})(typeof window !== 'undefined' ? window : globalThis);
+106
View File
@@ -0,0 +1,106 @@
'use strict';
/*
TrainerAdaptive адаптивный подбор навыка + очередь повторения (Фаза 2).
Чистая логика без DOM/сети (тестируется headless). Решает «что дать дальше»,
ведя ученика от простого к сложному и возвращая то, в чём он ошибался.
Приоритет nextSkill():
1) In-session повтор: навык, который провалили В ЭТОЙ сессии и подошёл срок
(due <= answered). Это лёгкое интервальное повторение внутри сессии.
2) Кросс-сессионный повтор: навык с серверным флагом due (срок Leitner прошёл).
3) Прогрессия: первый по порядку НЕ освоенный навык (simple complex).
4) Удержание: всё освоено навык с наименьшей коробкой (box), затем по порядку.
На каждом шаге избегаем немедленного повтора последнего навыка, если есть выбор.
API (window.TrainerAdaptive):
nextSkill({ ordered, progress, queue, answered, last }) -> skillId | null
onWrong(queue, skill, answered) -> queue' (поставить навык на повтор)
onCorrect(queue, skill) -> queue' (снять навык с повтора)
sessionStats(events) -> { total, correct, accuracy, skills, weak }
*/
(function (global) {
var GAP_BASE = 2; // через сколько задач навык всплывёт после ошибки
var GAP_MAX = 8;
function nextSkill(opts) {
opts = opts || {};
var ordered = opts.ordered || [];
var prog = opts.progress || {};
var queue = opts.queue || [];
var answered = opts.answered || 0;
var last = opts.last || null;
if (!ordered.length) return null;
var ids = ordered.map(function (g) { return g.id; });
function known(id) { return ids.indexOf(id) !== -1; }
function pos(id) { return ids.indexOf(id); }
function notLast(id) { return id !== last; }
// 1) In-session повтор: подошедшие по сроку записи очереди.
var dueQ = queue.filter(function (q) { return q.due <= answered && known(q.skill); })
.sort(function (a, b) { return a.due - b.due; });
var pick1 = dueQ.filter(function (q) { return notLast(q.skill); })[0] || (dueQ.length === 1 ? dueQ[0] : null);
if (pick1) return pick1.skill;
// 2) Кросс-сессионный повтор: серверный due (срок Leitner прошёл).
var overdue = ordered.filter(function (g) { var p = prog[g.id]; return p && p.due && notLast(g.id); });
if (overdue.length) return overdue[0].id;
// 3) Прогрессия: первый по порядку не освоенный.
var prog1 = ordered.filter(function (g) { var p = prog[g.id]; return !(p && p.mastered) && notLast(g.id); });
if (prog1.length) return prog1[0].id;
// 4) Удержание: всё освоено — наименьшая коробка, затем по порядку.
var pool = ordered.filter(function (g) { return notLast(g.id); });
if (!pool.length) pool = ordered.slice();
pool.sort(function (a, b) {
var ba = (prog[a.id] && prog[a.id].box) || 0;
var bb = (prog[b.id] && prog[b.id].box) || 0;
return ba - bb || (pos(a.id) - pos(b.id));
});
return pool.length ? pool[0].id : ids[0];
}
function onWrong(queue, skill, answered) {
queue = queue || [];
var existing = queue.filter(function (q) { return q.skill === skill; })[0];
var gap = existing ? Math.min((existing.gap || GAP_BASE) + 2, GAP_MAX) : GAP_BASE;
var rest = queue.filter(function (q) { return q.skill !== skill; });
rest.push({ skill: skill, due: (answered || 0) + gap, gap: gap });
return rest;
}
function onCorrect(queue, skill) {
return (queue || []).filter(function (q) { return q.skill !== skill; });
}
function sessionStats(events) {
events = events || [];
var total = events.length;
var correct = 0, bySkill = {};
events.forEach(function (e) {
if (e.correct) correct++;
var s = bySkill[e.skill] || (bySkill[e.skill] = { c: 0, n: 0 });
s.n++; if (e.correct) s.c++;
});
var skills = Object.keys(bySkill);
var weak = skills.filter(function (s) { return bySkill[s].c < bySkill[s].n; });
return {
total: total,
correct: correct,
accuracy: total ? Math.round(100 * correct / total) : 0,
skills: skills,
weak: weak
};
}
global.TrainerAdaptive = {
nextSkill: nextSkill,
onWrong: onWrong,
onCorrect: onCorrect,
sessionStats: sessionStats
};
})(typeof window !== 'undefined' ? window : globalThis);
+450
View File
@@ -0,0 +1,450 @@
'use strict';
/*
TrainerFigures чертежи геометрических задач тренажёра. ДАННЫЕ, не код.
Идея (та же модель безопасности, что у SimForge «объекты это данные» и
math6-svg): генератор задачи НЕ содержит SVG/кода он лишь ссылается на
ИМЕНОВАННЫЙ тип фигуры и привязки её размеров к параметрам задачи:
figure: { type:'right-triangle', a:'a', b:'b', c:'c', unknown:'c' }
Здесь 'a'/'b'/'c' имена параметров уже материализованной задачи
(problem.params, числа). Рендерер сам строит SVG ИЗ ЧИСЕЛ без eval/
new Function, без пользовательских строк в разметке (текст-подписи
экранируются). Чертёж ИЛЛЮСТРАЦИЯ рядом с условием: показывает данные
величины и «?» на искомой; математику по-прежнему считает движок (SimExpr).
Цвета подобраны под индиго-сцену героя (белые штрихи на тёмном фоне; при
верном ответе сцена зеленеет, при неверном краснеет, белое читается на всех).
API (window.TrainerFigures):
render(figureSpec, params) -> svgString | null (null, если тип неизвестен)
has(type) -> bool
TYPES -> { type: fn }
Контракт типа: fn(spec, params, U) -> body-строка (внутренности <svg>).
U утилиты (num/lbl/fit/ln/pgon/txt/arc/rightAngle/).
*/
(function (global) {
// ── размер холста и поля под подписи ──
var VB_W = 268, VB_H = 184, MARGIN = 38;
// ── палитра (только в SVG-стоки: stroke/fill — мусор не исполняется) ──
var STROKE = 'rgba(255,255,255,.94)'; // основные линии
var FILLSH = 'rgba(255,255,255,.10)'; // лёгкая заливка фигуры
var DASH = 'rgba(255,255,255,.78)'; // вспомогательные (высоты, диагонали)
var ARC = '#fde68a'; // дуги углов (тёплый, виден на всех фонах)
var UNK = '#fde68a'; // подпись искомой величины «?»
var VERTEX = 'rgba(255,255,255,.96)'; // точки-вершины
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Аккуратное число для подписи (целое без хвоста, иначе до 2 знаков).
function fmt(v) {
if (typeof v !== 'number' || !isFinite(v)) return '';
if (Math.abs(v - Math.round(v)) < 1e-9) return String(Math.round(v));
return String(Math.round(v * 100) / 100);
}
// Разрешение привязки: число → как есть; строка-имя параметра → params[name];
// числовая строка → parseFloat; иначе undefined.
function num(params, ref) {
if (typeof ref === 'number') return isFinite(ref) ? ref : undefined;
if (typeof ref === 'string') {
if (params && Object.prototype.hasOwnProperty.call(params, ref)) {
var v = params[ref];
return (typeof v === 'number' && isFinite(v)) ? v : undefined;
}
var f = parseFloat(ref);
if (isFinite(f)) return f;
}
return undefined;
}
// Подпись величины: «?» (искомая) или число. unknownKey сравнивается с key.
function lbl(val, key, unknownKey) {
if (unknownKey != null && key === unknownKey) return '?';
return fmt(val);
}
// ── геометрия в МАТЕМАТИЧЕСКИХ координатах (y вверх), затем fit→экран ──
function P(x, y) { return { x: x, y: y }; }
function add(a, b) { return P(a.x + b.x, a.y + b.y); }
function sub(a, b) { return P(a.x - b.x, a.y - b.y); }
function mul(a, k) { return P(a.x * k, a.y * k); }
function len(a) { return Math.hypot(a.x, a.y); }
function norm(a) { var l = len(a) || 1; return P(a.x / l, a.y / l); }
function mid(a, b) { return P((a.x + b.x) / 2, (a.y + b.y) / 2); }
function deg2rad(d) { return d * Math.PI / 180; }
// Подгонка набора мат-точек в холст: возвращает f.px(p) → экранная точка (y вниз).
function fit(pts) {
var minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
for (var i = 0; i < pts.length; i++) {
var p = pts[i];
if (p.x < minx) minx = p.x; if (p.x > maxx) maxx = p.x;
if (p.y < miny) miny = p.y; if (p.y > maxy) maxy = p.y;
}
var w = Math.max(1e-6, maxx - minx), h = Math.max(1e-6, maxy - miny);
var availW = VB_W - 2 * MARGIN, availH = VB_H - 2 * MARGIN;
var s = Math.min(availW / w, availH / h);
var drawW = w * s, drawH = h * s;
var ox = (VB_W - drawW) / 2, oy = (VB_H - drawH) / 2;
return {
s: s,
px: function (p) { return P(ox + (p.x - minx) * s, oy + (maxy - p.y) * s); }
};
}
// ── примитивы рисования (принимают ЭКРАННЫЕ точки) ──
function ln(a, b, opt) {
opt = opt || {};
return '<line x1="' + r1(a.x) + '" y1="' + r1(a.y) + '" x2="' + r1(b.x) + '" y2="' + r1(b.y) +
'" stroke="' + (opt.stroke || STROKE) + '" stroke-width="' + (opt.w || 2.4) +
'" stroke-linecap="round"' + (opt.dash ? ' stroke-dasharray="5 5"' : '') + '/>';
}
function pgon(ptsScreen, opt) {
opt = opt || {};
var d = ptsScreen.map(function (p) { return r1(p.x) + ',' + r1(p.y); }).join(' ');
return '<polygon points="' + d + '" fill="' + (opt.fill || FILLSH) + '" stroke="' +
(opt.stroke || STROKE) + '" stroke-width="' + (opt.w || 2.4) + '" stroke-linejoin="round"/>';
}
function dot(p, rr) {
return '<circle cx="' + r1(p.x) + '" cy="' + r1(p.y) + '" r="' + (rr || 2.6) + '" fill="' + VERTEX + '"/>';
}
// Текст с тёмным гало (paint-order) для читаемости на любом фоне сцены.
function txt(p, s, opt) {
opt = opt || {};
var fill = opt.fill || '#fff';
var size = opt.size || 13.5;
var anchor = opt.anchor || 'middle';
var weight = opt.weight || 700;
return '<text x="' + r1(p.x) + '" y="' + r1(p.y) + '" text-anchor="' + anchor +
'" dominant-baseline="middle" font-family="Manrope, system-ui, sans-serif" font-size="' + size +
'" font-weight="' + weight + '" fill="' + fill +
'" style="paint-order:stroke;stroke:rgba(15,23,42,.45);stroke-width:3.2px;stroke-linejoin:round">' +
esc(s) + '</text>';
}
// Подпись величины у середины ребра a-b, отодвинутая НАРУЖУ от точки away.
function edgeLabel(a, b, away, text, opt) {
opt = opt || {};
var m = mid(a, b);
var n = norm(sub(m, away)); // от центра наружу
var off = opt.off || 16;
var pos = P(m.x + n.x * off, m.y + n.y * off);
return txt(pos, text, opt);
}
function r1(n) { return Math.round(n * 10) / 10; }
// Дуга угла в вершине V между лучами на A и B; рисует короткую (внутреннюю) дугу
// радиуса rad экранных px. Возвращает { path, labelPos } (labelPos — для подписи).
function angleArc(Vs, As, Bs, rad) {
var a0 = Math.atan2(As.y - Vs.y, As.x - Vs.x);
var a1 = Math.atan2(Bs.y - Vs.y, Bs.x - Vs.x);
var d = a1 - a0;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
var n = 14, pts = [];
for (var i = 0; i <= n; i++) {
var a = a0 + d * (i / n);
pts.push(P(Vs.x + Math.cos(a) * rad, Vs.y + Math.sin(a) * rad));
}
var path = '<path d="M ' + pts.map(function (p) { return r1(p.x) + ' ' + r1(p.y); }).join(' L ') +
'" fill="none" stroke="' + ARC + '" stroke-width="2.4" stroke-linecap="round"/>';
var amid = a0 + d / 2;
var labelPos = P(Vs.x + Math.cos(amid) * (rad + 14), Vs.y + Math.sin(amid) * (rad + 14));
return { path: path, labelPos: labelPos };
}
// Маркер прямого угла в вершине Vs, стороны к As и Bs (экранные), размер m px.
function rightAngle(Vs, As, Bs, m) {
m = m || 13;
var u = norm(sub(As, Vs)), w = norm(sub(Bs, Vs));
var p1 = add(Vs, mul(u, m)), p3 = add(Vs, mul(w, m)), p2 = add(p1, mul(w, m));
return '<path d="M ' + r1(p1.x) + ' ' + r1(p1.y) + ' L ' + r1(p2.x) + ' ' + r1(p2.y) +
' L ' + r1(p3.x) + ' ' + r1(p3.y) + '" fill="none" stroke="' + STROKE +
'" stroke-width="2"/>';
}
var U = {
num: num, lbl: lbl, fmt: fmt, P: P, add: add, sub: sub, mul: mul, len: len,
norm: norm, mid: mid, deg2rad: deg2rad, fit: fit, ln: ln, pgon: pgon, dot: dot,
txt: txt, edgeLabel: edgeLabel, angleArc: angleArc, rightAngle: rightAngle,
STROKE: STROKE, FILLSH: FILLSH, DASH: DASH, ARC: ARC, UNK: UNK
};
/* ════════════════ ТИПЫ ФИГУР ════════════════ */
var TYPES = {
/* Прямоугольный треугольник (Пифагор).
a вертикальный катет, b горизонтальный катет, c гипотенуза.
unknown {a,b,c} какая величина искомая (рисуется «?»). */
'right-triangle': function (spec, p) {
var a = num(p, spec.a), b = num(p, spec.b), c = num(p, spec.c);
if (!(a > 0) || !(b > 0)) return null;
// мат-координаты: прямой угол в A (0,0), горизонт. катет B(b,0), верт. катет C(0,a)
var A = P(0, 0), B = P(b, 0), C = P(0, a);
var f = fit([A, B, C]);
var As = f.px(A), Bs = f.px(B), Cs = f.px(C);
var body = pgon([As, Bs, Cs]);
body += rightAngle(As, Bs, Cs, 13);
body += dot(As) + dot(Bs) + dot(Cs);
// подписи: горизонт. катет (A-B) ← b; верт. катет (A-C) ← a; гипотенуза (B-C) ← c
body += edgeLabel(As, Bs, Cs, lbl(b, 'b', spec.unknown), unkOpt('b', spec.unknown));
body += edgeLabel(As, Cs, Bs, lbl(a, 'a', spec.unknown), unkOpt('a', spec.unknown));
body += edgeLabel(Bs, Cs, As, (c != null ? lbl(c, 'c', spec.unknown) : '?'), unkOpt('c', spec.unknown));
return body;
},
/* Углы треугольника. angA левый, angB правый базовые углы.
Без ext: вершина (апекс) искомый угол «?».
ext:true внешний угол при правой вершине = angA+angB (рисуется «?»),
апекс получает angB, правый внутренний = 180angAangB. */
'triangle-angles': function (spec, p) {
var ax = num(p, spec.angA), bx = num(p, spec.angB);
if (!(ax > 0) || !(bx > 0)) return null;
var ext = !!spec.ext;
var alpha = ax; // левый внутренний угол
var gamma = ext ? (180 - ax - bx) : bx; // правый внутренний угол
if (!(gamma > 0) || alpha + gamma >= 179.5) return null;
// апекс T из L,R по внутренним углам alpha (слева), gamma (справа)
var L = P(0, 0), R = P(1, 0);
var s = Math.sin(deg2rad(alpha)) / Math.sin(deg2rad(alpha + gamma)); // s = |R→…| масштаб
var T = add(R, mul(P(-Math.cos(deg2rad(gamma)), Math.sin(deg2rad(gamma))), s));
var basePts = [L, R, T];
var E = null;
if (ext) { E = P(R.x + (R.x - L.x) * 0.55, 0); basePts.push(E); } // продолжение базы за R
var f = fit(basePts);
var Ls = f.px(L), Rs = f.px(R), Ts = f.px(T);
var body = pgon([Ls, Rs, Ts]);
if (ext) {
var Es = f.px(E);
body += ln(Rs, Es, { dash: false, w: 2.2 }); // продолжение стороны (базы)
}
body += dot(Ls) + dot(Rs) + dot(Ts);
// дуги+подписи углов
var arcL = angleArc(Ls, Rs, Ts, 22);
body += arcL.path + txt(arcL.labelPos, fmt(ax) + '°', { fill: '#fff', size: 12.5 });
if (ext) {
var arcT = angleArc(Ts, Ls, Rs, 22);
body += arcT.path + txt(arcT.labelPos, fmt(bx) + '°', { fill: '#fff', size: 12.5 });
// внешний угол при R: между продолжением базы (Es) и стороной R→T
var arcE = angleArc(Rs, f.px(E), Ts, 22);
body += arcE.path + txt(arcE.labelPos, '?', { fill: UNK, size: 16, weight: 800 });
} else {
var arcR = angleArc(Rs, Ts, Ls, 22);
body += arcR.path + txt(arcR.labelPos, fmt(bx) + '°', { fill: '#fff', size: 12.5 });
var arcTa = angleArc(Ts, Ls, Rs, 24);
body += arcTa.path + txt(arcTa.labelPos, '?', { fill: UNK, size: 16, weight: 800 });
}
return body;
},
/* Смежные углы: прямая через O, луч вверх под углом ang к правой части.
Известный угол ang (справа от луча) и искомый «?» (слева, = 180ang). */
'adjacent-angles': function (spec, p) {
var ang = num(p, spec.ang);
if (!(ang > 0) || ang >= 180) return null;
var O = P(0, 0), Lp = P(-1, 0), Rp = P(1, 0);
var ray = P(Math.cos(deg2rad(ang)), Math.sin(deg2rad(ang))); // луч вверх
var f = fit([Lp, Rp, O, ray]);
var Os = f.px(O), Ls = f.px(Lp), Rs = f.px(Rp), Rays = f.px(ray);
var body = ln(Ls, Rs, { w: 2.6 }); // прямая
body += ln(Os, Rays, { w: 2.6 }); // луч
body += dot(Os);
var arcR = angleArc(Os, Rs, Rays, 26); // известный угол (справа)
body += arcR.path + txt(arcR.labelPos, fmt(ang) + '°', { fill: '#fff', size: 12.5 });
var arcL = angleArc(Os, Rays, Ls, 26); // искомый (слева)
body += arcL.path + txt(arcL.labelPos, '?', { fill: UNK, size: 16, weight: 800 });
return body;
},
/* Прямоугольник: w (горизонталь), h (вертикаль). Обе стороны подписаны. */
'rectangle': function (spec, p) {
var w = num(p, spec.w), h = num(p, spec.h);
if (!(w > 0) || !(h > 0)) return null;
var A = P(0, 0), B = P(w, 0), C = P(w, h), D = P(0, h);
var f = fit([A, B, C, D]);
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(w / 2, h / 2));
var body = pgon([As, Bs, Cs, Ds]);
body += edgeLabel(As, Bs, cen, fmt(w), {});
body += edgeLabel(Bs, Cs, cen, fmt(h), {});
return body;
},
/* Квадрат со стороной a (подписаны две смежные стороны). */
'square': function (spec, p) {
var a = num(p, spec.a);
if (!(a > 0)) return null;
var A = P(0, 0), B = P(a, 0), C = P(a, a), D = P(0, a);
var f = fit([A, B, C, D]);
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(a / 2, a / 2));
var body = pgon([As, Bs, Cs, Ds]);
body += edgeLabel(As, Bs, cen, fmt(a), {});
body += edgeLabel(As, Ds, cen, fmt(a), {});
return body;
},
/* Треугольник по основанию и высоте: base (горизонт.), height (пунктирная высота). */
'triangle-base-height': function (spec, p) {
var base = num(p, spec.base), h = num(p, spec.height);
if (!(base > 0) || !(h > 0)) return null;
// апекс смещён (не равнобедренный, чтобы высота была наглядна), но проекция внутри основания
var apexX = base * 0.62;
var A = P(0, 0), B = P(base, 0), T = P(apexX, h), F = P(apexX, 0); // F — основание высоты
var f = fit([A, B, T]);
var As = f.px(A), Bs = f.px(B), Ts = f.px(T), Fs = f.px(F), cen = f.px(P(base / 2, h / 3));
var body = pgon([As, Bs, Ts]);
body += ln(Ts, Fs, { dash: true, stroke: DASH, w: 2 }); // высота
body += rightAngle(Fs, Bs, Ts, 11); // прямой угол у основания высоты
body += dot(As) + dot(Bs) + dot(Ts);
body += edgeLabel(As, Bs, Ts, fmt(base), {}); // основание
body += txt(P((Ts.x + Fs.x) / 2 + 14, (Ts.y + Fs.y) / 2), fmt(h), { fill: '#fff', size: 12.5, anchor: 'start' });
return body;
},
/* Трапеция: основания spec.bottom и spec.top (порядок неважен оба основания),
height (пунктирная высота). Длинное основание рисуем СНИЗУ ножка высоты
всегда внутри, подписи = реальные длины. */
'trapezoid': function (spec, p) {
var x1 = num(p, spec.bottom), x2 = num(p, spec.top), h = num(p, spec.height);
if (!(x1 > 0) || !(x2 > 0) || !(h > 0)) return null;
var bot = Math.max(x1, x2), top = Math.min(x1, x2);
var offset = (bot - top) / 2; // верхнее основание по центру
var A = P(0, 0), B = P(bot, 0), C = P(offset + top, h), D = P(offset, h);
var f = fit([A, B, C, D]);
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(bot / 2, h / 2));
var body = pgon([As, Bs, Cs, Ds]);
var Fs = f.px(P(offset, 0)); // ножка высоты на нижнем основании
body += ln(Ds, Fs, { dash: true, stroke: DASH, w: 2 });
body += rightAngle(Fs, Bs, Ds, 10);
body += edgeLabel(As, Bs, cen, fmt(bot), {}); // нижнее основание
body += edgeLabel(Ds, Cs, cen, fmt(top), {}); // верхнее основание
body += txt(P((Ds.x + Fs.x) / 2 - 13, (Ds.y + Fs.y) / 2), fmt(h), { fill: '#fff', size: 12.5, anchor: 'end' });
return body;
},
/* Параллелограмм: base (нижняя сторона), height (пунктирная высота к ней). */
'parallelogram': function (spec, p) {
var base = num(p, spec.base), h = num(p, spec.height);
if (!(base > 0) || !(h > 0)) return null;
var skew = base * 0.32;
var A = P(0, 0), B = P(base, 0), C = P(base + skew, h), D = P(skew, h);
var f = fit([A, B, C, D]);
var As = f.px(A), Bs = f.px(B), Cs = f.px(C), Ds = f.px(D), cen = f.px(P(base / 2 + skew / 2, h / 2));
var body = pgon([As, Bs, Cs, Ds]);
// высота от D перпендикулярно основанию (на проекцию D=skew)
var Fs = f.px(P(skew, 0));
body += ln(Ds, Fs, { dash: true, stroke: DASH, w: 2 });
body += rightAngle(Fs, Bs, Ds, 10);
body += edgeLabel(As, Bs, cen, fmt(base), {});
body += txt(P((Ds.x + Fs.x) / 2 - 13, (Ds.y + Fs.y) / 2), fmt(h), { fill: '#fff', size: 12.5, anchor: 'end' });
return body;
},
/* Ромб по диагоналям d1 (горизонт.), d2 (верт.) — диагонали пунктиром. */
'rhombus': function (spec, p) {
var d1 = num(p, spec.d1), d2 = num(p, spec.d2);
if (!(d1 > 0) || !(d2 > 0)) return null;
var R = P(d1 / 2, 0), L = P(-d1 / 2, 0), Tp = P(0, d2 / 2), Bt = P(0, -d2 / 2);
var f = fit([R, L, Tp, Bt]);
var Rs = f.px(R), Ls = f.px(L), Ts = f.px(Tp), Bs = f.px(Bt), Os = f.px(P(0, 0));
var body = pgon([Rs, Ts, Ls, Bs]);
body += ln(Ls, Rs, { dash: true, stroke: DASH, w: 1.8 }); // горизонтальная диагональ
body += ln(Bs, Ts, { dash: true, stroke: DASH, w: 1.8 }); // вертикальная диагональ
body += dot(Os, 2.2);
body += txt(P((Os.x + Rs.x) / 2, Os.y - 11), fmt(d1), { fill: '#fff', size: 12.5 });
body += txt(P(Os.x + 13, (Os.y + Ts.y) / 2), fmt(d2), { fill: '#fff', size: 12.5, anchor: 'start' });
return body;
},
/* Правильный n-угольник; markAngle:true — отметить один внутренний угол «?». */
'regular-polygon': function (spec, p) {
var n = Math.round(num(p, spec.n));
if (!(n >= 3) || n > 24) return null;
var pts = [];
var start = Math.PI / 2 + (n % 2 === 0 ? Math.PI / n : 0); // плоской стороной вниз
for (var i = 0; i < n; i++) {
var a = start + i * 2 * Math.PI / n;
pts.push(P(Math.cos(a), Math.sin(a)));
}
var f = fit(pts);
var sp = pts.map(function (pt) { return f.px(pt); });
var body = pgon(sp);
for (var j = 0; j < sp.length; j++) body += dot(sp[j], 2.3);
if (spec.markAngle) {
var v = sp[0], prev = sp[(n - 1) % n], next = sp[1];
var arc = angleArc(v, prev, next, 16);
body += arc.path + txt(arc.labelPos, '?', { fill: UNK, size: 15, weight: 800 });
}
return body;
},
/* Две подобные фигуры (треугольники): слева оригинал, справа увеличенный в k раз.
mode:'side' подписаны сходственные стороны (a и «?»);
mode:'perimeter' подписаны периметры (P и «?»). k подписан между ними. */
'two-similar': function (spec, p) {
var k = num(p, spec.k);
if (!(k > 0)) return null;
var mode = spec.mode || 'side';
var known = (mode === 'perimeter') ? num(p, spec.perim) : num(p, spec.side);
var vk = Math.min(1.85, Math.max(1.15, k)); // визуальный масштаб (не буквальный k)
// базовый треугольник (форма), две копии бок о бок
var shape = [P(0, 0), P(1.0, 0), P(0.35, 0.85)];
function place(scale, dx) {
return shape.map(function (pt) { return P(pt.x * scale + dx, pt.y * scale); });
}
var t1 = place(1, 0);
var gap = 0.6;
var t2 = place(vk, 1.0 + gap);
var f = fit(t1.concat(t2));
var s1 = t1.map(function (pt) { return f.px(pt); });
var s2 = t2.map(function (pt) { return f.px(pt); });
var body = pgon(s1) + pgon(s2);
var c1 = f.px(P((t1[0].x + t1[1].x + t1[2].x) / 3, (t1[0].y + t1[1].y + t1[2].y) / 3));
var c2 = f.px(P((t2[0].x + t2[1].x + t2[2].x) / 3, (t2[0].y + t2[1].y + t2[2].y) / 3));
if (mode === 'perimeter') {
body += txt(P(c1.x, c1.y), (known != null ? 'P=' + fmt(known) : 'P'), { fill: '#fff', size: 12.5 });
body += txt(P(c2.x, c2.y), '?', { fill: UNK, size: 16, weight: 800 });
} else {
// подпись нижней (сходственной) стороны каждого треугольника
body += edgeLabel(s1[0], s1[1], c1, (known != null ? fmt(known) : ''), {});
body += edgeLabel(s2[0], s2[1], c2, '?', { fill: UNK, size: 15, weight: 800 });
}
// коэффициент подобия между фигурами
var between = P((c1.x + c2.x) / 2, Math.min(c1.y, c2.y) - 6);
body += txt(between, 'k = ' + fmt(k), { fill: ARC, size: 12.5, weight: 800 });
return body;
}
};
// Опции подписи: искомая величина — амбер/крупнее.
function unkOpt(key, unknownKey) {
if (unknownKey != null && key === unknownKey) return { fill: UNK, size: 16, weight: 800 };
return { fill: '#fff', size: 13 };
}
function render(figureSpec, params) {
if (!figureSpec || typeof figureSpec !== 'object') return null;
var fn = TYPES[figureSpec.type];
if (typeof fn !== 'function') return null;
var body;
try { body = fn(figureSpec, params || {}, U); }
catch (e) { return null; }
if (!body) return null;
return '<svg class="tr-fig-svg" viewBox="0 0 ' + VB_W + ' ' + VB_H +
'" role="img" aria-hidden="true" preserveAspectRatio="xMidYMid meet" ' +
'style="overflow:visible">' + body + '</svg>';
}
global.TrainerFigures = {
render: render,
has: function (type) { return typeof TYPES[type] === 'function'; },
TYPES: TYPES,
_util: U
};
})(typeof window !== 'undefined' ? window : globalThis);
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -585,7 +585,7 @@ let _dashOffset = 0; // animated dash offset for link flow
LS.notif.init(); LS.notif.init();
lucide.createIcons(); lucide.createIcons();
const feats = await LS.loadFeatures(); const feats = await LS.loadFeatures();
if (feats.knowledge_map === false) { window.location.replace('/403'); return; } if (feats.knowledge_map === false && user?.role !== 'admin') { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.(); LS.hideDisabledFeatures?.();
document.querySelector('.sb-toggle')?.addEventListener('click', () => { document.querySelector('.sb-toggle')?.addEventListener('click', () => {
+138 -27
View File
@@ -10,7 +10,12 @@
<div class="fn-row"> <div class="fn-row">
<div class="fn-dot"></div> <div class="fn-dot"></div>
<span class="fn-label">y =</span> <span class="fn-label">y =</span>
<input class="fn-input" id="fn0" placeholder="sin(x)" autocomplete="off" spellcheck="false" oninput="updateFn(0)" /> <div class="fn-field">
<input class="fn-input" id="fn0" placeholder="sin(x)" autocomplete="off" spellcheck="false" oninput="updateFn(0)" />
<div class="fn-math" id="fn0-math" title="Нажми, чтобы изменить"></div>
</div>
<button class="fn-act" id="fn0-eye" type="button" title="Скрыть/показать" onclick="toggleFn(0)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="fn-act" type="button" title="Очистить" onclick="clearFn(0)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div> </div>
<div class="fn-preview" id="fn0-prev"></div> <div class="fn-preview" id="fn0-prev"></div>
<div class="fn-err" id="fn0-err">Синтаксическая ошибка</div> <div class="fn-err" id="fn0-err">Синтаксическая ошибка</div>
@@ -21,7 +26,12 @@
<div class="fn-row"> <div class="fn-row">
<div class="fn-dot"></div> <div class="fn-dot"></div>
<span class="fn-label">y =</span> <span class="fn-label">y =</span>
<input class="fn-input" id="fn1" placeholder="x^2 - 4" autocomplete="off" spellcheck="false" oninput="updateFn(1)" /> <div class="fn-field">
<input class="fn-input" id="fn1" placeholder="x^2 - 4" autocomplete="off" spellcheck="false" oninput="updateFn(1)" />
<div class="fn-math" id="fn1-math" title="Нажми, чтобы изменить"></div>
</div>
<button class="fn-act" id="fn1-eye" type="button" title="Скрыть/показать" onclick="toggleFn(1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="fn-act" type="button" title="Очистить" onclick="clearFn(1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div> </div>
<div class="fn-preview" id="fn1-prev"></div> <div class="fn-preview" id="fn1-prev"></div>
<div class="fn-err" id="fn1-err">Синтаксическая ошибка</div> <div class="fn-err" id="fn1-err">Синтаксическая ошибка</div>
@@ -32,56 +42,77 @@
<div class="fn-row"> <div class="fn-row">
<div class="fn-dot"></div> <div class="fn-dot"></div>
<span class="fn-label">y =</span> <span class="fn-label">y =</span>
<input class="fn-input" id="fn2" placeholder="tg(x)" autocomplete="off" spellcheck="false" oninput="updateFn(2)" /> <div class="fn-field">
<input class="fn-input" id="fn2" placeholder="tg(x)" autocomplete="off" spellcheck="false" oninput="updateFn(2)" />
<div class="fn-math" id="fn2-math" title="Нажми, чтобы изменить"></div>
</div>
<button class="fn-act" id="fn2-eye" type="button" title="Скрыть/показать" onclick="toggleFn(2)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="fn-act" type="button" title="Очистить" onclick="clearFn(2)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div> </div>
<div class="fn-preview" id="fn2-prev"></div> <div class="fn-preview" id="fn2-prev"></div>
<div class="fn-err" id="fn2-err">Синтаксическая ошибка</div> <div class="fn-err" id="fn2-err">Синтаксическая ошибка</div>
</div> </div>
<div style="margin-top:8px"></div> <div style="margin-top:8px"></div>
<div class="gp-section-title">Вставить</div>
<div class="gp-keypad">
<button class="kp-btn" data-ins="^2" data-tex="x^2" onclick="graphInsert(this.dataset.ins)"></button>
<button class="kp-btn" data-ins="^" data-tex="x^n" onclick="graphInsert(this.dataset.ins)">xⁿ</button>
<button class="kp-btn" data-ins="sqrt(|)" data-tex="\sqrt{x}" onclick="graphInsert(this.dataset.ins)"></button>
<button class="kp-btn" data-ins="/" data-tex="\tfrac{a}{b}" onclick="graphInsert(this.dataset.ins)">a/b</button>
<button class="kp-btn" data-ins="abs(|)" data-tex="|x|" onclick="graphInsert(this.dataset.ins)">|x|</button>
<button class="kp-btn" data-ins="pi" data-tex="\pi" onclick="graphInsert(this.dataset.ins)">π</button>
<button class="kp-btn" data-ins="sin(|)" data-tex="\sin" onclick="graphInsert(this.dataset.ins)">sin</button>
<button class="kp-btn" data-ins="cos(|)" data-tex="\cos" onclick="graphInsert(this.dataset.ins)">cos</button>
<button class="kp-btn" data-ins="tg(|)" data-tex="\operatorname{tg}" onclick="graphInsert(this.dataset.ins)">tg</button>
<button class="kp-btn" data-ins="ln(|)" data-tex="\ln" onclick="graphInsert(this.dataset.ins)">ln</button>
<button class="kp-btn" data-ins="exp(|)" data-tex="e^x" onclick="graphInsert(this.dataset.ins)"></button>
<button class="kp-btn" data-ins="(|)" data-tex="(\;)" onclick="graphInsert(this.dataset.ins)">( )</button>
</div>
<div class="gp-section-title">Примеры</div> <div class="gp-section-title">Примеры</div>
<div class="gp-preset-group"> <div class="gp-preset-group">
<div class="gp-preset-label">Линейные / степенные</div> <div class="gp-preset-label">Линейные / степенные</div>
<div class="presets-wrap"> <div class="presets-wrap">
<button class="preset-btn" onclick="applyPreset('2x-1')">2x1</button> <button class="preset-btn" data-tex="2x-1" onclick="applyPreset('2x-1')">2x1</button>
<button class="preset-btn" onclick="applyPreset('x^2')"></button> <button class="preset-btn" data-tex="x^2" onclick="applyPreset('x^2')"></button>
<button class="preset-btn" onclick="applyPreset('x^2-4')">x²−4</button> <button class="preset-btn" data-tex="x^2-4" onclick="applyPreset('x^2-4')">x²−4</button>
<button class="preset-btn" onclick="applyPreset('x^3-3x')">x³−3x</button> <button class="preset-btn" data-tex="x^3-3x" onclick="applyPreset('x^3-3x')">x³−3x</button>
<button class="preset-btn" onclick="applyPreset('x^4-4x^2+3')">x⁴−4x²+3</button> <button class="preset-btn" data-tex="x^4-4x^2+3" onclick="applyPreset('x^4-4x^2+3')">x⁴−4x²+3</button>
</div> </div>
</div> </div>
<div class="gp-preset-group"> <div class="gp-preset-group">
<div class="gp-preset-label">Тригонометрия</div> <div class="gp-preset-label">Тригонометрия</div>
<div class="presets-wrap"> <div class="presets-wrap">
<button class="preset-btn" onclick="applyPreset('sin(x)')">sin x</button> <button class="preset-btn" data-tex="\sin x" onclick="applyPreset('sin(x)')">sin x</button>
<button class="preset-btn" onclick="applyPreset('cos(x)')">cos x</button> <button class="preset-btn" data-tex="\cos x" onclick="applyPreset('cos(x)')">cos x</button>
<button class="preset-btn" onclick="applyPreset('tg(x)')">tg x</button> <button class="preset-btn" data-tex="\operatorname{tg} x" onclick="applyPreset('tg(x)')">tg x</button>
<button class="preset-btn" onclick="applyPreset('sin(2x)')">sin 2x</button> <button class="preset-btn" data-tex="\sin 2x" onclick="applyPreset('sin(2x)')">sin 2x</button>
<button class="preset-btn" onclick="applyPreset('x*sin(x)')">x·sin x</button> <button class="preset-btn" data-tex="x\,\sin x" onclick="applyPreset('x*sin(x)')">x·sin x</button>
<button class="preset-btn" onclick="applyPreset('sin(x)/x')">sin(x)/x</button> <button class="preset-btn" data-tex="\tfrac{\sin x}{x}" onclick="applyPreset('sin(x)/x')">sin(x)/x</button>
</div> </div>
</div> </div>
<div class="gp-preset-group"> <div class="gp-preset-group">
<div class="gp-preset-label">Показательные / логарифмы</div> <div class="gp-preset-label">Показательные / логарифмы</div>
<div class="presets-wrap"> <div class="presets-wrap">
<button class="preset-btn" onclick="applyPreset('exp(x)')"></button> <button class="preset-btn" data-tex="e^x" onclick="applyPreset('exp(x)')"></button>
<button class="preset-btn" onclick="applyPreset('2^x')"></button> <button class="preset-btn" data-tex="2^x" onclick="applyPreset('2^x')"></button>
<button class="preset-btn" onclick="applyPreset('ln(x)')">ln x</button> <button class="preset-btn" data-tex="\ln x" onclick="applyPreset('ln(x)')">ln x</button>
<button class="preset-btn" onclick="applyPreset('log(x)')">log x</button> <button class="preset-btn" data-tex="\log x" onclick="applyPreset('log(x)')">log x</button>
</div> </div>
</div> </div>
<div class="gp-preset-group"> <div class="gp-preset-group">
<div class="gp-preset-label">Прочие</div> <div class="gp-preset-label">Прочие</div>
<div class="presets-wrap"> <div class="presets-wrap">
<button class="preset-btn" onclick="applyPreset('sqrt(x)')">√x</button> <button class="preset-btn" data-tex="\sqrt{x}" onclick="applyPreset('sqrt(x)')">√x</button>
<button class="preset-btn" onclick="applyPreset('1/x')">1/x</button> <button class="preset-btn" data-tex="\tfrac{1}{x}" onclick="applyPreset('1/x')">1/x</button>
<button class="preset-btn" onclick="applyPreset('abs(x)')">|x|</button> <button class="preset-btn" data-tex="|x|" onclick="applyPreset('abs(x)')">|x|</button>
<button class="preset-btn" onclick="applyPreset('floor(x)')">⌊x⌋</button> <button class="preset-btn" data-tex="\lfloor x \rfloor" onclick="applyPreset('floor(x)')">⌊x⌋</button>
<button class="preset-btn" onclick="applyPreset('1/(1+exp(-x))')">σ(x)</button> <button class="preset-btn" data-tex="\sigma(x)" onclick="applyPreset('1/(1+exp(-x))')">σ(x)</button>
</div> </div>
</div> </div>
@@ -96,6 +127,12 @@
<div class="graph-canvas-outer"> <div class="graph-canvas-outer">
<div class="graph-canvas-wrap"> <div class="graph-canvas-wrap">
<canvas id="graph-canvas"></canvas> <canvas id="graph-canvas"></canvas>
<div class="graph-view-ctrls">
<button class="gv-btn" id="graph-pts-btn" type="button" title="Особые точки: нули, пересечения, y-перехват" onclick="toggleGraphPoints()"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="7" r="2"/><circle cx="13" cy="15" r="2"/><circle cx="19" cy="6" r="2"/></svg></button>
<button class="gv-btn" type="button" title="Приблизить" onclick="graphZoom(1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="gv-btn" type="button" title="Отдалить" onclick="graphZoom(-1)"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<button class="gv-btn" type="button" title="Сбросить вид" onclick="graphFit()"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 9 4 4 9 4"/><polyline points="20 9 20 4 15 4"/><polyline points="4 15 4 20 9 20"/><polyline points="20 15 20 20 15 20"/></svg></button>
</div>
</div> </div>
<div class="graph-info-bar" id="graph-info-bar"> <div class="graph-info-bar" id="graph-info-bar">
<div class="info-coord"> <div class="info-coord">
@@ -506,6 +543,16 @@
<!-- left panel --> <!-- left panel -->
<div class="proj-panel" style="width:240px;gap:0"> <div class="proj-panel" style="width:240px;gap:0">
<!-- Angle input -->
<div class="gp-section-title" style="margin-bottom:8px">Угол, °</div>
<div style="display:flex;gap:6px;margin-bottom:14px">
<input id="trig-angle-input" type="number" step="1" placeholder="напр. 150"
onkeydown="trigAngleKey(event,this)"
style="flex:1;min-width:0;padding:7px 10px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
<button class="preset-btn" style="flex-shrink:0;padding:7px 12px" title="Перейти к углу"
onclick="trigSetAngleDeg(document.getElementById('trig-angle-input'))"><svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
</div>
<!-- Function toggles --> <!-- Function toggles -->
<div class="gp-section-title" style="margin-bottom:10px">Отрезки</div> <div class="gp-section-title" style="margin-bottom:10px">Отрезки</div>
<div style="display:flex;flex-direction:column;gap:5px;margin-bottom:14px"> <div style="display:flex;flex-direction:column;gap:5px;margin-bottom:14px">
@@ -535,9 +582,14 @@
</label> </label>
</div> </div>
<!-- Graph function selector --> <!-- Graph (functions) — optional, can be hidden to focus on the circle -->
<div class="gp-section-title" style="margin-bottom:8px">График</div> <label class="tri-layer-row active" style="margin-bottom:8px" onclick="trigToggleGraph(this)">
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px"> <span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">График</span>
<span class="tri-layer-hint" style="color:var(--text-3)">функции</span>
<span class="tri-toggle"></span>
</label>
<div id="trig-graph-fns" style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px">
<button class="trig-fn-btn active" onclick="trigSetGraphFn('sin',this)" style="--fc:#EF476F">sin</button> <button class="trig-fn-btn active" onclick="trigSetGraphFn('sin',this)" style="--fc:#EF476F">sin</button>
<button class="trig-fn-btn" onclick="trigSetGraphFn('cos',this)" style="--fc:#06D6E0">cos</button> <button class="trig-fn-btn" onclick="trigSetGraphFn('cos',this)" style="--fc:#06D6E0">cos</button>
<button class="trig-fn-btn" onclick="trigSetGraphFn('tan',this)" style="--fc:#FFD166">tg</button> <button class="trig-fn-btn" onclick="trigSetGraphFn('tan',this)" style="--fc:#FFD166">tg</button>
@@ -553,6 +605,55 @@
<span class="tri-stat-k" style="color:#7BF5A4">ctg</span><span class="tri-stat-v" id="trig-v-cot"></span> <span class="tri-stat-k" style="color:#7BF5A4">ctg</span><span class="tri-stat-v" id="trig-v-cot"></span>
</div> </div>
<!-- Reference (acute) angle + signs by quadrant -->
<div class="gp-section-title" style="margin-bottom:8px">Опорный угол · знаки</div>
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:0.78rem">
<span style="color:var(--text-3)">острый угол к оси</span>
<span id="trig-ref" style="font-weight:800;color:var(--violet)"></span>
</div>
<div id="trig-signs" style="text-align:center;font-size:0.72rem;color:var(--text-2)"></div>
</div>
<!-- Exact values + reduction formula (table angles) -->
<div class="gp-section-title" style="margin-bottom:8px">Точные значения · приведение</div>
<div id="trig-formula" style="margin-bottom:14px;font-size:0.78rem;color:var(--text);background:rgba(155,93,229,0.06);border:1px solid rgba(155,93,229,0.15);border-radius:10px;padding:9px 11px"></div>
<!-- Equation solver: fn(x) = a -->
<div class="gp-section-title" style="margin-bottom:8px">Уравнение</div>
<div style="display:flex;align-items:center;gap:5px;margin-bottom:6px;flex-wrap:wrap">
<button class="trig-eq-fn trig-fn-btn active" onclick="trigSetEqFn('sin',this)" style="--fc:#EF476F">sin</button>
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('cos',this)" style="--fc:#06D6E0">cos</button>
<button class="trig-eq-fn trig-fn-btn" onclick="trigSetEqFn('tg',this)" style="--fc:#FFD166">tg</button>
<span style="color:var(--text-3);font-size:0.82rem;font-weight:700">x =</span>
<input id="trig-eq-input" type="number" step="0.1" placeholder="a" onkeydown="trigEqKey(event)"
style="width:58px;padding:6px 8px;border:1.5px solid var(--border-h);border-radius:8px;background:#fff;color:var(--text);font-family:'Manrope',sans-serif;font-size:0.82rem;outline:none" />
</div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button class="preset-btn" style="flex:1" onclick="trigSolve()">Решить</button>
<button class="preset-btn" style="flex:1" onclick="trigClearEq()">Сброс</button>
</div>
<div id="trig-eq-formula" style="font-size:0.82rem;color:var(--text);margin-bottom:4px;line-height:1.7"></div>
<div id="trig-eq-sols" style="font-size:0.72rem;color:var(--text-3);margin-bottom:14px"></div>
<!-- Values table (first quadrant), toggle -->
<label class="tri-layer-row" style="margin-bottom:8px" onclick="trigToggleTable(this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">Таблица значений</span>
<span class="tri-layer-hint" style="color:var(--text-3)">090°</span>
<span class="tri-toggle"></span>
</label>
<div id="trig-table" style="display:none;margin-bottom:14px;background:rgba(155,93,229,0.05);border:1px solid rgba(155,93,229,0.13);border-radius:10px;padding:6px 8px;overflow-x:auto"></div>
<!-- Parity (−α) + periods toggle -->
<label class="tri-layer-row" style="margin-bottom:8px" onclick="trigToggleParity(this)">
<span class="tri-dot" style="background:var(--violet);box-shadow:0 0 5px var(--violet)"></span>
<span class="tri-layer-name">Чётность (−α)</span>
<span class="tri-layer-hint" style="color:var(--text-3)">симметрия</span>
<span class="tri-toggle"></span>
</label>
<div id="trig-parity" style="display:none;margin-bottom:14px;font-size:0.82rem;color:var(--text);line-height:1.7;background:rgba(155,93,229,0.05);border:1px solid rgba(155,93,229,0.13);border-radius:10px;padding:8px 11px"></div>
<!-- Notable angles --> <!-- Notable angles -->
<div class="gp-section-title" style="margin-bottom:8px">Табличные углы</div> <div class="gp-section-title" style="margin-bottom:8px">Табличные углы</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:14px"> <div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:14px">
@@ -562,8 +663,16 @@
<button class="preset-btn" onclick="trigGoTo(Math.PI/3)">60°</button> <button class="preset-btn" onclick="trigGoTo(Math.PI/3)">60°</button>
<button class="preset-btn" onclick="trigGoTo(Math.PI/2)">90°</button> <button class="preset-btn" onclick="trigGoTo(Math.PI/2)">90°</button>
<button class="preset-btn" onclick="trigGoTo(2*Math.PI/3)">120°</button> <button class="preset-btn" onclick="trigGoTo(2*Math.PI/3)">120°</button>
<button class="preset-btn" onclick="trigGoTo(3*Math.PI/4)">135°</button>
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/6)">150°</button>
<button class="preset-btn" onclick="trigGoTo(Math.PI)">180°</button> <button class="preset-btn" onclick="trigGoTo(Math.PI)">180°</button>
<button class="preset-btn" onclick="trigGoTo(7*Math.PI/6)">210°</button>
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/4)">225°</button>
<button class="preset-btn" onclick="trigGoTo(4*Math.PI/3)">240°</button>
<button class="preset-btn" onclick="trigGoTo(3*Math.PI/2)">270°</button> <button class="preset-btn" onclick="trigGoTo(3*Math.PI/2)">270°</button>
<button class="preset-btn" onclick="trigGoTo(5*Math.PI/3)">300°</button>
<button class="preset-btn" onclick="trigGoTo(7*Math.PI/4)">315°</button>
<button class="preset-btn" onclick="trigGoTo(11*Math.PI/6)">330°</button>
</div> </div>
<!-- Angle info --> <!-- Angle info -->
@@ -583,8 +692,10 @@
</div><!-- /.proj-panel --> </div><!-- /.proj-panel -->
<!-- canvas --> <!-- canvas -->
<div class="proj-canvas-outer"> <div class="proj-canvas-outer" style="position:relative">
<canvas id="trigcircle-canvas"></canvas> <canvas id="trigcircle-canvas"></canvas>
<!-- KaTeX overlay: подписи значений/координат/угла над canvas -->
<div id="trig-overlay" style="position:absolute;inset:0;pointer-events:none;overflow:hidden;font-size:0.82rem"></div>
</div> </div>
</div><!-- /.sim-body-wrap --> </div><!-- /.sim-body-wrap -->
+44 -3
View File
@@ -48,6 +48,14 @@
.mm-card-body { padding: 12px 14px; border-top: 1px solid var(--border); } .mm-card-body { padding: 12px 14px; border-top: 1px solid var(--border); }
.mm-card-title { font-weight: 700; font-size: 0.86rem; color: var(--text); margin-bottom: 3px; } .mm-card-title { font-weight: 700; font-size: 0.86rem; color: var(--text); margin-bottom: 3px; }
.mm-card-meta { font-size: 0.74rem; color: var(--text-3); } .mm-card-meta { font-size: 0.74rem; color: var(--text-3); }
.mm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.mm-tag { font-size: 0.7rem; font-weight: 600; color: var(--violet); background: rgba(155,93,229,0.10); border: 1px solid rgba(155,93,229,0.22); border-radius: 99px; padding: 2px 9px; cursor: pointer; }
.mm-tag:hover { background: rgba(155,93,229,0.18); }
.mm-tagbar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; }
.mm-tagbar:empty { display: none; }
.mm-tagf { font-size: 0.76rem; font-weight: 600; color: var(--text-2); background: var(--surface); border: 1px solid var(--border); border-radius: 99px; padding: 4px 11px; cursor: pointer; }
.mm-tagf:hover { border-color: rgba(155,93,229,0.4); }
.mm-tagf.active { background: var(--violet); color: #fff; border-color: var(--violet); }
.mm-card-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; } .mm-card-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
.mm-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; font-size: 0.76rem; font-weight: 600; color: var(--text-2); text-decoration: none; transition: border-color .12s, color .12s; } .mm-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; font-size: 0.76rem; font-weight: 600; color: var(--text-2); text-decoration: none; transition: border-color .12s, color .12s; }
.mm-btn:hover { border-color: var(--violet); color: var(--violet); } .mm-btn:hover { border-color: var(--violet); color: var(--violet); }
@@ -110,6 +118,7 @@
<option value="link">Ссылки</option> <option value="link">Ссылки</option>
</select> </select>
</div> </div>
<div class="mm-tagbar" id="mm-tags"></div>
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div> <div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
</div> </div>
</div> </div>
@@ -172,7 +181,30 @@
} }
let _mats = []; let _mats = [];
let _cols = []; let _cols = [];
const _filter = { col: 'all', kind: 'all', q: '' }; const _filter = { col: 'all', kind: 'all', q: '', tag: null };
/* ── Теги (хранятся строкой через запятую в m.tags) ── */
function tagsOf(m) { return String((m && m.tags) || '').split(',').map(t => t.trim()).filter(Boolean); }
function normTags(str) { const seen = new Set(), out = []; String(str || '').split(',').forEach(t => { t = t.trim().slice(0, 40); const k = t.toLowerCase(); if (t && !seen.has(k)) { seen.add(k); out.push(t); } }); return out.slice(0, 12).join(', '); }
function tagsHtml(m) {
const ts = tagsOf(m);
if (!ts.length) return '';
return `<div class="mm-tags">${ts.map(t => `<span class="mm-tag" onclick="event.stopPropagation();setTag('${esc(t).replace(/'/g, "&#39;")}')">#${esc(t)}</span>`).join('')}</div>`;
}
function allTags() {
const set = new Map();
_mats.forEach(m => tagsOf(m).forEach(t => set.set(t.toLowerCase(), t)));
return Array.from(set.values()).sort((a, b) => a.localeCompare(b, 'ru'));
}
function renderTags() {
const bar = document.getElementById('mm-tags');
if (!bar) return;
const tags = allTags();
if (!tags.length) { bar.innerHTML = ''; return; }
let html = `<span class="mm-tagf${_filter.tag ? '' : ' active'}" onclick="setTag(null)">Все теги</span>`;
html += tags.map(t => `<span class="mm-tagf${_filter.tag === t.toLowerCase() ? ' active' : ''}" onclick="setTag('${esc(t).replace(/'/g, "&#39;")}')">#${esc(t)}</span>`).join('');
bar.innerHTML = html;
}
/* ── Move-to-collection select ── */ /* ── Move-to-collection select ── */
function moveSelect(m) { function moveSelect(m) {
@@ -202,6 +234,7 @@
${chip} ${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div> <div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div> <div class="mm-card-meta">${meta}</div>
${tagsHtml(m)}
<div class="mm-card-actions"> <div class="mm-card-actions">
${mv} ${mv}
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button> <button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
@@ -225,6 +258,7 @@
${chip} ${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div> <div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div> <div class="mm-card-meta">${meta}</div>
${tagsHtml(m)}
<div class="mm-card-actions"> <div class="mm-card-actions">
${mv} ${mv}
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a> <a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
@@ -304,6 +338,7 @@
if (_filter.col === 'none' && m.collection_id) return false; if (_filter.col === 'none' && m.collection_id) return false;
if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false; if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false;
if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false; if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false;
if (_filter.tag && !tagsOf(m).some(t => t.toLowerCase() === _filter.tag)) return false;
if (_filter.q) { if (_filter.q) {
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase(); const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
if (!hay.includes(_filter.q)) return false; if (!hay.includes(_filter.q)) return false;
@@ -326,6 +361,7 @@
grid.innerHTML = rows.length grid.innerHTML = rows.length
? rows.map(card).join('') ? rows.map(card).join('')
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`; : `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
renderTags();
lucide.createIcons(); lucide.createIcons();
} }
@@ -345,7 +381,8 @@
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); } function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
function onKind(v) { _filter.kind = v; renderGrid(); } function onKind(v) { _filter.kind = v; renderGrid(); }
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); } function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); }
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch; function setTag(t) { const lt = t ? String(t).toLowerCase() : null; _filter.tag = (_filter.tag === lt) ? null : lt; renderGrid(); }
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch; window.setTag = setTag;
/* ── Material actions ── */ /* ── Material actions ── */
async function moveMaterial(id, cid) { async function moveMaterial(id, cid) {
@@ -366,6 +403,7 @@
const content = `<div style="display:flex;flex-direction:column;gap:10px"> const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" /> <input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea> <textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
<input id="mm-nt-tags" placeholder="Теги через запятую (необязательно)" style="${FLD}" />
</div>`; </div>`;
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [ const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() }, { label: 'Отмена', onClick: () => m.close() },
@@ -374,7 +412,8 @@
const text = m.body.querySelector('#mm-nt-body').value.trim(); const text = m.body.querySelector('#mm-nt-body').value.trim();
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; } if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null; const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col }); m.close(); load(); } const tags = normTags(m.body.querySelector('#mm-nt-tags').value);
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col, tags }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} }, } },
] }); ] });
@@ -388,12 +427,14 @@
const content = `<div style="display:flex;flex-direction:column;gap:10px"> const content = `<div style="display:flex;flex-direction:column;gap:10px">
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" /> <input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''} ${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''}
<input id="mm-ed-tags" value="${esc(tagsOf(mt).join(', '))}" placeholder="Теги через запятую (напр. алгебра, формулы)" style="${FLD}" />
</div>`; </div>`;
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [ const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() }, { label: 'Отмена', onClick: () => m.close() },
{ label: 'Сохранить', primary: true, onClick: async () => { { label: 'Сохранить', primary: true, onClick: async () => {
const data = { title: m.body.querySelector('#mm-ed-title').value.trim() }; const data = { title: m.body.querySelector('#mm-ed-title').value.trim() };
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value; if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
data.tags = normTags(m.body.querySelector('#mm-ed-tags').value);
try { await LS.updateMaterial(id, data); m.close(); load(); } try { await LS.updateMaterial(id, data); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} }, } },
+1 -1
View File
@@ -792,7 +792,7 @@ const XP_MAP = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
async function init() { async function init() {
lucide.createIcons(); lucide.createIcons();
const feats = await LS.loadFeatures().catch(() => ({})); const feats = await LS.loadFeatures().catch(() => ({}));
if (feats.red_book === false) { window.location.replace('/403'); return; } if (feats.red_book === false && LS.getUser()?.role !== 'admin') { window.location.replace('/403'); return; }
LS.hideDisabledFeatures?.(); LS.hideDisabledFeatures?.();
// Auth (sidebar) // Auth (sidebar)
+2 -1
View File
@@ -196,7 +196,8 @@
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; } if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled). // Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
if (LS.loadFeatures) { // Админ имеет доступ всегда (он управляет модулями) — для него гейт не срабатывает.
if (LS.loadFeatures && !ip.isAdmin) {
LS.loadFeatures().then(function (feats) { LS.loadFeatures().then(function (feats) {
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; } if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
}).catch(function () {}); }).catch(function () {});
+16 -5
View File
@@ -1418,7 +1418,7 @@
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Учебники</b> — отключить раздел учебников.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Учебники</b> — отключить раздел учебников.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Флэшкарты, Live-квиз</b> — включить/выключить по необходимости.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Флэшкарты, Live-квиз</b> — включить/выключить по необходимости.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Экзаменационные тесты</b> — модуль с 80 вариантами по математике 9 класса.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Экзаменационные тесты</b> — модуль с 80 вариантами по математике 9 класса.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>И другие</b> — Доска, Классы, Питомец и геймификация, Карта знаний, Красная книга, Кроссворд, Виселица, Коллекция, Квантик-ассистент: каждый модуль скрывается своим флагом.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>И другие</b> — Доска, Классы, Питомец и геймификация, Карта знаний, Красная книга, Кроссворд, Виселица, Коллекция, Live-квиз, Онлайн-урок, Квантик-ассистент, игра «Квантик: Законы Мира», Конструктор симуляций, Путеводитель, трекер пожеланий: каждый модуль скрывается своим флагом. Админ при этом видит и открывает любой модуль, даже выключенный.</div></div>
</div> </div>
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="shield"></i></div><div class="tg-box-body"><div class="tg-box-label">Только для администратора</div>Изменение feature flags и доступа к контенту доступно только пользователям с ролью <b>admin</b>. Учителя видят только то, к чему у их класса есть доступ.</div></div> <div class="tg-note"><div class="tg-box-icon"><i data-lucide="shield"></i></div><div class="tg-box-body"><div class="tg-box-label">Только для администратора</div>Изменение feature flags и доступа к контенту доступно только пользователям с ролью <b>admin</b>. Учителя видят только то, к чему у их класса есть доступ.</div></div>
</div> </div>
@@ -1477,8 +1477,9 @@
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Нажмите кнопку-питомца → откроется окно с полем ввода.</div></div> <div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Нажмите кнопку-питомца → откроется окно с полем ввода.</div></div>
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Задайте вопрос: «объясни теорему Виета», «как создать класс», «реши …». Можно продолжать беседу — Квантик помнит контекст.</div></div> <div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Задайте вопрос: «объясни теорему Виета», «как создать класс», «реши …». Можно продолжать беседу — Квантик помнит контекст.</div></div>
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">В учебнике выделите фрагмент текста — появится кнопка <b>«Объяснить выделенное»</b>.</div></div> <div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">В учебнике выделите фрагмент текста — появится кнопка <b>«Объяснить выделенное»</b>.</div></div>
<div class="tg-step"><div class="tg-step-num">4</div><div class="tg-step-body">Режим <b>«Тест в банк»</b> (виден учителю/админу): напишите тему — Квантик сгенерирует вопросы с вариантами, вы проверите и сохраните прямо в <b>банк вопросов</b> (с выбором предмета и темы). Тема при этом создаётся автоматически.</div></div>
</div> </div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="graduation-cap"></i></div><div class="tg-box-body"><div class="tg-box-label">Для учителей</div>Квантик понимает учительские задачи: попросите составить вопросы по теме, план урока или объяснить, как работает инструмент платформы.</div></div> <div class="tg-tip"><div class="tg-box-icon"><i data-lucide="graduation-cap"></i></div><div class="tg-box-body"><div class="tg-box-label">Для учителей</div>Квантик понимает учительские задачи: попросите составить вопросы по теме, план урока или объяснить, как работает инструмент платформы. Он также знает, на какой странице/уроке находится ученик, и отвечает в этом контексте.</div></div>
</div> </div>
<div class="tg-section" id="s-18-3"> <div class="tg-section" id="s-18-3">
@@ -1573,19 +1574,22 @@
<div class="tg-section" id="s-20-2"> <div class="tg-section" id="s-20-2">
<div class="tg-section-title">20.2 Игры: Кроссворд и Виселица</div> <div class="tg-section-title">20.2 Игры: Кроссворд и Виселица</div>
<p>Учебные игры для закрепления терминов: <b>Кроссворд</b> (<a href="/crossword">/crossword</a>) и <b>Виселица</b> (<a href="/hangman">/hangman</a>). За прохождение начисляется XP (Глава 16). Включаются/отключаются feature-флагами.</p> <p>Учебные игры для закрепления терминов: <b>Кроссворд</b> (<a href="/crossword">/crossword</a>) и <b>Виселица</b> (<a href="/hangman">/hangman</a>). За прохождение начисляется XP (Глава 16). Включаются/отключаются feature-флагами.</p>
<p><b>Квантик: Законы Мира</b> (<a href="/quantik">/quantik</a>) — физическая игра-головоломка: уровни на 2D-механике, звёзды и прогресс по карте-созвездию. Уровни можно создавать в Конструкторе симуляций (Глава 21) и раздавать классу.</p>
</div> </div>
<div class="tg-section" id="s-20-3"> <div class="tg-section" id="s-20-3">
<div class="tg-section-title">20.3 Красная книга и Коллекция</div> <div class="tg-section-title">20.3 Красная книга и Коллекция</div>
<p><b>Красная книга</b> (<a href="/red-book">/red-book</a>) — модуль по экологии и биоразнообразию: виды, биомы, экосистемы и игры-квесты. <b>Коллекция</b> (<a href="/collection">/collection</a>) — собранные учеником материалы и награды.</p> <p><b>Красная книга</b> (<a href="/red-book">/red-book</a>) — модуль по экологии и биоразнообразию: виды, биомы, экосистемы и игры-квесты. <b>Коллекция</b> (<a href="/collection">/collection</a>) — карточки по всем темам предметов: ученик «прокачивает» их уровни (бронза → платина), правильно отвечая на вопросы темы. Новые темы появляются в коллекции автоматически, как только в них есть вопросы.</p>
</div> </div>
<div class="tg-section" id="s-20-4"> <div class="tg-section" id="s-20-4">
<div class="tg-section-title">20.4 Мои материалы, Магазин и Родители</div> <div class="tg-section-title">20.4 Мои материалы, Магазин и Родители</div>
<div class="tg-steps"> <div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Мои материалы</b> (<a href="/my-materials">/my-materials</a>) — ученик сохраняет к себе доску (PNG) и заметки из онлайн-урока; копия остаётся даже после удаления сессии.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Мои материалы</b> (<a href="/my-materials">/my-materials</a>) — ученик сохраняет к себе доску (PNG), вырезки учебника и заметки; копия остаётся даже после удаления сессии. Можно раскладывать по <b>папкам</b> и помечать <b>тегами</b>, искать и фильтровать по ним. Учитель может раздать материал классу.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Магазин наград</b> — за монеты (начисляются вместе с XP) ученик покупает предметы и награды.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Магазин наград</b> — за монеты (начисляются вместе с XP) ученик покупает предметы и награды.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Родительские аккаунты</b> (<a href="/parent">/parent</a>) — родитель привязывается к ученику и видит его прогресс и уведомления.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Родительские аккаунты</b> (<a href="/parent">/parent</a>) — родитель привязывается к ученику и видит его прогресс и уведомления.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Трекер пожеланий</b> (<a href="/wishes">/wishes</a>) — любой пользователь предлагает улучшения платформы; админ их видит и сортирует по статусу. Приватный (автор + админ), а не публичная доска.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Путеводитель</b> (<a href="/sitemap">/sitemap</a>) — карта-обзор всех разделов платформы со ссылками.</div></div>
</div> </div>
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Почти всё</div>Дальше — мощный «Конструктор симуляций» (создание своих интерактивных сцен), а за ним — главы для администраторов (видны только под ролью admin).</div></div> <div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Почти всё</div>Дальше — мощный «Конструктор симуляций» (создание своих интерактивных сцен), а за ним — главы для администраторов (видны только под ролью admin).</div></div>
</div> </div>
@@ -1839,7 +1843,7 @@
</div> </div>
<div class="tg-section" id="s-a3-3"> <div class="tg-section" id="s-a3-3">
<div class="tg-section-title">A3.3 Feature Flags</div> <div class="tg-section-title">A3.3 Feature Flags</div>
<p>Включение/отключение модулей платформы без перезапуска сервера: биохимия, учебники, флэшкарты, доска, live-квиз, экзамен, симуляции, игры (кроссворд, виселица), красная книга, карта знаний, коллекция, питомец и геймификация, Квантик-ассистент.</p> <p>Включение/отключение модулей платформы без перезапуска сервера: биохимия, учебники, флэшкарты, доска, live-квиз, онлайн-урок, экзамен, симуляции и Конструктор симуляций, игры (кроссворд, виселица), игра «Квантик: Законы Мира», красная книга, карта знаний, коллекция, питомец и геймификация, Квантик-ассистент, Путеводитель, трекер пожеланий. Админ видит и открывает любой модуль независимо от флага.</p>
</div> </div>
<div class="tg-chapter-nav"> <div class="tg-chapter-nav">
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-a2')"> <div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-a2')">
@@ -1990,6 +1994,10 @@
<div class="tg-section" id="s-a7-2"> <div class="tg-section" id="s-a7-2">
<div class="tg-section-title">A7.2 Модели и лимиты</div> <div class="tg-section-title">A7.2 Модели и лимиты</div>
<p>Для провайдера Kilo доступен список бесплатных моделей прямо на карточке (переключатель). Кнопка <b>«Загрузить модели провайдера»</b> в форме подтягивает живой список моделей с их лимитами.</p> <p>Для провайдера Kilo доступен список бесплатных моделей прямо на карточке (переключатель). Кнопка <b>«Загрузить модели провайдера»</b> в форме подтягивает живой список моделей с их лимитами.</p>
<div class="tg-steps">
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Сканировать модели</b> — кнопка находит на шлюзе все бесплатные модели, прогоняет каждую тест-запросом на русском и показывает отчёт (новые / исчезнувшие / % русского / скорость); «Применить выбранные» обновляет рабочий список.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Без ключа</b> — шлюз Pollinations работает без API-ключа (бейдж «без ключа»), годится как бесплатный запасной провайдер.</div></div>
</div>
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="gauge"></i></div><div class="tg-box-body"><div class="tg-box-label">Лимиты моделей</div>Под моделью показывается «контекст · ответ до N токенов · бесплатно/платно» — данные тянутся автоматически из API провайдера (работает и для Gemini, и для новых моделей).</div></div> <div class="tg-tip"><div class="tg-box-icon"><i data-lucide="gauge"></i></div><div class="tg-box-body"><div class="tg-box-label">Лимиты моделей</div>Под моделью показывается «контекст · ответ до N токенов · бесплатно/платно» — данные тянутся автоматически из API провайдера (работает и для Gemini, и для новых моделей).</div></div>
</div> </div>
<div class="tg-section" id="s-a7-3"> <div class="tg-section" id="s-a7-3">
@@ -1998,6 +2006,9 @@
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>RAG по учебникам</b> — тумблер «Искать ответы по учебникам» + кнопка «Переиндексировать учебники».</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>RAG по учебникам</b> — тумблер «Искать ответы по учебникам» + кнопка «Переиндексировать учебники».</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Кнопки на экзамене</b> — тумблер показа «Подсказка / Спросить Квантика» на карточках задач (Глава 18).</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Кнопки на экзамене</b> — тумблер показа «Подсказка / Спросить Квантика» на карточках задач (Глава 18).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Статистика</b> — запросы к ИИ / из кэша / FAQ за день и за 30 дней, лайки и дизлайки.</div></div> <div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Статистика</b> — запросы к ИИ / из кэша / FAQ за день и за 30 дней, лайки и дизлайки.</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Сократический режим</b> — тумблер: для учеников Квантик объясняет теорию полно, но конкретные задачи не решает «под ключ» (даёт метод и наводящий шаг). Авто-включается при просьбе «реши за меня».</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Авто-проверка провайдеров</b> — фоновый пинг каждые 15 мин + кнопка «Проверить сейчас»; упавший активный провайдер сам уступает место здоровому (цветной индикатор на карточке).</div></div>
<div class="tg-step"><div class="tg-step-num"></div><div class="tg-step-body"><b>Знания о системе</b> — кнопка «Проиндексировать систему» снимает срез включённых модулей + ваше «Описание системы», чтобы Квантик знал актуальное состояние платформы и не предлагал отключённое. Снимок сам обновляется при смене фича-флагов.</div></div>
</div> </div>
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово!</div>Вы изучили полное руководство — все разделы для учителей и администраторов.</div></div> <div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово!</div>Вы изучили полное руководство — все разделы для учителей и администраторов.</div></div>
</div> </div>
+9
View File
@@ -479,6 +479,15 @@
/* Фаза 5: открыть связанную симуляцию из карточки учебника (не уходя в учебник). */ /* Фаза 5: открыть связанную симуляцию из карточки учебника (не уходя в учебник). */
function openLabSim(simId, ev) { function openLabSim(simId, ev) {
if (ev) ev.stopPropagation(); if (ev) ev.stopPropagation();
// Страховка: если «Лаборатория» отключена — не открываем (кнопка и так скрыта
// kill-switch'ем). Админ имеет доступ всегда (admin-override).
try {
const u = LS.getUser && LS.getUser();
if (!(u && u.role === 'admin')) {
const f = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
if (f && f.lab === false) { if (LS.toast) LS.toast('Лаборатория отключена', 'warn'); return; }
}
} catch (e) { /* нет кэша — открываем как раньше */ }
location.href = '/lab?sim=' + encodeURIComponent(simId); location.href = '/lab?sim=' + encodeURIComponent(simId);
} }
window.openLabSim = openLabSim; window.openLabSim = openLabSim;
+371
View File
@@ -0,0 +1,371 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Конструктор генераторов — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"/>
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
:root {
--ink:#1b1f38; --ink-soft:#5b6378; --ink-faint:#98a1b8;
--g1:#6366f1; --g2:#8b5cf6; --accent-ink:#4338ca; --accent-soft:#eef0ff;
--ok:#10b981; --ok-ink:#047857; --bad:#ef4444;
--sh:0 16px 40px rgba(27,31,56,.09), 0 2px 6px rgba(27,31,56,.04);
--ease:cubic-bezier(.22,.61,.36,1);
}
.sb-content {
background-color:#f5f6fb;
background-image:
radial-gradient(1000px 600px at 86% -10%, rgba(139,92,246,.10), transparent 60%),
radial-gradient(820px 560px at 2% -6%, rgba(99,102,241,.09), transparent 55%),
linear-gradient(rgba(99,102,241,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(99,102,241,.05) 1px, transparent 1px);
background-size:100% 100%,100% 100%,26px 26px,26px 26px; background-attachment:fixed;
}
.gb-wrap { max-width:1080px; margin:0 auto; padding:30px 20px 90px; }
.gb-h1 { font-family:'Manrope',sans-serif; font-weight:800; font-size:clamp(1.5rem,4vw,2rem); letter-spacing:-.02em; color:var(--ink); margin:0 0 4px; }
.gb-sub { color:var(--ink-soft); font-size:.95rem; margin-bottom:22px; }
.gb-grid { display:grid; grid-template-columns:300px 1fr; gap:20px; align-items:start; }
@media (max-width:880px){ .gb-grid { grid-template-columns:1fr; } }
.gb-card { background:#fff; border:1px solid rgba(99,102,241,.1); border-radius:18px; box-shadow:var(--sh); padding:20px; }
.gb-card h2 { font-family:'Manrope',sans-serif; font-size:1rem; font-weight:800; color:var(--ink); margin:0 0 14px; }
.gb-list-item { display:flex; align-items:center; gap:8px; padding:10px 12px; border-radius:12px; border:1px solid rgba(99,102,241,.12); margin-bottom:8px; background:#fbfbff; }
.gb-li-main { flex:1; min-width:0; cursor:pointer; }
.gb-li-title { font-weight:700; color:var(--ink); font-size:.9rem; }
.gb-li-meta { font-size:.74rem; color:var(--ink-faint); }
.gb-li-pub { font-size:.66rem; font-weight:800; text-transform:uppercase; letter-spacing:.04em; padding:2px 7px; border-radius:99px; }
.gb-li-pub.draft { background:rgba(148,163,184,.16); color:#64748b; }
.gb-li-pub.published { background:var(--ok); color:#fff; }
.gb-icon-btn { background:none; border:none; cursor:pointer; color:var(--ink-faint); padding:4px; border-radius:8px; }
.gb-icon-btn:hover { background:rgba(99,102,241,.1); color:var(--accent-ink); }
.gb-icon-btn .ic { width:16px; height:16px; }
.gb-empty { color:var(--ink-faint); font-size:.85rem; text-align:center; padding:14px; }
.gb-field { margin-bottom:14px; }
.gb-field label { display:block; font-size:.82rem; font-weight:700; color:var(--ink-soft); margin-bottom:5px; }
.gb-field input, .gb-field textarea, .gb-field select {
width:100%; font:inherit; padding:9px 12px; border:1px solid rgba(99,102,241,.22); border-radius:10px; outline:none; color:var(--ink); box-sizing:border-box; transition:.15s;
}
.gb-field input:focus, .gb-field textarea:focus, .gb-field select:focus { border-color:var(--g1); box-shadow:0 0 0 3px rgba(99,102,241,.14); }
.gb-field .hint { font-size:.75rem; color:var(--ink-faint); margin-top:4px; font-family:'Cambria Math',serif; }
.gb-row2 { display:flex; gap:12px; flex-wrap:wrap; }
.gb-row2 > * { flex:1; min-width:140px; }
.gb-check { display:flex; align-items:center; gap:8px; font-size:.85rem; font-weight:600; color:var(--ink-soft); }
.gb-check input { width:auto; }
.gb-rows { display:flex; flex-direction:column; gap:7px; }
.gb-rrow { display:flex; gap:7px; align-items:center; }
.gb-rrow input { font:inherit; padding:7px 10px; border:1px solid rgba(99,102,241,.2); border-radius:9px; outline:none; min-width:0; }
.gb-rrow .nm { width:64px; flex:0 0 auto; font-family:'Cambria Math',serif; }
.gb-rrow .num { width:60px; flex:0 0 auto; }
.gb-rrow .grow { flex:1; font-family:'Cambria Math',serif; }
.gb-add { font:inherit; font-size:.82rem; font-weight:700; cursor:pointer; color:var(--accent-ink); background:rgba(99,102,241,.08); border:1px dashed rgba(99,102,241,.3); border-radius:9px; padding:6px 12px; margin-top:7px; }
.gb-add:hover { background:var(--accent-soft); }
.gb-btn { font:inherit; font-weight:700; cursor:pointer; border:none; border-radius:12px; padding:11px 20px; transition:.16s var(--ease); display:inline-flex; align-items:center; gap:7px; }
.gb-btn .ic { width:16px; height:16px; }
.gb-primary { color:#fff; background:linear-gradient(135deg,var(--g1),var(--g2)); box-shadow:0 8px 20px rgba(99,102,241,.3); }
.gb-primary:hover { transform:translateY(-2px); }
.gb-ghost { background:rgba(99,102,241,.08); color:var(--accent-ink); }
.gb-ghost:hover { background:rgba(99,102,241,.16); }
.gb-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:16px; }
.gb-preview { margin-top:18px; padding:18px; border-radius:14px; background:linear-gradient(180deg,#fbfbff,#f4f5fd); border:1px solid rgba(99,102,241,.14); }
.gb-preview h3 { font-size:.74rem; text-transform:uppercase; letter-spacing:.07em; color:var(--accent-ink); font-weight:800; margin:0 0 10px; }
.gb-pv-eq { font-family:'Cambria Math',serif; font-size:1.5rem; color:var(--ink); text-align:center; padding:6px 0 14px; }
.gb-pv-ans { text-align:center; color:var(--ok-ink); font-weight:700; margin-bottom:10px; }
.gb-pv-step { padding:7px 0; border-top:1px dashed rgba(99,102,241,.2); font-size:.9rem; color:#334155; }
.gb-pv-step:first-child { border-top:none; }
.gb-pv-step .stx { font-family:'Cambria Math',serif; display:block; margin-top:3px; }
.gb-err { background:#fee2e2; color:#b91c1c; border-radius:10px; padding:10px 14px; font-size:.86rem; font-weight:600; margin-top:14px; }
.gb-err:empty { display:none; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="gb-wrap">
<h1 class="gb-h1">Конструктор генераторов</h1>
<div class="gb-sub">Создайте параметрический генератор задач: диапазоны → формулы → шаблон → ответ. Сервер проверит, что ответ согласован с условием.</div>
<div class="gb-grid">
<div class="gb-card">
<h2>Мои генераторы</h2>
<div id="gb-list"><div class="gb-empty">Загрузка…</div></div>
<button class="gb-btn gb-ghost" id="gb-new" type="button" style="margin-top:8px;width:100%;justify-content:center">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
Новый генератор
</button>
</div>
<div class="gb-card">
<h2 id="gb-form-title">Новый генератор</h2>
<div class="gb-field"><label>Заголовок</label><input id="f-title" placeholder="напр. Линейное: ax + b = c"/></div>
<div class="gb-row2">
<div class="gb-field"><label>Тема</label><input id="f-topic" placeholder="custom" value="custom"/></div>
<div class="gb-field"><label>Тип</label>
<select id="f-kind">
<option value="solve">Уравнение (solve)</option>
<option value="compute">Вычисление (compute)</option>
</select>
</div>
</div>
<div class="gb-field">
<label>Параметры (диапазоны целых)</label>
<div class="gb-rows" id="f-pick"></div>
<button class="gb-add" id="add-pick" type="button">+ параметр</button>
<div class="hint">имя, от, до — напр. a: 2…9. Зарезервированы: x, e, pi, tau.</div>
</div>
<div class="gb-field">
<label>Производные (формулы от параметров)</label>
<div class="gb-rows" id="f-derive"></div>
<button class="gb-add" id="add-derive" type="button">+ формула</button>
<div class="hint">напр. c = a*root + b. Приём «корень-вперёд»: задайте root и выведите c.</div>
</div>
<div class="gb-row2">
<div class="gb-field"><label>Левая часть</label><input id="f-lhs" placeholder="{a}*x + {b}"/></div>
<div class="gb-field"><label>Правая часть</label><input id="f-rhs" placeholder="{c}"/></div>
</div>
<div class="gb-field"><label>Условие текстом (для «вычисления»)</label><input id="f-display" placeholder="напр. Найдите {p}% от {a}"/></div>
<div class="gb-row2">
<div class="gb-field"><label>Ответ (формула)</label><input id="f-answer" placeholder="root"/></div>
<div class="gb-field"><label>Ограничения (опц.)</label><input id="f-require" placeholder="root != 0"/></div>
</div>
<div class="gb-field"><label class="gb-check"><input type="checkbox" id="f-int" checked/> Ответ — целое число</label></div>
<div class="gb-field">
<label>Шаги решения</label>
<div class="gb-rows" id="f-sol"></div>
<button class="gb-add" id="add-sol" type="button">+ шаг</button>
<div class="hint">пояснение словами + формула шага (одно равенство), напр. x = {cmb} / {a}.</div>
</div>
<div class="gb-actions">
<button class="gb-btn gb-ghost" id="gb-preview" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
Превью
</button>
<button class="gb-btn gb-primary" id="gb-save" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
Сохранить
</button>
<label class="gb-check" style="margin-left:auto"><input type="checkbox" id="f-pub"/> Опубликовать ученикам</label>
</div>
<div class="gb-err" id="gb-err"></div>
<div class="gb-preview" id="gb-pv" style="display:none">
<h3>Превью задачи</h3>
<div class="gb-pv-eq" id="pv-eq"></div>
<div class="gb-pv-ans" id="pv-ans"></div>
<div id="pv-sol"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/labs/_sim_expr.js"></script>
<script src="/js/trainer/_trainer_engine.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script>
(function () {
'use strict';
if (typeof LS === 'undefined') return;
var ip = LS.initPage();
if (!ip) return;
if (!ip.isAdmin) { location.href = '/dashboard'; return; } // конструктор — только админам
var TE = window.TrainerEngine;
var $ = function (id) { return document.getElementById(id); };
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function kat(latex, disp) { if (window.katex && latex) { try { return window.katex.renderToString(latex, { displayMode: !!disp, throwOnError: false }); } catch (e) {} } return null; }
var editingId = null;
// ── динамические строки ──
function pickRow(name, lo, hi) {
var d = document.createElement('div'); d.className = 'gb-rrow';
d.innerHTML = '<input class="nm" placeholder="имя" value="' + esc(name || '') + '"/>' +
'<input class="num" type="number" placeholder="от" value="' + (lo == null ? '' : lo) + '"/>' +
'<input class="num" type="number" placeholder="до" value="' + (hi == null ? '' : hi) + '"/>' +
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
return d;
}
function kvRow(name, val, nmPh, valPh) {
var d = document.createElement('div'); d.className = 'gb-rrow';
d.innerHTML = '<input class="nm" placeholder="' + nmPh + '" value="' + esc(name || '') + '"/>' +
'<input class="grow" placeholder="' + valPh + '" value="' + esc(val || '') + '"/>' +
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
return d;
}
function solRow(note, tex) {
var d = document.createElement('div'); d.className = 'gb-rrow';
d.innerHTML = '<input class="grow" placeholder="пояснение" value="' + esc(note || '') + '"/>' +
'<input class="grow" placeholder="формула шага" value="' + esc(tex || '') + '" style="font-family:\'Cambria Math\',serif"/>' +
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
return d;
}
$('add-pick').addEventListener('click', function () { $('f-pick').appendChild(pickRow()); });
$('add-derive').addEventListener('click', function () { $('f-derive').appendChild(kvRow('', '', 'имя', 'формула')); });
$('add-sol').addEventListener('click', function () { $('f-sol').appendChild(solRow()); });
function clearForm() {
editingId = null;
$('gb-form-title').textContent = 'Новый генератор';
$('f-title').value = ''; $('f-topic').value = 'custom'; $('f-kind').value = 'solve';
$('f-lhs').value = ''; $('f-rhs').value = ''; $('f-display').value = '';
$('f-answer').value = ''; $('f-require').value = ''; $('f-int').checked = true; $('f-pub').checked = false;
$('f-pick').innerHTML = ''; $('f-derive').innerHTML = ''; $('f-sol').innerHTML = '';
$('f-pick').appendChild(pickRow('a', 2, 9));
$('f-derive').appendChild(kvRow('', '', 'имя', 'формула'));
$('f-sol').appendChild(solRow());
$('gb-err').textContent = ''; $('gb-pv').style.display = 'none';
}
function readRows(container, mapper) {
var out = [];
container.querySelectorAll('.gb-rrow').forEach(function (r) {
var inputs = r.querySelectorAll('input');
var v = mapper(inputs);
if (v) out.push(v);
});
return out;
}
function buildSpec() {
var spec = { title: $('f-title').value.trim(), topic: $('f-topic').value.trim() || 'custom', kind: $('f-kind').value };
var pick = {};
readRows($('f-pick'), function (i) {
var nm = i[0].value.trim(); if (!nm) return null;
var lo = parseInt(i[1].value, 10), hi = parseInt(i[2].value, 10);
if (!isNaN(lo) && !isNaN(hi)) pick[nm] = [lo, hi];
return null;
});
spec.pick = pick;
var derive = {};
readRows($('f-derive'), function (i) { var nm = i[0].value.trim(); if (nm && i[1].value.trim()) derive[nm] = i[1].value.trim(); return null; });
if (Object.keys(derive).length) spec.derive = derive;
if ($('f-lhs').value.trim()) spec.lhs = $('f-lhs').value.trim();
if ($('f-rhs').value.trim()) spec.rhs = $('f-rhs').value.trim();
if ($('f-display').value.trim()) spec.display = $('f-display').value.trim();
if ($('f-answer').value.trim()) spec.answer = $('f-answer').value.trim();
if ($('f-require').value.trim()) spec.require = $('f-require').value.trim();
spec.integerAnswer = $('f-int').checked;
spec.solution = readRows($('f-sol'), function (i) {
var note = i[0].value.trim(), tex = i[1].value.trim();
if (!note && !tex) return null;
return { note: note, tex: tex };
});
return spec;
}
// материализуем спек локально (тот же движок, что у ученика) для превью/валидации
function tryInstantiate(spec) {
if (!spec.title) return { err: 'Укажите заголовок.' };
if (!Object.keys(spec.pick || {}).length) return { err: 'Добавьте хотя бы один параметр.' };
try {
var p = TE.instantiate(spec, { seed: (Math.random() * 1e9) | 0, strict: true });
if (!p) return { err: 'Не удалось сгенерировать задачу — проверьте диапазоны и ограничения.' };
return { p: p };
} catch (e) {
return { err: 'Проверка не прошла: ' + (e && e.message ? e.message : 'ответ не согласован с условием') };
}
}
function renderPreview() {
var r = tryInstantiate(buildSpec());
if (r.err) { $('gb-err').textContent = r.err; $('gb-pv').style.display = 'none'; return false; }
$('gb-err').textContent = '';
var p = r.p;
var eq = $('pv-eq'); var h = kat(p.latex, true); if (h) eq.innerHTML = h; else eq.textContent = p.display || '—';
$('pv-ans').textContent = 'Ответ: ' + (p.answers ? p.answers.join('; ') : ('x = ' + p.answer));
$('pv-sol').innerHTML = (p.solution || []).map(function (s, i) {
var m = s.latex ? (kat(s.latex, false) || esc(s.tex || '')) : esc(s.tex || '');
return '<div class="gb-pv-step">' + (i + 1) + '. ' + esc(s.note || '') + (m ? '<span class="stx">' + m + '</span>' : '') + '</div>';
}).join('');
$('gb-pv').style.display = 'block';
return true;
}
$('gb-preview').addEventListener('click', renderPreview);
function save() {
var spec = buildSpec();
var r = tryInstantiate(spec);
if (r.err) { $('gb-err').textContent = r.err; return; }
var status = $('f-pub').checked ? 'published' : 'draft';
var pr = editingId ? LS.practiceGenUpdate(editingId, spec, status) : LS.practiceGenCreate(spec, status);
pr.then(function (res) {
if (res && res.ok) { if (LS.toast) LS.toast(editingId ? 'Генератор обновлён' : 'Генератор создан', 'success'); editingId = res.generator.dbid; $('gb-form-title').textContent = 'Редактирование'; loadList(); }
else { $('gb-err').textContent = 'Не удалось сохранить.'; }
}).catch(function (e) { $('gb-err').textContent = 'Ошибка сохранения: ' + (e && e.message || ''); });
}
$('gb-save').addEventListener('click', save);
$('gb-new').addEventListener('click', clearForm);
function fillForm(g) {
editingId = g.dbid;
$('gb-form-title').textContent = 'Редактирование: ' + (g.title || '');
$('f-title').value = g.title || ''; $('f-topic').value = g.topic || 'custom';
$('f-kind').value = (g.kind === 'compute') ? 'compute' : 'solve';
$('f-lhs').value = g.lhs || ''; $('f-rhs').value = g.rhs || ''; $('f-display').value = g.display || '';
$('f-answer').value = g.answer || ''; $('f-require').value = g.require || ''; $('f-int').checked = !!g.integerAnswer;
$('f-pub').checked = g.status === 'published';
$('f-pick').innerHTML = ''; var pk = g.pick || {};
Object.keys(pk).forEach(function (k) { $('f-pick').appendChild(pickRow(k, pk[k][0], pk[k][1])); });
if (!Object.keys(pk).length) $('f-pick').appendChild(pickRow());
$('f-derive').innerHTML = ''; var dv = g.derive || {};
Object.keys(dv).forEach(function (k) { $('f-derive').appendChild(kvRow(k, dv[k], 'имя', 'формула')); });
if (!Object.keys(dv).length) $('f-derive').appendChild(kvRow('', '', 'имя', 'формула'));
$('f-sol').innerHTML = ''; (g.solution || []).forEach(function (s) { $('f-sol').appendChild(solRow(s.note, s.tex)); });
if (!(g.solution || []).length) $('f-sol').appendChild(solRow());
$('gb-err').textContent = ''; $('gb-pv').style.display = 'none';
window.scrollTo(0, 0);
}
var myId = (LS.getUser && LS.getUser()) ? LS.getUser().id : null;
function loadList() {
LS.practiceGenList().then(function (r) {
var mine = (r.generators || []).filter(function (g) { return g.owner_id === myId; });
var box = $('gb-list');
if (!mine.length) { box.innerHTML = '<div class="gb-empty">Пока нет генераторов. Создайте первый.</div>'; return; }
box.innerHTML = '';
mine.forEach(function (g) {
var d = document.createElement('div'); d.className = 'gb-list-item';
d.innerHTML = '<div class="gb-li-main"><div class="gb-li-title">' + esc(g.title) + '</div><div class="gb-li-meta">' + esc(g.topic) + ' · ' + esc(g.kind || 'solve') + '</div></div>' +
'<span class="gb-li-pub ' + (g.status === 'published' ? 'published' : 'draft') + '">' + (g.status === 'published' ? 'опубл.' : 'черновик') + '</span>' +
'<button class="gb-icon-btn gb-del" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></button>';
d.querySelector('.gb-li-main').addEventListener('click', function () { fillForm(g); });
d.querySelector('.gb-del').addEventListener('click', function () {
LS.practiceGenDelete(g.dbid).then(function () { if (LS.toast) LS.toast('Удалено', 'success'); if (editingId === g.dbid) clearForm(); loadList(); }).catch(function () {});
});
box.appendChild(d);
});
if (window.lucide) lucide.createIcons();
}).catch(function () { $('gb-list').innerHTML = '<div class="gb-empty">Не удалось загрузить.</div>'; });
}
clearForm();
loadList();
})();
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More