Когда разработчики ленятся писать документацию: описываем клиентский API силами нейросетей. API documentation.. API documentation. automation.. API documentation. automation. DevOps.. API documentation. automation. DevOps. Git.. API documentation. automation. DevOps. Git. hostkey.. API documentation. automation. DevOps. Git. hostkey. llm.. API documentation. automation. DevOps. Git. hostkey. llm. markdown.. API documentation. automation. DevOps. Git. hostkey. llm. markdown. ollama.. API documentation. automation. DevOps. Git. hostkey. llm. markdown. ollama. PHP.. API documentation. automation. DevOps. Git. hostkey. llm. markdown. ollama. PHP. python.. API documentation. automation. DevOps. Git. hostkey. llm. markdown. ollama. PHP. python. Блог компании HOSTKEY.. API documentation. automation. DevOps. Git. hostkey. llm. markdown. ollama. PHP. python. Блог компании HOSTKEY. искусственный интеллект.. API documentation. automation. DevOps. Git. hostkey. llm. markdown. ollama. PHP. python. Блог компании HOSTKEY. искусственный интеллект. Программирование.

Автор: Александр Казанцев, руководитель отдела документации и контента

Представьте себе код, который был создан достаточно давно,после этого не подвергался сильному рефакторингу, а только дописывался и частично обновлялся. У него нет 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 представляет собой не монолитный скрипт, а конвейер из пяти независимых компонентов:

  1. PHPFuncIndexer (LLM);

  2. Whitelist Export (LLM);

  3. Doc Generator (LLM);

  4. Validator (regex+LLM);

  5. 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'functions+([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 минимизированный контекст и сэкономить как скорость, так и время обработки.

Когда разработчики ленятся писать документацию: описываем клиентский API силами нейросетей - 1

Рассмотрим на примере 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*cases+["']({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*(cases+|defaults*:)')
        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 это исключение из HostKeyInvApiExceptions.

Для метода ip/get_ip мы получаем по итогу следующую картину:

// === FILE HEADER (context) ===
use HostKeyInvApiExceptionsPlatformException;
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'ifs*(s*$actions*==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. Модель должна ответить на три вопроса: 

  1. Затрагивает ли изменение клиентское API?

  2. Какие методы изменились? 

  3. Нужно ли обновлять белый список?

Делаем мы это с помощью следующего промпта:

"""Ты — экспертный аналитик изменений в 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-модели на вашем сервере

Последние версии популярных LLM-моделей уже предустановлены на вашем сервере.

Посмотреть

Автор: akdengi

Источник