Замените LLM-вики на Python-компилятор: руководство с кодом | AiManual
AiManual Logo Ai / Manual.
03 Июл 2026 Гайд

Как заменить LLM-вики на чистый Python-компилятор: пошаговое руководство с кодом

Пошаговое руководство по созданию локального компилятора заметок на Python без LLM. Парсер Markdown, граф связей, генератор HTML и линтер. Полный код и тесты.

Все вики-движки врут (или стоят дорого)

Obsidian с Copilot, Notion AI, Foam — крутые инструменты, пока вы не видите счёт за API. Я однажды запустил авто-тегирование 500 заметок через GPT-4. Спойлер: пришлось продать почку. Шутка, но боль реальна. LLM-вики решают задачу, которую можно решить простым парсингом и графом зависимостей. Зачем платить за токены, если можно написать 200 строк на Python?

Эта статья — мой личный манифест отказа от переусложнённых решений. Мы соберём локальный компилятор заметок, который парсит Markdown, строит граф связей, генерирует статический HTML и проверяет качество линтером. Без единого вызова нейросети. Всё работает на вашем ноутбуке, бесплатно и молниеносно.

Эта статья — идеальный кандидат для подхода «Delegation Filter»: мы явно решаем, что не нужно LLM. Подробнее — в моём материале Delegation Filter: когда НЕ использовать LLM в продакшн-пайплайнах.

Что мы построим: архитектура за 5 минут

Представьте, что у вас есть папка с .md файлами, внутри которых ссылки вида [[Заметка 1]]. Наша программа:

  1. Парсер — вытаскивает заголовки, теги, ссылки и метаданные из каждого файла.
  2. Построитель графа — собирает словарь: файл → список связанных файлов.
  3. Генератор HTML — превращает всё в статический сайт с навигацией.
  4. Линтер — ищет битые ссылки, дубликаты заголовков, опечатки.

Звучит скучно? Зато работает. И никакой LLM не нужна. Если вам всё же интересны гибридные подходы — почитайте про контекстную инженерию для локальных LLM.

Шаг 1. Парсер: выкусываем мясо из Markdown

