07.09.2026

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

server one
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'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.

    1. Поиск конца 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 просто обрежет середину.

    1. Сборка результата

    Найдя начало и конец, функция собирает два фрагмента: заголовок файла (первые 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. Модель должна ответить на три вопроса: 

    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-моделей уже предустановлены на вашем сервере.

    Другие статьи

    04.09.2026

    Как собрать AI-контур на VPS: подписки ChatGPT и Claude, OmniRoute, LiteLLM и разработка без VPN

    VPN приходится держать включённым на каждой машине, и он всё равно рвётся посреди работы. Сессия слетает, авторизацию приходится проходить заново, а иногда прилетает бан аккаунта, за который вы уже заплатили. Показываем альтернативу: один VPS со статическим IP, LiteLLM как единая точка входа ко всем моделям и OmniRoute для маршрутизации между провайдерами. Разбираем конфиги, подключение IDE и агентов.

    25.08.2026

    JupyterLab на GPU-сервере: полное руководство по настройке для команды в 2026 году

    Пошаговое руководство по развертыванию JupyterLab на GPU-сервере. Настройка JupyterHub, драйверов NVIDIA, безопасности (Nginx/SSL), лимитов ресурсов и мониторинга.

    09.08.2026

    NVIDIA RTX PRO 5000 Blackwell с 72 Гб видеопамяти. Есть ли смысл переплачивать за «половинку» флагмана?

    RTX PRO 5000 Blackwell на 72 Гб: золотая середина для локальных ИИ-моделей или переоцененный апгрейд? Разбираемся в нашем обзоре.

    01.08.2026

    Большие модели и цена миллиона токенов

    Китайские модели дешевле или это ловушка? Разбираемся, как не переплачивать за токены и почему цена в прайсе — еще не вся правда о расходах на ИИ.

    20.07.2026

    Внутренняя документация, которую никто не читает. Как сделать, чтобы читали (на примере ONLYOFFICE Workspace)

    Документация умирает не от лени сотрудников, а из-за неудобства и потери доверия к данным. Разбираем «два кита» качественной базы знаний: удобство использования и контроль актуальности. Показываем на примере ONLYOFFICE Workspace, как превратить хаос в работающий процесс с помощью шаблонов, ролевой модели доступа и дисциплины пересмотра.

    Upload