- BrainTools - https://www.braintools.ru -

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

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

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

Посмотреть [1]

Общая схема: конвейер из пяти агентов

Система документирования 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 сильно делегируют логику [2]. Тот же контроллер eq.php — это тонкий маршрутизатор, который вызывает функции из файла func/func_eq.php. Чтобы LLM могла корректно описать, что возвращает метод, ей нужен код не только контроллера, но и вызываемых функций.

Индексатор php_func_indexer.py [3] сканирует все .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 этот конфликт [4] не выражен так явно, как в других файлах. Например в 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 [5] мы обращаемся к 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-ответы, ошибки [6] — подставляются в шаблон промпта 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"}
    ```

Данные действия повторяем [7] для каждого метода .php файлов Invapi и формируем из них полный список методов. Дополнительно формируем заголовочную структуру и общую таблицу методов, а из всех файлов собираем общий файл API вызовов.

Важно, что мы не генерирует Markdown целиком через LLM. Мы разбиваем задачу на этапы, когда Python-код собирает таблицу, curl и JSON программно, а LLM только добавляет описания и форматирует итоговый результат. 

Этот гибридный подход гарантирует единообразие документации и уменьшение числа ошибок из-за галлюцинаций нейросети. Например, если бы LLM генерировала таблицу параметров с нуля, она могла бы пропустить обязательный параметр или неправильно указать тип. А так таблица собирается из уже проверенного JSON, а на долю LLM остаётся только описание.

Этап 4: Мониторинг GitLab. Заставляем неросеть анализировать git-diff

Сгенерированная документация иногда устаревает уже на следующий день в результате правок кода разработчиками. Если код продолжает меняться, то скрипт monitor_invapi_docs_llm.py [8] решает эту проблему — он отслеживает коммиты в GitLab и автоматически запускает регенерацию затронутых файлов.

Монитор обращается к GitLab API и запрашивает список коммитов за последние N часов (по умолчанию 24). Фильтр по пути app/, чтобы не реагировать [9] на изменения в тестах, конфигах или фронтенде.

Для каждого изменённого 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 [10]

WHITELIST_ONLY_PROMPT

0.05

16 000

Анализ методов

Invapi_docs_agent.py [10]

DOCUMENTATION_ONLY_PROMPT

0.05

24 000

Regex-валидация

validator.py [11]

LLM-валидация

Invapi_docs_agent.py [10]

VALIDATION_PROMPT

0.10

8 000

Генерация Markdown

Invapi_docs_agent.py [10]

MARKDOWN_ONLY_PROMPT

0.05

24 000

Анализ коммитов

monitor_Invapi_docs_llm.py [8]

(встроенный в код)

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

Посмотреть [1]

Автор: akdengi

Источник [12]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/35327

URLs in this post:

[1] Посмотреть: https://hostkey.ru/apps/llms/

[2] логику: http://www.braintools.ru/article/7640

[3] indexer.py: http://indexer.py

[4] конфликт: http://www.braintools.ru/article/7708

[5] whitelist.py: http://whitelist.py

[6] ошибки: http://www.braintools.ru/article/4192

[7] повторяем: http://www.braintools.ru/article/4012

[8] llm.py: http://llm.py

[9] реагировать: http://www.braintools.ru/article/1549

[10] agent.py: http://agent.py

[11] validator.py: http://validator.py

[12] Источник: https://habr.com/ru/companies/hostkey/articles/1077728/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1077728

www.BrainTools.ru

Rambler's Top100