diff --git a/backend/scripts/seed_ctmath_ct2021_v1.js b/backend/scripts/seed_ctmath_ct2021_v1.js
new file mode 100644
index 0000000..65e3cf7
--- /dev/null
+++ b/backend/scripts/seed_ctmath_ct2021_v1.js
@@ -0,0 +1,363 @@
+'use strict';
+/* ───────────────────────────────────────────────────────────────────────────
+ seed_ctmath_ct2021_v1.js
+ Чистый вариант-пробник для трека exam-prep `ctmath`.
+
+ Источник: Централизованное тестирование (ЦТ) по математике, 2021, Вариант 1.
+ Формат 2021: Часть А = А1–А18, Часть В = В1–В14. Всего **32 задания**.
+ ⚠️ А12 и А16 — с НЕСКОЛЬКИМИ верными ответами; В2,В3 — множественный выбор; В1 —
+ на установление соответствия. Перенабрано вручную в KaTeX по PDF:
+ F:\!Рабочие\ЦТ\Математика\Математика\ЦТ-ЦЭ\ЦТ 2021.pdf (10 вариантов, табл. ответов стр.45).
+
+ ⚠️ Ответы решены самостоятельно и СВЕРЕНЫ с официальной таблицей (стр.45, столбец
+ «Вариант 1»): ВСЕ 32 совпали, включая B9=324, B11=960, B13=460, B14=1375. variant=117.
+
+ Реконструкции/адаптации заданий-«с-картинкой» (смысл/ответ сохранены, авто-проверка):
+ • А7 (график) → множество $f(x)\le-3$ задано промежутками ($8$ целых $x$);
+ • А17 (медиана на сетке) → координаты вершин заданы (медиана из B: $7y=4x-3$);
+ • В1 (диаграмма посещений) → данные в figure_html-таблице;
+ • В4 (загон на пастбище) → размеры $a,2a$ и сторона $a+140$ заданы текстом ($800$);
+ • В2/В3 — утверждения текстом (как в оригинале).
+ ⚠️ В5: в скане `∛(-7)` — на деле `∛(-343)=-7` (иначе ответ нецелый), официальный ответ -98.
+ А12/А16 (несколько верных) → тип open, ответ = номера в порядке возрастания ('12','15').
+ Без авторских ссылок (политика «все учебники наши»).
+
+ Идемпотентность: upsert по UNIQUE(exam_key, variant, task_idx).
+ Запуск:
+ node backend/scripts/seed_ctmath_ct2021_v1.js # DRY-RUN (по умолчанию)
+ node backend/scripts/seed_ctmath_ct2021_v1.js --apply # запись в БД
+
+ ⚠️ Массовую запись в БД запускает ПОЛЬЗОВАТЕЛЬ вручную. Без --apply ничего не пишется.
+ ─────────────────────────────────────────────────────────────────────────── */
+
+const { DatabaseSync } = require('node:sqlite');
+const path = require('path');
+
+const APPLY = process.argv.includes('--apply');
+const EXAM = 'ctmath';
+const VARIANT = 117;
+const N_TASKS = 32;
+const PROV = 'ЦТ–2021, Вариант 1';
+const R = String.raw;
+
+const L = ['а', 'б', 'в', 'г', 'д'];
+const mc = (...html) => html.map((h, i) => [L[i], h]);
+const TD = 'style="border:1px solid #99a;padding:3px 10px"';
+
+/* ── 32 задания ─────────────────────────────────────────────────────────── */
+const TASKS = [
+ // ── Часть A: А1–А18 ──────────────────────────────────────────────────────
+ { idx: 1, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 1,
+ text: R`Треугольник $ABC$ — равнобедренный с основанием $AB$, угол при вершине $C$ равен $56^\circ$. Найдите градусную меру угла $BAC$.`,
+ opts: mc('$62^\circ$', '$68^\circ$', '$34^\circ$', '$64^\circ$', '$28^\circ$'),
+ answer: 'а',
+ sol: R`Углы при основании равны: $\angle BAC=\dfrac{180^\circ-56^\circ}{2}=62^\circ$.` },
+
+ { idx: 2, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
+ text: R`Среди дробей $\dfrac{13}{7}$; $\dfrac{15}{7}$; $\dfrac{30}{7}$; $\dfrac{27}{7}$; $\dfrac{18}{7}$ укажите ту, которая равна дроби $4\tfrac27$.`,
+ opts: mc('$\dfrac{13}{7}$', '$\dfrac{15}{7}$', '$\dfrac{30}{7}$', '$\dfrac{27}{7}$', '$\dfrac{18}{7}$'),
+ answer: 'в',
+ sol: R`$4\tfrac27=\dfrac{4\cdot7+2}{7}=\dfrac{30}{7}$.` },
+
+ { idx: 3, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 1,
+ text: R`Даны пары значений переменных $x$ и $y$: $(3;9)$, $(-15;3)$, $(0;12)$, $(14;-2)$, $(6;6)$. Укажите пару, которая НЕ является решением уравнения $x+y=12$.`,
+ opts: mc('$(3;9)$', '$(-15;3)$', '$(0;12)$', '$(14;-2)$', '$(6;6)$'),
+ answer: 'б',
+ sol: R`$(-15)+3=-12\ne12$, поэтому пара $(-15;3)$ не является решением.` },
+
+ { idx: 4, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 1,
+ text: R`Среди чисел $-7$; $-11$; $11$; $-1$; $0$ укажите то, которое не меньше $-9$ и не больше $-2$.`,
+ opts: mc('$-7$', '$-11$', '$11$', '$-1$', '$0$'),
+ answer: 'а',
+ sol: R`Условие $-9\le x\le-2$ выполнено только для $-7$.` },
+
+ { idx: 5, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 1,
+ text: R`Точка $C$ делит отрезок $AB$ в отношении $5:3$, считая от точки $A$. Если длина отрезка $AB$ равна $24$, то длина отрезка $CB$ равна:`,
+ opts: mc('$14{,}4$', '$9{,}6$', '$6$', '$9$', '$15$'),
+ answer: 'г',
+ sol: R`$AC:CB=5:3$, поэтому $CB=24\cdot\dfrac{3}{8}=9$.` },
+
+ { idx: 6, type: 'mc', topic: 'numbers', subtopic: 'num-real', diff: 2,
+ text: R`В магазин поступило $43$ коробки с маслом по $110$ пачек масла в каждой. Какое наименьшее количество пачек масла необходимо продавать ежедневно, чтобы масло было распродано не более чем за $60$ дней?`,
+ opts: mc('$78$', '$81$', '$79$', '$83$', '$77$'),
+ answer: 'в',
+ sol: R`Всего $43\cdot110=4730$ пачек. $\dfrac{4730}{60}=78{,}8\ldots$, поэтому ежедневно нужно продавать не менее $79$ пачек.` },
+
+ { idx: 7, type: 'mc', topic: 'functions', subtopic: 'fn-properties', diff: 2,
+ text: R`На промежутке $[-6;6]$ функция $y=f(x)$ удовлетворяет неравенству $f(x)\le-3$ ровно при $x\in[-5;-2]\cup[1;4]$. Найдите количество целых значений $x$, при которых $f(x)\le-3$.`,
+ opts: mc('$7$', '$6$', '$5$', '$9$', '$8$'),
+ answer: 'д',
+ sol: R`Целые $x$ из $[-5;-2]$: $-5,-4,-3,-2$ (четыре); из $[1;4]$: $1,2,3,4$ (четыре). Всего $8$.` },
+
+ { idx: 8, type: 'mc', topic: 'expressions', subtopic: 'expr-fractions', diff: 2,
+ text: R`Результат упрощения выражения $|a-6|-|a|$ при $\dfrac16$1)\ y=0{,}2x^{2}$; $\ 2)\ y=8^{\frac{x^{4}-16}{2|x|}}$; $\ 3)\ y=-\dfrac3x$; $\ 4)\ y=x^{2}-x+2$; $\ 5)\ y=\sin2x$.`,
+ answer: '12',
+ sol: R`Чётны функции $1$ ($y=0{,}2x^{2}$) и $2$ (показатель $\frac{x^{4}-16}{2|x|}$ — чётная функция). Функции $3$ и $5$ нечётны, $4$ — общего вида.` },
+
+ { idx: 13, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 3,
+ text: R`Площадь прямоугольного треугольника равна $2$, а радиус описанной около него окружности равен $R$. Укажите номер формулы, которой может выражаться сумма катетов $a$ и $b$.
$1)\ a+b=\dfrac{R^{2}+4}{R}$; $\ 2)\ a+b=\sqrt{R^{2}+2}$; $\ 3)\ a+b=2\sqrt{R^{2}+4}$; $\ 4)\ a+b=\dfrac{R^{2}+2}{R}$; $\ 5)\ a+b=2\sqrt{R^{2}+2}$.`,
+ opts: mc('$\dfrac{R^{2}+4}{R}$', '$\sqrt{R^{2}+2}$', '$2\sqrt{R^{2}+4}$', '$\dfrac{R^{2}+2}{R}$', '$2\sqrt{R^{2}+2}$'),
+ answer: 'д',
+ sol: R`Гипотенуза $c=2R$, площадь $\tfrac12 ab=2$, значит $ab=4$. $(a+b)^{2}=a^{2}+b^{2}+2ab=4R^{2}+8$, поэтому $a+b=2\sqrt{R^{2}+2}$.` },
+
+ { idx: 14, type: 'mc', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 3,
+ text: R`Основанием прямой треугольной призмы $ABCA_1B_1C_1$ является треугольник $ABC$, в котором $\angle A=20^\circ$, $\angle C=25^\circ$, а радиус описанной около него окружности равен $\sqrt7$. Найдите длину диагонали грани $AA_1C_1C$, если площадь этой грани равна $2\sqrt{35}$.`,
+ opts: mc('$3\sqrt3$', '$\sqrt5$', '$2\sqrt6$', '$4\sqrt6$', '$9\sqrt3$'),
+ answer: 'в',
+ sol: R`$\angle B=135^\circ$, $AC=2R\sin B=2\sqrt7\cdot\dfrac{\sqrt2}{2}=\sqrt{14}$. Из $AC\cdot AA_1=2\sqrt{35}$: $AA_1=\sqrt{10}$. Диагональ грани $\sqrt{AC^{2}+AA_1^{2}}=\sqrt{14+10}=2\sqrt6$.` },
+
+ { idx: 15, type: 'mc', topic: 'functions', subtopic: 'fn-graphs', diff: 2,
+ text: R`Парабола $y=2x^{2}+bx+c$ пересекает ось абсцисс в точках с абсциссами $3$ и $4$. Найдите сумму $b+c$.`,
+ opts: mc('$12$', '$5$', '$20$', '$10$', '$14$'),
+ answer: 'г',
+ sol: R`$y=2(x-3)(x-4)=2x^{2}-14x+24$, поэтому $b=-14$, $c=24$ и $b+c=10$.` },
+
+ { idx: 16, type: 'open', topic: 'equations', subtopic: 'eq-quadratic', diff: 3,
+ text: R`Укажите номера уравнений, которые являются равносильными (запишите цифрами в порядке возрастания).
$1)\ (x-6)(x+6)=0$; $\ 2)\ \sqrt{x+10}=2$; $\ 3)\ x^{2}+36=0$; $\ 4)\ \dfrac{x-x^{2}-5}{4}+\dfrac{x^{2}-x-3}{3}=\dfrac14$; $\ 5)\ |x|-6=0$.`,
+ answer: '15',
+ sol: R`Множества решений: 1) $\{-6;6\}$; 2) $\{-6\}$; 3) корней нет; 4) $\{-5;6\}$; 5) $\{-6;6\}$. Совпадают решения у уравнений $1$ и $5$.` },
+
+ { idx: 17, type: 'mc', topic: 'planimetry', subtopic: 'plan-triangles', diff: 3,
+ text: R`Треугольник $ABC$ имеет вершины $A(0;2)$, $B(6;3)$, $C(-2;-4)$ (узлы сетки). Укажите номер уравнения прямой, содержащей медиану, проведённую из вершины $B$.
$1)\ 7y=3x+3$; $\ 2)\ 5y=4x-1$; $\ 3)\ y=3$; $\ 4)\ y=5x+4$; $\ 5)\ 7y=4x-3$.`,
+ opts: mc('$7y=3x+3$', '$5y=4x-1$', '$y=3$', '$y=5x+4$', '$7y=4x-3$'),
+ answer: 'д',
+ sol: R`Медиана из $B$ идёт в середину $AC$ — точку $M(-1;-1)$. Прямая через $B(6;3)$ и $M(-1;-1)$ имеет угловой коэффициент $\dfrac{3-(-1)}{6-(-1)}=\dfrac47$ и уравнение $7y=4x-3$.` },
+
+ { idx: 18, type: 'mc', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
+ text: R`$SABCD$ — правильная четырёхугольная пирамида, все рёбра которой равны $48$. Точка $M$ — середина ребра $SD$, точка $N$ на ребре $SC$ такова, что $CN:NS=1:3$. Найдите длину отрезка, по которому плоскость, проходящая через точки $M$ и $N$ параллельно ребру $SA$, пересекает основание $ABCD$.`,
+ opts: mc('$16\sqrt{13}$', '$16\sqrt{10}$', '$8\sqrt{37}$', '$12\sqrt{17}$', '$56$'),
+ answer: 'б',
+ sol: R`В координатах с центром основания секущая плоскость пересекает основание по прямой $x-3y=-24$. Внутри квадрата отрезок идёт от стороны $AD$ (точка $(-24;0)$) до стороны $BC$ (точка $(24;16)$); его длина $\sqrt{48^{2}+16^{2}}=\sqrt{2560}=16\sqrt{10}$.` },
+
+ // ── Часть B: В1–В14 ──────────────────────────────────────────────────────
+ { idx: 19, type: 'long', topic: 'word-sequences', subtopic: 'word-problems', diff: 3,
+ text: R`На диаграмме (см. таблицу) показано количество посещений сайта по дням недели. Для начала каждого из предложений А–В подберите его окончание $1$–$6$.
А) В какой день количество посещений было на $20$ больше, чем в предыдущий?
Б) В какой день количество посещений было на 35 % меньше, чем во вторник?
В) В какой день количество посещений было на 10 % больше, чем в предыдущий?
Окончания: 1) вторник; 2) среда; 3) четверг; 4) пятница; 5) суббота; 6) воскресенье.`,
+ fig: R`
| День | вт | ср | чт | пт | сб | вс |
|---|
| Посещений | 400 | 440 | 260 | 300 | 640 | 660 |
`,
+ answer: 'А6Б3В2',
+ ansShow: 'А6Б3В2',
+ sol: R`А) на $20$ больше предыдущего — воскресенье ($660-640=20$), окончание 6. Б) на 35 % меньше вторника — $400\cdot0{,}65=260$ — четверг, окончание 3. В) на 10 % больше предыдущего — среда ($400\cdot1{,}1=440$), окончание 2. Ответ: А6Б3В2.` },
+
+ { idx: 20, type: 'open', topic: 'trigonometry', subtopic: 'trig-identities', diff: 4,
+ text: R`Выберите номера трёх верных утверждений (запишите цифрами в порядке возрастания).
$1)$ если $\cos(\arccos a)=\cos\left(\arccos\tfrac1{18}\right)$, то $a=\tfrac1{18}$;
$2)$ если $\cos\alpha=-\cos\tfrac{\pi}{18}$, то $\arccos(\cos\alpha)=-\tfrac{\pi}{18}$;
$3)$ если $\sin\alpha=\sin\tfrac{17\pi}{18}$, то $\arcsin(\sin\alpha)=\tfrac{17\pi}{18}$;
$4)$ если $\arccos a=\tfrac{\pi}{18}$, то $a=\cos\tfrac{\pi}{18}$;
$5)$ если $\sin\alpha=\sin\tfrac{\pi}{18}$, то $\alpha=-\tfrac{\pi}{18}$;
$6)$ если $\sin\alpha=\sin\tfrac{\pi}{18}$, то $\arcsin(\sin\alpha)=\tfrac{\pi}{18}$.`,
+ answer: '146',
+ sol: R`1) $\cos(\arccos a)=a$, значит $a=\tfrac1{18}$ — верно. 4) $\arccos a=\tfrac{\pi}{18}\Rightarrow a=\cos\tfrac{\pi}{18}$ — верно. 6) $\arcsin\left(\sin\tfrac{\pi}{18}\right)=\tfrac{\pi}{18}$ — верно. Утверждения $2,3,5$ неверны (значения арккосинуса/арксинуса лежат в своих главных промежутках). Верны $1,4,6$.` },
+
+ { idx: 21, type: 'open', topic: 'stereometry', subtopic: 'ster-basics', diff: 4,
+ text: R`Две перпендикулярные плоскости $\alpha$ и $\beta$ пересекаются по прямой $a$, точка $A$ принадлежит плоскости $\beta$. Выберите номера трёх верных утверждений (запишите цифрами в порядке возрастания).
$1)$ любая прямая, проходящая через $A$ и пересекающая $\alpha$, пересекает прямую $a$;
$2)$ существует единственная прямая, проходящая через $A$ и перпендикулярная плоскости $\alpha$;
$3)$ прямая, проходящая через $A$ и перпендикулярная $\beta$, перпендикулярна $\alpha$;
$4)$ любая точка прямой $a$ лежит в плоскостях $\alpha$ и $\beta$;
$5)$ любая прямая, лежащая в $\alpha$ и перпендикулярная прямой $a$, перпендикулярна $\beta$;
$6)$ любая прямая, перпендикулярная прямой $a$, принадлежит плоскости $\beta$.`,
+ answer: '245',
+ sol: R`Верны: $2$ (через точку — единственная прямая, перпендикулярная плоскости), $4$ (прямая $a$ — линия пересечения), $5$ (характеристическое свойство перпендикулярных плоскостей). Утверждения $1,3,6$ неверны. Верны $2,4,5$.` },
+
+ { idx: 22, type: 'open', topic: 'planimetry', subtopic: 'plan-quadrilaterals', diff: 3,
+ text: R`На пастбище квадратной формы огорожен загон — прямоугольник со сторонами $a$ и $2a$ (в метрах); сторона квадратного пастбища равна $a+140$. Найдите площадь загона (в м²), если площадь пастбища в $32$ раза больше площади загона.`,
+ answer: '800',
+ sol: R`Площадь загона $2a^{2}$, площадь пастбища $(a+140)^{2}$. Из $(a+140)^{2}=32\cdot2a^{2}=64a^{2}$ следует $a+140=8a$, $a=20$. Площадь загона $2\cdot20^{2}=800$ м².` },
+
+ { idx: 23, type: 'open', topic: 'expressions', subtopic: 'expr-powers-roots', diff: 3,
+ text: R`Найдите значение выражения $\sqrt8\cdot\sqrt[3]{-343}\cdot\sqrt{32}-7\cdot\dfrac{\sqrt[5]{64}}{\sqrt[5]{-2}}$.`,
+ answer: '-98',
+ sol: R`$\sqrt8\cdot\sqrt{32}=\sqrt{256}=16$, $\sqrt[3]{-343}=-7$, поэтому первое слагаемое $16\cdot(-7)=-112$. $\dfrac{\sqrt[5]{64}}{\sqrt[5]{-2}}=\sqrt[5]{-32}=-2$, поэтому $7\cdot(-2)=-14$. Значение $-112-(-14)=-98$.` },
+
+ { idx: 24, type: 'open', topic: 'stereometry', subtopic: 'ster-rotation', diff: 4,
+ text: R`Площадь боковой поверхности цилиндра равна $15\pi$. Найдите объём $V$ цилиндра, если радиус его основания больше высоты на $3{,}5$. В ответ запишите значение выражения $\dfrac{6V}{\pi}$.`,
+ answer: '225',
+ sol: R`$2\pi rh=15\pi\Rightarrow rh=7{,}5$, $r=h+3{,}5$. Тогда $(h+3{,}5)h=7{,}5$, $h=1{,}5$, $r=5$. $V=\pi r^{2}h=37{,}5\pi$, и $\dfrac{6V}{\pi}=225$.` },
+
+ { idx: 25, type: 'open', topic: 'trigonometry', subtopic: 'trig-equations', diff: 4,
+ text: R`Решите уравнение $\sqrt3\cos\left(\dfrac{5\pi}{18}+\pi x\right)=-1{,}5$. В ответ запишите увеличенное в $3$ раза произведение наибольшего корня (в радианах) на количество корней этого уравнения на промежутке $[3;9]$.`,
+ answer: '160',
+ sol: R`$\cos\left(\tfrac{5\pi}{18}+\pi x\right)=-\tfrac{\sqrt3}{2}$, откуда $x=\tfrac59+2k$ или $x=-\tfrac{10}{9}+2k$. На $[3;9]$ корни $\tfrac{41}{9},\tfrac{59}{9},\tfrac{77}{9},\tfrac{44}{9},\tfrac{62}{9},\tfrac{80}{9}$ — всего $6$; наибольший $\tfrac{80}{9}$. Тогда $3\cdot\tfrac{80}{9}\cdot6=160$.` },
+
+ { idx: 26, type: 'open', topic: 'equations', subtopic: 'eq-logarithmic', diff: 4,
+ text: R`Найдите сумму всех целых решений неравенства $\log_{0{,}3}\log_{4{,}7}\left(2^{x+9{,}1}-1\right)\ge0$.`,
+ answer: '-15',
+ sol: R`Основание $0{,}3<1$, поэтому $0<\log_{4{,}7}\left(2^{x+9{,}1}-1\right)\le1$, откуда $1<2^{x+9{,}1}-1\le4{,}7$, то есть $2<2^{x+9{,}1}\le5{,}7$. Значит $-8{,}1b$ (с $b\mid a$, $a=bq$) сумма результатов равна $q(b+1)^{2}=1521=3^{2}\cdot13^{2}$. Получаем пары $(338;2)$ и $(108;12)$. Сумма всех чисел $338+2+108+12=460$.` },
+
+ { idx: 32, type: 'open', topic: 'stereometry', subtopic: 'ster-polyhedra', diff: 5,
+ text: R`Основанием пирамиды $SABCD$ является выпуклый четырёхугольник $ABCD$, диагонали $AC$ и $BD$ которого перпендикулярны и пересекаются в точке $O$, причём $AO=9$, $OC=16$, $BO=OD=12$. Вершина $S$ удалена на расстояние $\dfrac{61}{7}$ от каждой из прямых $AB$, $BC$, $CD$ и $AD$. Через середину высоты пирамиды параллельно её основанию проведена секущая плоскость, делящая пирамиду на две части. Найдите значение выражения $10\cdot V$, где $V$ — объём большей из частей.`,
+ answer: '1375',
+ sol: R`Стороны $AB=AD=15$, $BC=CD=20$; $AB+CD=BC+AD$, значит четырёхугольник описанный. Площадь основания $\tfrac12\cdot25\cdot24=300$, полупериметр $35$, радиус вписанной окружности $r=\tfrac{300}{35}=\tfrac{60}{7}$. Высота $h=\sqrt{\left(\tfrac{61}{7}\right)^{2}-\left(\tfrac{60}{7}\right)^{2}}=\tfrac{11}{7}$. Объём пирамиды $\tfrac13\cdot300\cdot\tfrac{11}{7}=\tfrac{1100}{7}$. Сечение на половине высоты отсекает сверху подобную пирамиду объёмом $\tfrac18$; большая часть $\tfrac78\cdot\tfrac{1100}{7}=137{,}5$. Тогда $10V=1375$.` },
+];
+
+/* ── Сборка 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}Ответ: ${ans}
`;
+ if (t.ref) html += `Учебник: ${t.ref}
`;
+ 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_ct2021_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_ct2021_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 → «Варианты» → «ЦТ-2021».\n`);
+} catch (e) {
+ db.exec('ROLLBACK');
+ console.error('\n✗ Ошибка записи, откат транзакции:', e.message);
+ process.exitCode = 1;
+}
+db.close();
diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js
index 9fad96f..cb3a37d 100644
--- a/backend/src/routes/exam-prep.js
+++ b/backend/src/routes/exam-prep.js
@@ -51,6 +51,7 @@ const VARIANT_LABEL = {
114: 'ЦТ-2018',
115: 'ЦТ-2019',
116: 'ЦТ-2020',
+ 117: 'ЦТ-2021',
},
};
const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`;