Содержание статьи
19 сентября в одной папке src/ лежало 29 тысяч строк, около шестидесяти файлов, и ни одного пакета. pyproject.toml с пустым списком модулей, всё запускается из репозитория. Я сел писать план.
Хотелось простого. У нас есть поиск по графу встреч: ссылки между узлами, русская морфология, досье по людям и системам. Отдать его отдельно нельзя. Возьмёшь поиск, получишь в придачу запись звука, диаризацию и onnxruntime.
Зачем резать, кроме красоты. Чтобы границы держали тесты, а не моя память. Чтобы граф можно было кормить не только встречами: диктовкой, чужой записью. И чтобы кусок можно было попробовать одной командой, не ставя весь Чароит. Чем ниже порог, тем больше людей дойдут до баг-репорта.
По плану переезд занимал один день. Ушло восемь дней, переехало восемь модулей.
План
Семь пакетов, стрелки только вниз: core, llm, graph, cloud, audio, meeting, app. Граф не знает слова встреча. Шесть фаз: забор (0), переезд (1), разрез graph_updater (2), инжекция (3), самостоятельные пакеты (4), демон (5). На старых местах шимы на один релиз, чтобы скрипты не упали. В день переезда фичи не мёрджим.
План поменялся в тот же день.
Забор
Нулевая фаза — поставить import-linter с контрактами по текущим границам. import-linter хочет импортируемый пакет. У меня его нет, в этом весь вопрос.
Написал свой сторож. Он проходит по AST всех файлов, собирает граф импортов и сверяет его с layout.json: у каждого модуля слой, у слоёв порядок, ребро против стрелки разрешено только с номером карточки. Тест в CI, карта зависимостей рядом, в docs/design/. 20 сентября это ушло в main.
Слои в коде уже были, их просто ничего не держало. Рёбер против течения сейчас три, у каждого карточка.
Порядок фаз поменял там же: 0 → 3 → 2 → 1 → 4, демон остался последним. Сначала развязки, потом переезд. Двигать файлы с перепутанными связями значит переносить путаницу в новые папки. Шимы на все модули не понадобились, хватило одной строки в conftest.py.
Где лежат данные
Первая развязка оказалась про пути. Модули угадывали корень данных по своему расположению, через __file__. Переложи файл на уровень глубже, и он пойдёт искать данные владельца не туда. Без ошибки.
С 20 сентября корень данных вычисляется в одном месте, в модуле-каноне, и сторож следит, чтобы второго такого места не появилось. С 23-го точки входа обязаны назвать корень сами. После этого файлы можно двигать.
core оказался двумя слоями
Дальше неделя ушла на core. По плану туда шли safe_write, frontmatter, конфиг, коды выхода, политика приватности. Всё низкое, без зависимостей.
Сторож показал другое. Половина этого знает о машине: где корень данных, где конфиг владельца, что можно отправлять в облако. Вторая половина не знает ничего и знать не должна. Отдай такое ядро чужому человеку, и оно полезет искать корень данных и конфиг, которых у него нет.
25 сентября core разрезал на base и runtime. В base нет окружения, ни путей, ни конфига, и сторож проверяет это по слою. Граф не импортирует канон корней, сторож проверяет и это.
Кому это нужно
Ещё 23-го, пока резал core, я решил, ради чего всё. Семь пакетов для красоты не нужны. Нужен один, который можно поставить: поиск по графу ссылок с русской морфологией для любой папки с markdown. Встречи потом.
И план встретился с кодом ещё раз. В брифе пакет graph — список: graphs, graph_names, graph_nodes, graph_links, dossier, graph_search и пишущая часть graph_updater. По коду этот список не замыкается. graphs знает, где лежат данные владельца. graph_updater — 3,5 тысячи строк, и внутри встречи.
Перестал думать списками. Пакет — то, что тянет за собой один вход. Взял graph_search, прошёл по графу импортов, получил замыкание. Восемь модулей: dossier, frontmatter, graph_names, graph_nodes, graph_search, model_seam, redirects, safe_write.
Модель эмбеддингов пакет получает значением, снаружи. Сам он не знает ни Ollama, ни адреса сервера.
Проба
Пакет считаю отдельным, когда его восемь файлов, скопированных во временную папку, заводятся без Чароита. Ставится ли он одним pip, покажет колесо, о нём в конце.
Проба копирует файлы и запускает их отдельным процессом с отравленным окружением: домашняя папка, корень данных, каталог графа, TMPDIR указывают в ловушку. Пользовательских путей Python нет, байткод не пишется. Вместо модели эмбеддингов функция, которая считает буквы. Пакет строит индекс по демо-графу из репозитория, пишет кэш векторов, второй экземпляр читает его обратно и находит узел.
Любое чтение ловушки или запись мимо своей папки данных — падение. Аудит-хук Python ловит открытие файла до того, как пакет что-то прочитает.
По плану это приёмка фазы 3, инжекции: граф заводится с фейковой моделью во временной папке, без Ollama и без конфига. Она зелёная.
Сначала головы, потом код
У меня правило: задача не начинается без внешнего ревьюера. Он читает постановку до первой строки кода и отвечает на вопрос, то ли я чиню и там ли.
Схема папок хранилища, чтобы пакет не знал, что у Чароита архив лежит в папке Встречи-архив, прошла 24 входных круга. Форма пакета — 11. Следующий круг идёт, только если прошлый нашёл Critical. Значит, 23 раза подряд находили: ошибку в постановке, противоречие с кодом, уже построенное. Один раз я описал в брифе функцию из неслитой ветки как существующую.
Заодно упростилась раскладка. Гнездо packages/<дистрибутив>/src/<пакет>/ оказалось лишним: код пакета живёт в src/charoite_graph/, корни импорта не меняются, а в packages/ остаются только метаданные дистрибутива.
Переезд
Сам переезд механический, и делал его не я. Исполнитель — DeepSeek в песочнице под отдельной учёткой, без токена хостинга и без доступа к чужим каталогам. На входе задание, на выходе патч. Патч проходит допуск, пушу его я.
8 модулей в src/charoite_graph/, импорты в 53 потребителях, 74 файла. Обёрток на старых местах нет. Сторож сам следит, чтобы члена пакета никто не импортировал мимо пакета.
Три выходных круга.
Первый нашёл, что членство в пакете считалось по родительской папке. Модуль во вложенной папке пакета не был членом ни для кого, и каталог переставал совпадать с замыканием молча.
Второй — что будет, если кто-то удалит __init__.py. Пакет тихо станет пространством имён, импорт продолжит работать, а правило пустого __init__ будет проверять пустоту. Закрыл той же проверкой, что уже стояла для входа: объявил пакет — его __init__ обязан существовать.
Третий чистый. Тестов в репозитории больше 3,7 тысячи, 19 сентября было 2132.
Чего не сделал
Семи папок нет. llm, cloud, audio, meeting, app и бывший core живут в src/, как жили, их границы держит таблица слоёв. Переезд ради папок пользы не даёт, а шимы удваивают путь к каждому модулю, и сторож обхода становится невозможен.
Episode, контракт между встречей и графом, не ввёл. Второй реализации нет: диктовка пишет одну заметку без узлов. Абстракция без второй реализации — это догадка.
graph_updater на 3,5 тысячи строк не резал. Демон тоже не трогал: main() на 2800 строк, около 18 потоков делят состояние через замыкания. У обоих будут свои брифы.
Дальше колесо. charoite-graph собирается из src/charoite_graph, проба запускается уже по установленному артефакту в чистом окружении. Когда это позеленеет, pip install charoite-graph и двадцать строк примера перестанут быть планом.
Код и релизы: github.com/charoiteai/Charoite_audio, Apache 2.0. Девлог по-английски: charoiteai.github.io/Charoite_audio. Первая часть — про устройство и запись, вторая — про граф встреч как память, третья — про диаризацию и три ложных диагноза, четвёртая — про ревизию после кнопки Стоп, пятая — про облако и что уходит наружу, шестая — про полчаса тишины, которых не было в протоколе.
