Почему не сохранялись навыки учеников, хотя код был готов. Три ошибки при передаче данных между сервисами
Коротко. В StudyNinja поле skill_uid (навык, который тренирует задача) принимали все нужные функции платформы, но в данных оно было пустым в каждой из 1782 попыток на dev-стенде. Причин оказалось три, и каждой хватало бы одной. Запрос к графу проходил узел навыка без имени и не мог его вернуть. Сервис графа отдавал пустую строку вместо null. А платформа искала поле на верхнем уровне ответа, тогда как граф клал его внутрь meta. При починке нашлось ещё одно звено. Ошибок не было ни одной. Всё, что опиралось на навыки, тихо работало на уровне темы и выглядело исправным.
Что мы увидели
Замер на dev-стенде показал странную картину. Состояний освоения по темам накопилось 470, а по навыкам ни одного. Поле skill_uid было пустым во всех 1782 попытках учеников.
При этом в графе знаний навыки есть. Их 313, и через них проходят пути к 7694 примерам задач. То есть данные существовали, код их ждал, а между ними ничего не доезжало.
Как навык должен попадать в попытку
В графе предмет разложен на темы, навыки, методы решения и примеры. Сервис графа (KnowledgeBaseAI) подбирает примеры для урока и отдаёт их платформе (StudyNinja-API). Платформа собирает из примера задачу, а когда ученик отвечает, записывает попытку с идентификаторами темы, навыка, метода и примера.
граф: тема → навык → метод → пример
↓ выборка примеров
платформа: пример → задача → ответ ученика → попытка (topic, skill, method, example)Навык должен был пройти всю эту цепочку. Он терялся на каждом её отрезке.
Разрыв первый. Почему запрос к графу не вернул навык
Запрос, который выбирает примеры, проходил путь от темы к примеру через навык. Но узел навыка был записан анонимным паттерном, без переменной. Вот упрощённый пример, не наш настоящий запрос.
MATCH (t:Topic {uid: $topic})-[:NEEDS]->(:Skill)-[:TEACHES]->(m:Method)-[:HAS]->(e:Example)
RETURN e.uid AS example_uid, m.uid AS method_uidCypher проходит узел (:Skill), но вернуть его свойства в RETURN нельзя, потому что у него нет имени. Исправление умещается в одну переменную.
MATCH (t:Topic {uid: $topic})-[:NEEDS]->(sk:Skill)-[:TEACHES]->(m:Method)-[:HAS]->(e:Example)
RETURN e.uid AS example_uid, m.uid AS method_uid, sk.uid AS skill_uidОбидная деталь. В описании этой же функции было записано, что ровно эту проблему уже чинили для метода. Метод получил переменную и начал доезжать, а навык так и остался в состоянии «до исправления».
Разрыв второй. Почему пустая строка хуже null
Там, где сервис графа всё-таки отдавал поле, оно было пустым. Для авторских примеров так.
# было
"skill_uid": r.get("skill_uid") or "",
# стало
"skill_uid": r.get("skill_uid") or None,А для сгенерированных заданий навык не передавался вовсе, и комментарий в коде честно это признавал.
# было
"skill_uid": None, # Skill UID is unknown for generated questions
# стало
"skill_uid": _sole_skill_uid(topic_context),Пустая строка опаснее null. Проверка «поле передано» проходит, а значения нет. Отсутствие должно выглядеть как отсутствие.
Для сгенерированных заданий мы не стали подставлять первый попавшийся навык темы. Навык записывается, только если он у темы единственный. Иначе честный null, потому что лучше не знать, чем записать неправду в историю ученика.
Разрыв третий. Где поле потерялось между сервисами
Сервис графа клал навык внутрь вложенного объекта meta, а платформа читала его с верхнего уровня ответа. Схематично это выглядело так.
# сервис графа отдавал
{"uid": "...", "method_uid": "...", "meta": {"skill_uid": "..."}}
# платформа читала
skill_uid = q.get("skill_uid") # на верхнем уровне его нет, всегда NoneНа стороне графа навык подняли на верхний уровень ответа, туда же, где лежат тема, метод и пример. Функция платформы, которая читает поле, уже смотрела на верхний уровень и правок не потребовала.
Четвёртое звено, которое нашлось при починке
Когда три разрыва закрыли, навык всё равно не доезжал. Оказалось, что платформа, собирая задачу из примера, переносила идентификаторы примера и метода, а навык нет. Перенос добавили одной строкой.
"example_uid": ex.get("uid") or "",
**({"method_uid": str(ex["method_uid"])} if ex.get("method_uid") else {}),
# Отсутствие оставляем отсутствием, а не пустой строкой (TECH-378).
**({"skill_uid": str(ex["skill_uid"])} if ex.get("skill_uid") else {}),Комментарий в соседних строках этого же файла рассказывает, что история уже случалась. Раньше так же терялись пример и метод. Идентификатор лежал под именем, которого общий разбор попыток не читал, и у 1319 задач занятий не было ни примера, ни метода в данных.
Почему этого никто не заметил
Потому что ничего не ломалось. Платформа принимала skill_uid во всех нужных местах, в записи попытки, в обновлении состояния ученика, в расчётах. Читая код, легко поверить, что навыки работают.
Но там, где навыка нет, код аккуратно откатывался на уровень темы. Любая ветка, которая опиралась на навык, тихо превращалась в ветку по теме и продолжала выдавать правдоподобный результат. Ни исключения, ни пустого экрана, ни жалобы. Тихий запасной путь оказался хуже честной ошибки.
Что исправили и как проверили
Проверка на dev-стенде после исправления.
Изменение только добавило новое. Всё, что считалось по темам, не сдвинулось. Старые 1782 попытки остались без навыка, задним числом мы их не заполняли. Изменения закрыты 34 тестами, 26 в сервисе графа и 8 в платформе.
Неожиданный вывод
Когда навыки наконец доехали, мы посмотрели, сколько их в каждой теме. В 165 темах из 202, то есть в 82%, навык ровно один. В этих темах слой навыков просто повторяет тему, а реально различает он только 37 тем.
Поэтому мы не стали перестраивать на навыки то, что и так хорошо работает на уровне темы. Починить передачу поля было нужно. Переводить на него всё подряд оказалось не нужно.
Коротко для тех, кто строит похожие системы
Ни одна из четырёх ошибок не была сложной. Сложно было заметить, что они вообще есть, ведь код выглядел готовым, а данные молчали.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.