Первое, что я написал — неправильный парсер. Он разбивал файл по строкам и искал [[...]] регуляркой. Работало, пока в заметке не появился блочный код с квадратными скобками. Баг №1: [[code]] внутри ``` ломал всё.

Как НЕ надо: простая регулярка без учёта контекста. re.findall(r'\[\[(.*?)\]\]', text)

Правильный парсер сначала вырезает блоки кода (и инлайн-код), а потом ищет вики-ссылки. Добавляем парсинг заголовков # и метаданных (YAML front matter).

import re, yaml, os, json from pathlib import Path def parse_markdown(filepath: Path) -> dict:     with open(filepath, 'r', encoding='utf-8') as f:         raw = f.read()          # Убираем блоки кода, чтобы не ловить ссылки внутри     cleaned = re.sub(r'```.*?```', '', raw, flags=re.DOTALL)     cleaned = re.sub(r'`[^`]+`', '', cleaned)          # Извлекаем front matter (если есть)     meta = {}     if cleaned.startswith('---'):         _, fm, rest = cleaned.split('---', 2)         meta = yaml.safe_load(fm) or {}         cleaned = rest          # Заголовки     titles = re.findall(r'^# (.+)$', cleaned, re.MULTILINE)          # Вики-ссылки     links = re.findall(r'\[\[(.*?)\]\]', cleaned)          return {         'path': filepath,         'meta': meta,         'titles': titles,         'links': links,         'content': cleaned     }

Обратите внимание: после вырезания кода мы теряем позиции, но нам нужны только имена ссылок. Если нужны offset — сохраняйте маппинг. Для нашей задачи хватит и так.

Шаг 2. Граф: без NetworkX, своими руками

Многие тянут networkx для построения графа. Но нам нужен просто словарь: {'file': ['linked_file1', 'linked_file2']}. Тяжёлая библиотека не нужна.

class WikiGraph:     def __init__(self, notes_dir: Path):         self.dir = notes_dir         self.nodes: dict[str, dict] = {}      def build(self):         for md_file in self.dir.glob('*.md'):             parsed = parse_markdown(md_file)             node_name = md_file.stem             self.nodes[node_name] = {                 'title': parsed['titles'][0] if parsed['titles'] else node_name,                 'links': [self._normalize_link(link) for link in parsed['links']],                 'meta': parsed['meta']             }                  # Добавляем обратные ссылки         for name, node in self.nodes.items():             backlinks = []             for other_name, other_node in self.nodes.items():                 if name in other_node['links']:                     backlinks.append(other_name)             node['backlinks'] = backlinks              @staticmethod     def _normalize_link(link: str) -> str:         # Приводим к слагу для поиска         return link.strip().lower().replace(' ', '-')

Баг №2: я забыл нормализовать ссылки — [[Заметка 1]] и [[заметка-1]] считались разными. Фикс — _normalize_link. Не спорю, неидеально, но для 99% случаев хватает.

Шаг 3. Генератор HTML: из Markdown в веб

Хотим получить сайт, где каждая страница — это заметка с панелью «Связанные заметки» и «Обратные ссылки». Используем стандартную библиотеку markdown (или mistune для скорости).

import markdown from jinja2 import Template  # лёгкий шаблонизатор  HTML_TEMPLATE = '''  {{ title }} 

{{ title }}

{{ content_html }}

Связанные заметки

Обратные ссылки

    {% for back in backlinks %}
  • {{ back }}
  • {% endfor %}
''' def generate_html(graph: WikiGraph, output_dir: Path): template = Template(HTML_TEMPLATE) for name, node in graph.nodes.items(): # Конвертируем Markdown в HTML md_content = (graph.dir / f"{name}.md").read_text() html_content = markdown.markdown(md_content) html = template.render( title=node['title'], content_html=html_content, links=node['links'], backlinks=node['backlinks'] ) (output_dir / f"{name}.html").write_text(html)

Если хотите красивую подсветку кода и удобную навигацию — подключите Prism.js. Я добавил в шаблон ссылку на CDN.

Шаг 4. Линтер: ищем мусор в заметках

Без линтера ваша вики быстро превратится в свалку. Наш линтер проверяет:

  • Битые ссылки — ссылается ли [[файл]] на существующий .md файл.
  • Дубликаты заголовков — несколько страниц с одинаковым H1.
  • Орфографию — через pyspellchecker (если установлен).
  • Пустые страницы — файлы без текста.
def lint(graph: WikiGraph):     errors = []     for name, node in graph.nodes.items():         # Битые ссылки         for link in node['links']:             if link not in graph.nodes:                 errors.append(f"{name}: битая ссылка на '{link}'")                  # Пустые страницы         content = (graph.dir / f"{name}.md").read_text().strip()         if not content:             errors.append(f"{name}: пустой файл")          # Дубликаты заголовков     titles = [node['title'] for node in graph.nodes.values()]     duplicates = [t for t in titles if titles.count(t) > 1]     for dup in set(duplicates):         errors.append(f"Дубликат заголовка: '{dup}'")     return errors

Баг №3: линтер ругался на [[index]], хотя я намеренно делал индексную страницу. Пришлось добавить белый список.

Собираем всё вместе и запускаем

# main.py from pathlib import Path import sys  NOTES_DIR = Path('./notes') OUTPUT_DIR = Path('./site')  def main():     graph = WikiGraph(NOTES_DIR)     graph.build()          errors = lint(graph)     if errors:         for err in errors:             print(f'[LINT] {err}')         # Можно остановиться, но я предпочитаю генерировать даже с ошибками         # raise SystemExit(1)          OUTPUT_DIR.mkdir(exist_ok=True)     generate_html(graph, OUTPUT_DIR)     print('Сайт сгенерирован в ./site')  if __name__ == '__main__':     main()

Теперь python main.py — и вы получаете статический сайт. Разместите его на любом хостинге, например Vercel (бесплатный тариф отлично подходит).

Производительность: почему это быстрее LLM

Сравните: парсинг 1000 файлов занимает ~0.3 секунды. LLM-вики на каждый запрос тратит 2-5 секунд + деньги. Наш компилятор — это однократный прогон. Не верите — замерьте сами. Кстати, о производительности локальных моделей: в статье Новый сэмплер и верификатор для llama.cpp показано, как даже крошечная модель может быть быстрой, но для нашей задачи и она избыточна.

Тесты: как убедиться, что всё не развалится

# test_wiki.py def test_parse_ignores_code_blocks():     text = '```\n[[code]]\n```\nOutside [[valid]]'     result = parse_markdown(text)     assert result['links'] == ['valid']  def test_lint_detects_broken_links():     # создаём временные файлы     ...     graph = WikiGraph(...)     graph.build()     errors = lint(graph)     assert any('broken' in e for e in errors)

Юнит-тесты на парсер, интеграционные на граф и линтер — всё в одном файле. Я запускаю pytest перед каждым коммитом.

Когда LLM всё-таки нужна? (и когда нет)

Если вы пишете заметки на естественном языке и хотите семантический поиск (например, «найди все записи про деплой Kubernetes») — тут без LLM сложно. Но базовая навигация по ссылкам и графам — это чистая алгоритмика. Я часто комбинирую: для быстрой навигации использую описанный компилятор, а для сложных запросов поднимаю локальную модель через Ollama. Как настроить — читайте в гайде Приватный перевод кода на локальной LLM.

Но помните: сначала спросите себя «Можно ли это сделать без нейросети?». Мой опыт показывает, что в 80% случаев ответ «да».

Подписаться на канал