Представьте себе код, который был создан достаточно давно,после этого не подвергался сильному рефакторингу, а только дописывался и частично обновлялся. У него нет 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-моделей уже предустановлены на вашем сервере.
Общая схема: конвейер из пяти агентов
Система документирования 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-блока:
-
Маппинг прав:
$permission = match ($action) { 'list', 'list_for_server', ... => 'view', 'add', 'edit', 'delete', ... => 'edit', }; -
Диспатч вызовов:
'data' => match ($action) { 'list' => api_keys_list($res['customer_id']), 'add' => api_keys_add($params, ...), }; -
Локализация уведомлений:
$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 строк.
Функция работает в три этапа:
- Поиск начала 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.
- Поиск конца 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 просто обрежет середину.
- Сборка результата
Найдя начало и конец, функция собирает два фрагмента: заголовок файла (первые 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-валидация |
validator.py |
— |
— |
— |
|
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-моделей уже предустановлены на вашем сервере.