Автор: Александр Казанцев, руководитель отдела документации и контента
Представьте себе код, который был создан достаточно давно,после этого не подвергался сильному рефакторингу, а только дописывался и частично обновлялся. У него нет REST/FAST API реализации, его писали и пишут разные разработчики и заставить их выдать вменяемую документацию задача еще та (даже если они пытаются). А раз нет REST API, то нельзя например прикрутить тот же Swagger.
До недавнего времени клиентская документация писалась вручную и моментально устаревала. Новые методы появлялись в коде каждый день, а документировались — если кто-то про это вспомнил.. Старые методы удалялись, а в документации оставались неработающие призраки.
На момент написания статьи, PHP-бэкенд Invapi, который мы используем в HOSTKEY, содержит порядка 60 с лишним методов-контроллеров в директории app/. Каждый контроллер представляет собой .php файл, который принимает HTTP-запрос, смотрит на параметр action и выполняет нужную ветку кода. Общее число вызовов уже приближается к 1000, доступны клиенту несколько сотен. То есть если клиент вызывает POST /ip.php с action=get_ip, и PHP-контроллер выполняет соответствующий case блок. И все это должно быть описано и конечно должно быть актуальным.
Когда я начал автоматизировать создание документации, API был одной из интересных задач. И у меня родилась система из Python-скриптов, которые анализируют PHP-код из Git репозитория Invapi через локальную LLM (на основе Ollama), генерируют Markdown-документацию, валидируют её на основе исходников и автоматически отслеживают изменения в GitLab по текущим коммитам, определяя что там поменялось и надо ли это документировать.
В данной статье я расскажу, как это все устроено внутри.
LLM-модели на вашем сервере Последние версии популярных LLM-моделей уже предустановлены на вашем сервере. |
Общая схема: конвейер из пяти агентов
Система документирования API представляет собой не монолитный скрипт, а конвейер из пяти независимых компонентов:
PHPFuncIndexer (LLM);
Whitelist Export (LLM);
Doc Generator (LLM);
Validator (regex+LLM);
Monitor (GitLab API).
Каждый этап — это отдельный LLM-вызов с уникальным промптом, своими таймаутами и принципами работы с ошибками. Такое разделение позволяет перезапускать любой этап независимо, например, перегенерать документацию одного метода без повторного анализа всего белого списка или отслеживать изменения и не запускать весь конвейер, если они не затрагивают клиентские методы API.
Разберем каждый этап подробнее.
Этап 1: Индексация функций или зачем LLM знать о том, что хранится в файлах func/*.php
PHP-контроллеры Invapi сильно делегируют логику. Тот же контроллер eq.php — это тонкий маршрутизатор, который вызывает функции из файла func/func_eq.php. Чтобы LLM могла корректно описать, что возвращает метод, ей нужен код не только контроллера, но и вызываемых функций.
Индексатор php_func_indexer.py сканирует все .php файлы функций и строит индекс сигнатур. Упрощенно его код выглядит вот так:
class PHPFuncIndexer: # Паттерн для извлечения функции FUNC_PATTERN = re.compile( r'(\/\*\*.*?\*\/\s*)?' # опциональный docblock r'function\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*' # имя функции r'\(([^)]*)\)' # параметры r'(?:\s*:\s*(?:\??\s*[a-zA-Z_][a-zA-Z0-9_|<>\[\]\\\s]*))?' # return type r'\s*\{', # открывающая { re.DOTALL ) # Паттерн для извлечения полей: $arr['key'] FIELD_PATTERN = re.compile( r'\$[a-zA-Z_][a-zA-Z0-9_]*\s*\[\s*[\'"]([a-zA-Z_][a-zA-Z0-9_]*)[\'"]\s*\]' )
Индексатор не просто собирает имена функций. Он извлекает имена полей возвращаемых массивов ($out['server_id'], $result['price']) и формирует компактный сниппет для контекста, передаваемого в нейросеть:
def to_context_snippet(self) -> str: """Формирует компактный сниппет для контекста LLM""" fields = ", ".join(self.return_fields[:15]) if self.return_fields else "unknown" params_str = ", ".join(self.params[:5]) if len(self.params) > 5: params_str += ", ..." doc = f"\n// {self.docblock.strip()}" if self.docblock.strip() else "" return ( f"function {self.name}({params_str}) {{ ... }}{doc}\n" f"// Возвращает поля: {fields}" )
Сниппет передаётся в LLM вместе с кодом контроллера. Поэтому модель видит не просто голый PHP, а понимает структуру ответа. Индекс кэшируется 24 часа в файл .func_index_cache.json и реиспользуется повторно для работы над каждым методом.
Этап 2: Белый список или почему LLM не может просто найти все методы
Сложная задача, которая стоит при описании API, это найти все клиентские методы в соответствующем PHP-файле и отличить их от внутренних, служебных и административных. Возьмём реальный файл ip.php. И хотя это не самый большой файл в проекте (в нем около 500 строк кода), он содержит в себе на текущий момент 35 вызываемых методов, оформленных как case-блоки в одном switch (почему так — вопрос к разработчикам, наверное им так было удобнее). При этом из них клиенту доступны всего семь:
Метод | Действие | Описание |
|---|---|---|
get_client_ip | получение IP клиента | Возвращает IP-адрес клиента, совершившего запрос к API |
get_ip | получение информации об IP | Возвращает полную информацию о конкретном IP-адресе: сетевые данные, маску подсети и другие параметры. |
get_ptr | получение PTR-записи | Возвращает текущую PTR-запись для указанного IP-адреса, если он закреплен за сервером в данной локации |
get_traffic | получение трафика | Возвращает данные о сетевом трафике (in/out) для указанного IP-адреса за выбранный период. Поддерживает получение сводной информации или детальных тиков. |
list_free_ip | получение списка свободных IP | Возвращает список неиспользуемых IPv4 адресов для указанной локации на основе тегов Route Reflector. Для клиентов доступны только те подсети, которые привязаны к их email или subaccount. |
set_main | установка основного IP-адреса для интерфейса | Устанавливает указанный IPv4 адрес в качестве основного (main) адреса для сервера. При необходимости обновляет данные в биллинговой системе WHMCS. |
update_ptr | обновление PTR-записи | Обновляет PTR-запись для IP-адреса, закрепленного за сервером. Проверяет наличие связи между IP и ID сервера перед выполнением операции. |
Проблема 1: параметры извлекаются по-разному
Если в то же файле api_keys.php все параметры завёрнуты в контейнер $params:
$params = $_POST['params'] ?? $_GET['params'] ?? []; 'add' => api_keys_add($params, $res['customer_id'], $res['email'] ?? ''),
То в ip.php всё иначе. В нем параметры разбросаны по верхнему уровню файла:
$action = htmlspecialchars($_GET['action'] ?? $_POST['action'] ?? ''); $ip = htmlspecialchars($_POST['ip'] ?? $_GET['ip'] ?? ""); $ptr = htmlspecialchars($_GET['ptr'] ?? $_POST['ptr'] ?? ""); $location = htmlspecialchars($_GET['location'] ?? $_POST['location'] ?? ""); $vlan = htmlspecialchars($_GET['vlan'] ?? $_POST['vlan'] ?? -1); $id = (int)($_GET['id'] ?? $_POST['id'] ?? -1); $period_start = htmlspecialchars($_POST['period_start'] ?? $_GET['period_start'] ?? time() - 86400);
В результате мы имеем 18 переменных на верхнем уровне. Но не все они используются во всех case-блоках. Метод get_ip использует только $ip и $full. Метод get_traffic использует $ip, $period_start, $period_stop, $summary, $unbilled.
Для надежности мы используем комбинированный способ из Regex и LLM.
Regex-валидатор MethodValidator извлекает обращения к $_POST/$_GET:
class MethodValidator: INPUT_PATTERN = re.compile( r'(?:\$_POST|\$_GET|\$_REQUEST|\$data|\$msg|\$params)' r'\s*\[\s*[\'"]([a-zA-Z_][a-zA-Z0-9_]*)[\'"]\s*\]' )
Но в ip.php переменные извлекаются один раз в начале файла, а в case-блоках используются уже $ip, $ptr, $location. Regex не поймёт связь $ip = $_POST['ip'] и if ($ip == ""). Поэтому парсинг параметров нужно делегировать уже нейросетевой модели.
Проблема 2: разные стили вывода ответа
Здесь также все зависит от кода. Если в api_keys.php все методы возвращают ответ через одну конструкцию:
$response = ['result' => 'OK', 'data' => match ($action) { ... }]; die(json_encode($response));
То в ip.php мы имеем уже три разных стиля:
Стиль 1: die() — прерывает выполнение
case "get_ip": die(json_encode($full ? ["data" => $res] + $response : $res + $response));
Стиль 2: echo() — продолжает выполнение
case "list_subnets": echo json_encode(['result' => 'OK', 'subnets' => $res]);
Стиль 3: mix — сначала die, потом echo
case "get_traffic": if (is_array($res)) echo json_encode(array("result" => "OK", "traffic" => $res)); else die(json_encode(["result" => -1, "message" => "..."]));
Система должна понять, что die(json_encode(...)) и echo json_encode(...) по факту это одно и то же и представляет собой возврат JSON-ответа клиенту. На лицо разница в семантике PHP, но не в API.
Проблема 3: $action vs $msg["action"] или ловушка внутренних колбэков
В ip.php этот конфликт не выражен так явно, как в других файлах. Например в eq.php мы имеем следующее:
// Клиентский API switch ($action) { case 'on': // ✅ Клиент: action=on case 'reboot': // ✅ Клиент: action=reboot } // Внутренний колбэк от гипервизора if ($msg["action"] == "console") { // ❌ Внутренний if ($msg["action"] == "deploy_vm") { // ❌ Внутренний
Для ее решения, наш промпт для формирования «белого» списка (о нем мы поговорим ниже) содержит жёсткое правило:
WHITELIST_ONLY_PROMPT = """... ⚠️ КРИТИЧЕСКОЕ ПРАВИЛО: ЕСЛИ условие использует $msg["action"] ИЛИ $msg['action'] — ЭТО ВНУТРЕННИЙ КОЛЛБЭК, НЕ КЛИЕНТСКИЙ МЕТОД! ✅ Включать ТОЛЬКО если условие использует ГЛОБАЛЬНУЮ переменную $action: - switch ($action) { case "clear_pxe": ... } > ✅ "clear_pxe" ❌ Исключать: - switch ($msg["action"]) { case "get_status": ... } > ❌ ПРОПУСТИТЬ ... """
Проблема 4:_ADMIN_ONLY методы
В файле ip.php есть проверки прав внутри case-блоков:
case "bill_traffic": // Нет проверки — только для серверных скриптов $jj_task = jenkins_journal_add("$module/$action", $_GET); // ... 50 строк кода case "dhcp_report": if ($perm["customer"] == 1) throw new Exception("invalid request"); // Только для админа case "ip_history": if ($perm["customer"] == 1) throw new Exception("invalid request"); // Только для админа case "get_ip": // Нет проверки — доступен всем if (!filter_var($ip, FILTER_VALIDATE_IP)) throw new Exception("malformed IP");
При создании документации, наша система должна правильно определить следующее:
bill_traffic — это внутренний метод, так как нет проверки токена для этого уровня, плюс используется jenkins_journal_add;
dhcp_report — это admin-only метод (явно задано $perm["customer"] == 1);
get_ip — это клиентский метод, но он доступен всем только с валидным токеном.
Эвристика такого определения задается следующими строками в промте для LLM:
WHITELIST_ONLY_PROMPT = """... ✅ "client_methods" — клиент может вызвать, если: - Метод определяется через ГЛОБАЛЬНЫЙ $action (НЕ $msg["action"]) - Нет явных проверок на роль администратора - Есть auth_validate_token() ❌ "admin_methods" — только админ, если: - Есть проверки: is_admin(), check_admin_role(), auth_check_permission() с admin ролью - $perm["customer"] == 1 → throw Exception (клиент не может) ... """
Но есть и смешанные случаи. В ip.php пример этого метод get_traffic:
case "get_traffic": if ($perm["customer"] == 1) { // Проверяем, принадлежит ли IP клиенту $id = ipv4_get_eq($ip); if (!in_array($id, $perm["servers"])) throw new Exception("invalid IP"); } // Клиент может вызвать, но только для своих IP $res = ipv4_get_traffic($ip, $period_start, $period_stop, $summary, $unbilled);
То есть это клиентский метод, но с ограничением видимости, так как клиент видит только свои IP адреса. LLM должна это понять и не отнести к admin-only методам.
Проблема 5: обязательность параметров — empty() vs ?? vs проверки
Это еще одна проблема, которая может «сломать мозг» нейросети. В ip.php обязательность определяется по-разному в разных case-блоках:
Стиль 1: прямая проверка в case:
case "list_subnets": if ($location == "") throw new Exception("location required"); // location — ОБЯЗАТЕЛЬНЫЙ ПАРАМЕТР
Стиль 2: default значение на верхнем уровне:
$location = htmlspecialchars($_GET['location'] ?? $_POST['location'] ?? ""); // location имеет дефолт "", но в list_subnets проверяется на пустоту
Стиль 3: проверка типа:
case "get_vlan_ip": if ($vlan == -1) throw new Exception("vlan id required"); // vlan — ОБЯЗАТЕЛЬНЫЙ ПАРАМЕТР, дефолт -1 означает "не передан"
Стиль 4: проверка диапазона:
case "get_range_ip": if ($id == -1) throw new Exception("range id required"); // id — ОБЯЗАТЕЛЬНЫЙ ПАРАМЕТР
Стиль 5: без проверки или опциональный:
case "get_ip": // $ip проверяется на валидность, но не на пустоту if (!filter_var($ip, FILTER_VALIDATE_IP)) throw new Exception("malformed IP");
Для учета этих стилей, наш промпт DOCUMENTATION_ONLY_PROMPT содержит следующие эвристики:
DOCUMENTATION_ONLY_PROMPT = """... - Обязательный (required: true), если: - Есть проверка: if (empty($params['name'])) throw ... - Есть проверка: if ($location == "") throw ... - Есть проверка: if ($id == -1) throw ... - Нет оператора ?? или ?: с дефолтным значением - Опциональный (required: false), если: - Есть $params['active'] ?? 1 или $params['ip'] ?: '' - Есть дефолт: $location = htmlspecialchars($_GET['location'] ?? "" ...) - Проверяется только на тип, не на наличие: if (!filter_var($ip, ...)) ... """
В ip.php присутствует ещё одна тонкость: дефолтное значение $id = -1 используется как сигнал отсутствия параметра, а не как реальное значение:
$id = (int)($_GET['id'] ?? $_POST['id'] ?? -1); case "get_range_ip": if ($id == -1) throw new Exception("range id required"); // $id == -1 означает "не передан" case "set_main": if (!$id) throw new Exception("invalid request"); // $id == 0 тоже считается отсутствием
LLM должна понять эту конвенцию и применять -1 как sentinel value.
Проблема 6: несколько match/switch в одном файле
В ip.php у нас только один switch($action) на весь файл. Но в том же api_keys.php мы имеем уже три match-блока:
1. Маппинг прав:
$permission = match ($action) { 'list', 'list_for_server', ... => 'view', 'add', 'edit', 'delete', ... => 'edit', };
2. Диспатч вызовов:
'data' => match ($action) { 'list' => api_keys_list($res['customer_id']), 'add' => api_keys_add($params, ...), };
3. Локализация уведомлений:
$message = match($action) { "add" => ["header" => "Новый API ключ", ...], };
Тут я поступил просто — система берёт первый найденный match/switch по паттерну $action, потому что в типичной структуре Invapi основной диспатч стоит выше вспомогательных конструкций. Но это не гарантируется и мы дополняем данный способ другими проверками.
RBAC-фильтрация: обращение к API прав и нечёткое сравнение имён
Дополнительно к коду, клиентские функции отделены от внутренних функций системы через RBAC.
Поэтому в clean_adminonly_whitelist.py мы обращаемся к RBAC API Invapi и получаем список функций с admin_only=1.
Извлечение case-блоков и три LLM-вызова на метод
Когда система знает список методов в конкретном .php файле, ей нужно вырезать код каждого метода для передачи в LLM для его описания. Это одна из самых интересных функций в системе. Её задача вырезать из большого PHP-файла только код конкретного case-блока, чтобы передать в LLM минимизированный контекст и сэкономить как скорость, так и время обработки.

Рассмотрим на примере ip.php. Если передать весь файл целиком, то нам понадобится 15 КБ или 4000 токенов. А если файл будет на 500Kb (а есть и такие) и больше? Но LLM нужен только один конкретный блок, например get_ip, а это всего 15 строк.
Функция работает в три этапа:
1. Поиск начала case-блока
def extract_action_code(php_code: str, action_name: str, context_lines: int = 30) -> str: # Ищем строку: case "get_ip": или case 'get_ip': case_pattern = re.compile( rf'^\s*case\s+["\']({re.escape(action_name)})["\']\s*:', re.MULTILINE ) case_matches = list(case_pattern.finditer(php_code)) if case_matches: match = case_matches[0] # Берём первое вхождение start_line = php_code[:match.start()].count('\n') # Номер строки начала
Здесь re.MULTILINE применяем, чтобы ^ работал с началом каждой строки, а не всего текста, а re.escape(action_name) чтобы спецсимволы в имени метода не сломали regex.
2. Поиск конца case-блока — подсчёт скобок
В PHP case в switch не создаёт свою область видимости:
switch ($action) { case "get_ip": // Начало блока if (!filter_var($ip, ...)) { // { — brace_depth = 1 throw new Exception(); // } // } — brace_depth = 0 $res = ipv4_get_network($ip); // Ещё код break; // case "set_main": // Следующий case это КОНЕЦ предыдущего // ... }
Если просто искать case-строку с помощью regex, можно захватить лишний код. Поэтому система считает глубину вложенности скобок:
next_case_re = re.compile(r'^\s*(case\s+|default\s*:)') brace_depth = 0 # Глубина вложенных {} ВНУТРИ case end_line = start_line for i in range(start_line + 1, total): line = lines[i] stripped = line.strip() # Проходим по каждому символу строки for ch in line: if ch == '{': brace_depth += 1 # Входим в if/else/for/while elif ch == '}': if brace_depth > 0: brace_depth -= 1 # Выходим из вложенного блока else: # } на уровне 0 — закрывающая скобка switch end_line = i - 1 break
Ключевой момент данного кода brace_depth == 0 означает, что мы находимся на уровне switch, а не внутри вложенного if. Когда на этом уровне встречается case или default:, то это конец текущего блока:
# На уровне switch (brace_depth == 0) проверяем case/default if brace_depth == 0 and next_case_re.match(stripped): end_line = i - 1 break
Для вызова ip.php/change_ip это критично: блок содержит if/else, try/catch, foreach то есть достаточное число вложенных скобок. Простой regex до следующего case просто обрежет середину.
3. Сборка результата
Найдя начало и конец, функция собирает два фрагмента: заголовок файла (первые 30 строк: use, require, объявления переменных) и сам case-блок:
def _build_extracted_result( php_code, lines, start_line, end_line, action_name, context_lines, total ): result_parts = [] # Первые N строк — объявления, use, namespace header_end = min(context_lines, total) result_parts.append("// === FILE HEADER (context) ===") result_parts.extend(lines[:header_end]) # Выделенный блок result_parts.append(f"// === ACTION: {action_name} ===") result_parts.extend(lines[start_line:end_line + 1]) extracted = '\n'.join(result_parts) log(f"extract_action_code: {original_tokens} -> {extracted_tokens} токенов") return extracted
Заголовок нужен, чтобы LLM знала, какие use-импорты и переменные доступны. Без этого модель не поймёт, что $ip это $_POST['ip'], а PlatformException это исключение из HostKey\InvApi\Exceptions.
Для метода ip/get_ip мы получаем по итогу следующую картину:
// === FILE HEADER (context) === use HostKey\InvApi\Exceptions\PlatformException; require_once dirname(__DIR__) . "/init.php"; require_once __DIR__ . "/func/func.php"; ... $ip = htmlspecialchars($_POST['ip'] ?? $_GET['ip'] ?? ""); $full = (bool)($_POST['full'] ?? $_GET['full'] ?? false); // === ACTION: get_ip === case "get_ip": { if (!filter_var($ip, FILTER_VALIDATE_IP)) throw new Exception("malformed IP=$ip"); if ($ip == "127.0.0.1") die(json_encode(["result" => -1, "message" => "No data for loopback"])); $res = ipv4_get_network($ip); if (!is_array($res)) { throw new PlatformException("unknown IP $ip", -1, $module, $action); } die(json_encode($full ? ["data" => $res] + $response : $res + $response, JSON_THROW_ON_ERROR)); break; }
Для файлов без switch может быть найден второй паттерн if ($action == "name"):
if_pattern = re.compile( rf'if\s*\(\s*\$action\s*==\s*["\']({re.escape(action_name)})["\']\s*\)', re.MULTILINE ) if_match = if_pattern.search(php_code) if if_match: start_line = php_code[:if_match.start()].count('\n') # Ищем закрывающую } для этого if-блока brace_count = 0 found_open = False for i in range(start_line, total): for ch in lines[i]: if ch == '{': brace_count += 1 found_open = True elif ch == '}': brace_count -= 1 if found_open and brace_count <= 0: end_line = i break
Третий паттерн это match ($action) { 'name' => ... } и он встречается в PHP 8+ (да, даже вот так, часть кода написана в новых нотациях).
match_pattern = re.compile( rf'["\']({re.escape(action_name)})["\']\s*=>', re.MULTILINE ) match_m = match_pattern.search(php_code) if match_m: start_line = php_code[:match_m.start()].count('\n') # Ищем конец: следующий '...' => или закрывающая } next_match_re = re.compile(r'^\s*["\'][^"\']+["\']\s*=>')
В некоторых файлах (например в ранее упомянутом api_keys.php) присутствуют все три паттерна и система последовательно пробует каждый.
В результате предыдущих действий LLM находит все клиентские методы и формирует yaml файл с ними, так называемый «белый список» whitelist_config.yaml, а также словарь методов в формате JSON.
Этап 3: Формирование Markdown-документации. От JSON до финального текста
К этому моменту система уже знает всё о методе: его имя, описание, список параметров с типами и обязательностью, примеры ответов и ошибок. Но это всё только JSON внутри Python-словаря. Нужно превратить это в красивый Markdown документ.
Откуда берутся данные? На предыдущих этапах LLM сгенерировала JSON-структуру для каждого метода. Вот пример для ip/get_ip:
{ "action_name": "get_ip", "action_type": "получение информации об IP", "http_method": "POST", "description": "Возвращает полную информацию об IP-адресе", "required_role": null, "parameters": [ {"name": "token", "type": "string", "required": true, "description": "Токен авторизации"}, {"name": "ip", "type": "string", "required": true, "description": "IPv4-адрес"} ], "success_response": {"result": "OK", "module": "ip", "action": "get_ip", "ip": "192.168.1.1", "network": "192.168.1.0"}, "error_responses": {"code": -1, "message": "malformed IP"} }
Эти данные уже прошли валидацию. Regex-ом мы проверили их структуру, а нейросеть сверила их с исходным кодом. Далее мы собираем их в файл в формате Markdown по шагам.
Шаг 1: Таблица параметров
Скрипт обходит список параметров метода и формирует каждую строку таблицы. Первая строка это всегда action с значением имени метода. Затем мы добавляем каждый параметр из JSON. В результате получаем вот такую таблицу для нашего метода:
Параметр | Обязательный | Тип | Описание |
|---|---|---|---|
action | ✅ | string | get_ip |
token | ✅ | string | Токен авторизации |
ip | ✅ | string | IPv4-адрес |
Шаг 2: curl-пример
Далее скрипт собирает команду curl из обязательных параметров. Для каждого required: true параметра добавляется строка --data "name=value". Параметр token получает плейсхолдер HOSTKEY_TOKEN вместо реального значения:
curl_params_parts = [f' --data "action={action_name}"'] for param in method.get('parameters', []): param_name = param.get('name', '') if _is_required(param.get('required')): if param_name == 'token': curl_params_parts.append(f' --data "{param_name}=HOSTKEY_TOKEN"') else: example = param.get('example', 'VALUE') curl_params_parts.append(f' --data "{param_name}={example}"') curl_params = ' \\\n'.join(curl_params_parts)
Для массивов (is_array: true) curl получает повторяющийся ключ:
--data "tags[]=value1" \ --data "tags[]=value2"
Шаг 3: JSON-ответы
Объекты success_response и error_responses форматируются через json.dumps с отступами в 2 пробела. Каждая строка дополнительно получает отступ на 2 пробела, потому что в Markdown-блоке кода внутри специального блока MkDocs ??? success нужен отступ в 4 пробела:
success_response_json = json.dumps( method.get('success_response', {}), ensure_ascii=False, indent=2 ) # Добавляем отступ для MkDocs admonition success_response_json = '\n'.join( ' ' + line if line else line for line in success_response_json.split('\n') )
Шаг 4: Сборка промпта
Все подготовленные куски — таблица, curl, JSON-ответы, ошибки — подставляются в шаблон промпта MARKDOWN_ONLY_PROMPT. Этот промпт содержит инструкцию для LLM: «Верни ТОЛЬКО markdown, без пояснений» плюс готовый каркас:
markdown_prompt = MARKDOWN_ONLY_PROMPT.format( action_name=action_name, description=method.get('description', ''), http_method=method.get('http_method', 'POST'), params_table=params_table, curl_params=curl_params, success_response_json=success_response_json, error_responses_json=error_responses_json, api_base_url=api_base_url.rstrip('/'), filename=filename_no_ext, endpoint=f"{api_base_url}/{filename_no_ext}" )
Шаг 5: LLM дорабатывает текст
LLM получает уже готовые данные и генерирует финальный Markdown с описанием, примерами кода и форматированием. Как видно по предыдущим шагам, это происходит не «с нуля». Модель финально оборачивает структурированные данные в единообразный Markdown типа такого:
## **ip/get_ip** Возвращает полную информацию об IP-адресе: сеть, маску, шлюз, локацию. **HTTP-метод:** POST **Параметры:** | Параметр | Обязательный | Тип | Описание | |----------|-------------|-----|----------| | action | ✅ | string | `get_ip` | | token | ✅ | string | Токен авторизации | | ip | ✅ | string | IPv4-адрес для запроса | !!! question "Пример запроса" ```bash curl -s "https://invapi.hostkey.ru/ip.php" -X POST \ --data "action=get_ip" \ --data "token=HOSTKEY_TOKEN" \ --data "ip=192.168.1.1" ``` ??? success "Пример успешного ответа" ```json { "result": "OK", "module": "ip", "action": "get_ip", "ip": "192.168.1.1", "network": "192.168.1.0", "netmask": "255.255.255.0", "gateway": "192.168.1.1", "location": "NL" } ``` ??? failure "Примеры ошибок" ```json {"code": -1, "message": "malformed IP=not-an-ip"} ```
Данные действия повторяем для каждого метода .php файлов Invapi и формируем из них полный список методов. Дополнительно формируем заголовочную структуру и общую таблицу методов, а из всех файлов собираем общий файл API вызовов.
Важно, что мы не генерирует Markdown целиком через LLM. Мы разбиваем задачу на этапы, когда Python-код собирает таблицу, curl и JSON программно, а LLM только добавляет описания и форматирует итоговый результат.
Этот гибридный подход гарантирует единообразие документации и уменьшение числа ошибок из-за галлюцинаций нейросети. Например, если бы LLM генерировала таблицу параметров с нуля, она могла бы пропустить обязательный параметр или неправильно указать тип. А так таблица собирается из уже проверенного JSON, а на долю LLM остаётся только описание.
Этап 4: Мониторинг GitLab. Заставляем неросеть анализировать git-diff
Сгенерированная документация иногда устаревает уже на следующий день в результате правок кода разработчиками. Если код продолжает меняться, то скрипт monitor_invapi_docs_llm.py решает эту проблему — он отслеживает коммиты в GitLab и автоматически запускает регенерацию затронутых файлов.
Монитор обращается к GitLab API и запрашивает список коммитов за последние N часов (по умолчанию 24). Фильтр по пути app/, чтобы не реагировать на изменения в тестах, конфигах или фронтенде.
Для каждого изменённого PHP-файла извлекается git-diff и передаётся в LLM. Модель должна ответить на три вопроса:
Затрагивает ли изменение клиентское API?
Какие методы изменились?
Нужно ли обновлять белый список?
Делаем мы это с помощью следующего промпта:
"""Ты — экспертный аналитик изменений в PHP-репозитории InvAPI. Твоя задача: определить, требуют ли изменения обновления документации клиентского API. Отвечай ТОЛЬКО валидным JSON: { "affects_client_api": true/false, "files_to_update_docs": ["file1.php"], "changed_methods": {"filename.php": ["method1", "method2"]}, "deleted_methods": {"filename.php": ["old_method"]}, "needs_whitelist_update": true/false, "reason": "Краткое пояснение", "confidence": 0.95 } """
Ключевой критерий здесь affects_client_api. Если разработчик поправил логирование или отформатировал код, то это не затрагивает клиентов и документацию трогать не нужно. Если же добавился новый параметр или изменился формат ответа, то критерий affects_client_api вернет значение true.
Если нейросетевая модель не смогла определить изменённые методы (например, дифф слишком большой или модель ответила невалидным JSON), система извлекает их из диффа через regex и ищет добавленные строки с объявлением функций:
Самая опасная ситуация — это удаление метода из кода. Документация описывает несуществующий метод, клиент пытается его вызвать и получает ошибку. Монитор моего кода отслеживает удаления через LLM и генерирует Markdown-отчёт с чек-листом для ручной правки.
Важно, что система не удаляет документацию автоматически. Это связано с тем, что разработчики могут удалить метод, а потом через пару часов откатить изменения или система определит после новых правок метод не как клиентский и т.п.
Также перед реальным обновлением можно запустить монитор в режиме --dry-run. Он покажет, какие файлы были бы обновлены, без записи изменений. Это полезно для проверки: не пропустила ли LLM важное изменение или не сработал ли fallback-regex на ложном срабатывании.
В итоге у меня получилась следующая структура вызовов LLM
Этап | Скрипт | Промпт | Температура | Среднее число используемых токенов |
|---|---|---|---|---|
Белый список | Invapi_docs_agent.py | WHITELIST_ONLY_PROMPT | 0.05 | 16 000 |
Анализ методов | Invapi_docs_agent.py | DOCUMENTATION_ONLY_PROMPT | 0.05 | 24 000 |
Regex-валидация | — | — | — | |
LLM-валидация | Invapi_docs_agent.py | VALIDATION_PROMPT | 0.10 | 8 000 |
Генерация Markdown | Invapi_docs_agent.py | MARKDOWN_ONLY_PROMPT | 0.05 | 24 000 |
Анализ коммитов | monitor_Invapi_docs_llm.py | (встроенный в код) | 0.10 | 1 500 |
Все вызовы идут на локальный инстанс Ollama (localhost:11434) с моделью gemma4:26b-it-qat. Ограничение OLLAMA_MAX_CTX=128000 критично для больших файлов вроде eq.php (~242 КБ) или whmcs.php (~344 Kb) и его можно расширить и до 256K максимально поддерживаемых моделью.
Также в системе есть динамический расчет размера контекста, для оптимальной загрузки GPU, исправление только определенных методов в файлах и другие ускоряющие примочки типа двух параллельных потоков на двух инстансах Ollama. Все хозяйство крутится на сервере с двумя Nvidia Tesla V100 c 16 Гб видеопамяти.
Заключение
Такая система из ~4000 строк Python достаточно неплохо решает задачу динамической генерации документации API Invapi, которая начинается с поиска методов в запутанном PHP-коде и заканчивается красиво оформленным разделом на сайте. Комбинация программных проверок с LLM позволяет избежать большинства ошибок, а для нас — команды документирования — ежедневного разбирательства в новых мерджах кода.
Да, это система не идеальна, да могут быть определенные ошибки, которые чаще всего решаются запуском перегенерации того или иного метода в ручном режиме. Но пока разработчики обещают выкатить REST API с нормальной автоматизированной сборкой документации, приходится выкручиваться так. И данная система уже три месяца (запустил я ее в начале мая) ежедневно проверяет изменения в коде и вносит правки в документацию.
P.S. По тому же принципу построена генерация полной документации в корпоративной xwiki, но там добавляется еще один этап конвертации .md документации в xwiki формат и заливки ее через API на Вики.
LLM-модели на вашем сервере Последние версии популярных LLM-моделей уже предустановлены на вашем сервере. |

