#!/usr/bin/env python3 """ LLM-улучшатель документации Nubes Terraform Provider. Прогоняет сгенерированные docs-generator'ом .md файлы через LLM, чтобы сделать описания читаемыми и логичными. Использование: python3 05_generate_docs_llm.py generated/test/docs [--out generated/test/docs_llm] """ import json, os, re, shutil, sys, time, socket from pathlib import Path from urllib.request import Request, urlopen API_URL = "https://api.aillm.ru/v1/chat/completions" API_KEY = "sk-ucI5YvOticoOQ9Kuj5K9mQ" MODEL = "gpt-oss-120b" SYSTEM_PROMPT = """Ты — технический писатель Nubes Terraform Provider. Улучшаешь автогенерированные Markdown-файлы документации. ═══════════════════════════════════════════════════════ АБСОЛЮТНЫЕ ЗАПРЕТЫ ═══════════════════════════════════════════════════════ - НЕ выдумывай имена параметров, типы, значения по умолчанию - НЕ трогай HCL-блоки (всё внутри ```hcl ... ```) - НЕ трогай имена параметров в таблицах (snake_case / camelCase из API) - НЕ трогай навигационные строки вида [Manual](x.md) · [Create params](y.md) ... - НЕ трогай блоки `!!! danger` — это автоматические предупреждения - НЕ добавляй и не удаляй строки/колонки в таблицах - Верни ТОЛЬКО готовый текст файла. Без объяснений, без``` вокруг всего текста ═══════════════════════════════════════════════════════ ПРАВИЛА ДЛЯ ТАБЛИЦ ПАРАМЕТРОВ (_params_create.md, _params_modify.md) ═══════════════════════════════════════════════════════ Таблицы create-параметров содержат столбцы: Параметр | Тип | Обязательный | По умолчанию | Описание | Ограничения Таблицы modify-параметров содержат столбцы: Code | Type | Description | Constraints Таблицы вложенных параметров (### map-fixed) содержат столбцы: Code | Type | Required | Default | Description | Constraints Можно менять ТОЛЬКО текст в колонках Описание/Description и Ограничения/Constraints. НЕ трогай колонки: Параметр/Code, Тип/Type, Обязательный/Required, По умолчанию/Default. ПРАВИЛО A — value_list в Constraints: value_list=1, 3, 5, 7 → очисти ячейку Constraints до пустой. В ячейку Description добавь строку: «Допустимые значения: **1, 3, 5, 7**» Если в Description уже был текст — добавь после него, через пробел или перевод строки (
). ПРАВИЛО B — regex в Constraints: Замени regex-строку на читаемое описание формата: - cron-подобный regex → «Формат: cron-выражение. Пример: `0 0 * * *`» - UUID regex → «Формат: UUID» - IP-адрес regex → «Формат: IP-адрес» - Прочее → кратко опиши формат своими словами ПРАВИЛО C — пустое Description (пустая ячейка или —): Напиши краткое описание параметра (1–2 предложения). Приоритет источников: 1. MAN — ищи текст, связанный с параметром по смыслу и по именам sub-params 2. Имя параметра snake_case → понятный русский 3. Тип и контекст соседних параметров в группе ПРАВИЛО D — верхнеуровневые группы (строки с map-fixed или array-map-fixed): Эти строки — контейнеры, в них вложены sub-params. Если Description пустое — напиши 1 предложение: что содержит группа и зачем. Смотри на имена sub-params (они идут в следующих строках) + MAN. Пример: clusterConfiguration с sub-params cpu/memory/disk/replicas → «Ресурсы пода кластера: CPU (milicores), RAM (MB), диск (GB) и количество реплик» Пример: backupConfiguration с sub-params s3_uid/retain/schedule → «Параметры резервного копирования: S3-хранилище, расписание и глубина хранения» ═══════════════════════════════════════════════════════ ПРАВИЛА ДЛЯ ОПЕРАЦИЙ (_ops.md) ═══════════════════════════════════════════════════════ Операции без описания (пустая строка, нет текста после —): Напиши 1 предложение о том, что делает операция с ресурсом. Используй MAN если передан. Не придумывай параметров. Универсальные шаблоны (если MAN не помогает): suspend → «Приостановка ресурса (поды остановлены, данные сохранены)» resume → «Запуск ранее остановленного ресурса» restart → «Перезапуск подов ресурса. ⚠️ Возможна кратковременная недоступность» reconcile → «Принудительная синхронизация состояния с API» recovery → «Восстановление из резервной копии» ═══════════════════════════════════════════════════════ ПРАВИЛА ДЛЯ ГЛАВНОЙ СТРАНИЦЫ (Name.md — блок
) ═══════════════════════════════════════════════════════ MAN находится внутри `
Справка (MAN)` — НЕ трогай теги details/summary. Внутри — HTML в `
`. Преобразуй HTML → читаемый Markdown:

/

→ ## / ###
  • → - элемент списка → **текст** → `текст` текст → [текст](url)
    ,

    ,

    → удали тег, замени переносами строк где нужно Лишние пустые строки подряд → одна пустая строка Сохраняй всё смысловое содержание. Не перефразируй, не сокращай. ═══════════════════════════════════════════════════════ ПРАВИЛА ДЛЯ ПРИМЕРОВ (_example.md) ═══════════════════════════════════════════════════════ Строки с TODO — замени на типичный реальный пример если он предсказуем: resource_name = "TODO" → "my-postgres" resource_realm = "TODO" → "k8s-3-sandbox-nubes-ru" # укажите ваш кластер master_ip_space = "TODO" → "internet-no-antiddos-v1" # из вашей организации slave_ip_space = "TODO" → "internet-no-antiddos-v1" # из вашей организации Оставь TODO если значение непредсказуемо (UUID чужого ресурса): s3_uid = "TODO" → s3_uid = "TODO" # UUID ресурса nubes_s3 из state: nubes_s3.backup_store.id """ def call_llm(prompt: str) -> str: data = json.dumps({ "model": MODEL, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}, ], "temperature": 0.15, "max_tokens": 8192, }).encode() req = Request(API_URL, data=data, headers={ "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", }) for attempt in range(2): try: socket.setdefaulttimeout(90) with urlopen(req, timeout=90) as resp: result = json.loads(resp.read()) content = result["choices"][0]["message"]["content"].strip() if content.startswith("```"): lines = content.split("\n") if len(lines) > 2: content = "\n".join(lines[1:-1]) return content except Exception as e: print(f" retry {attempt+1}/2: {e}", file=sys.stderr) time.sleep(3) raise RuntimeError("LLM failed after 2 retries") def extract_man(text: str) -> str: """Вырезает блок ## MAN из главной страницы.""" m = re.search(r'## MAN\s*\n(.*?)(?=\n## |\Z)', text, re.DOTALL) return m.group(1).strip() if m else "" def build_prompt(file_type: str, filename: str, content: str, man: str = "") -> str: """Формирует промпт для LLM с типом файла и опциональным MAN-контекстом.""" parts = [f"Тип файла: {file_type}"] if man: parts.append(f"\n=== MAN СЕРВИСА (контекст для описаний групп и параметров) ===\n{man}") parts.append(f"\n=== {filename} ===\n{content}") return "\n".join(parts) def file_type(name: str) -> str: """Определяет тип файла по имени.""" if name.endswith("_params_create"): return "ПАРАМЕТРЫ СОЗДАНИЯ" if name.endswith("_params_modify"): return "ПАРАМЕТРЫ ИЗМЕНЕНИЯ" if name.endswith("_ops"): return "ОПЕРАЦИИ" if name.endswith("_example"): return "ПРИМЕР" return "ГЛАВНАЯ СТРАНИЦА" def needs_man_context(name: str) -> bool: """Нужен ли MAN-контекст для этого типа файла.""" return any(name.endswith(s) for s in ("_params_create", "_params_modify", "_ops")) def is_llm_target(name: str) -> bool: """Файлы, которые обрабатываются через LLM.""" return any(name.endswith(s) for s in ("_params_create", "_params_modify", "_ops", "_example")) or ( "_" not in name and name != "index" ) def main(): docs_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("generated/test/docs") if not docs_dir.exists(): print(f"ERROR: {docs_dir} not found", file=sys.stderr) sys.exit(1) # Определяем выходную директорию out_dir = docs_dir for i, arg in enumerate(sys.argv): if arg == "--out" and i + 1 < len(sys.argv): out_dir = Path(sys.argv[i + 1]) break out_dir.mkdir(parents=True, exist_ok=True) # Находим все сервисы (по главным страницам без суффиксов и без subresource) all_md = sorted(docs_dir.glob("*.md")) services = set() for f in all_md: name = f.stem if name == "index": continue if "_" not in name: services.add(name) if not services: print("No services found", file=sys.stderr) sys.exit(1) print(f"Services: {len(services)}") failed = [] total = 0 for svc in sorted(services): print(f"\n--- {svc} ---") # Читаем MAN из главной страницы (raw docs) main_file = docs_dir / f"{svc}.md" man_text = "" if main_file.exists(): man_text = extract_man(main_file.read_text()) # Обрабатываем все файлы сервиса svc_files = sorted(docs_dir.glob(f"{svc}*.md")) for f in svc_files: name = f.stem if not is_llm_target(name): # Копируем как есть: outputs, params (landing), subresource dest = out_dir / f.name shutil.copy2(f, dest) continue total += 1 dest = out_dir / f.name # Skip already processed files if dest.exists() and dest.stat().st_size > 100: print(f" {f.name}... SKIP (already done)", flush=True) continue print(f" {f.name}...", end=" ", flush=True) content = f.read_text() if len(content) > 12000: content = content[:12000] + "\n\n... (обрезано)\n" ft = file_type(name) man = man_text if needs_man_context(name) else "" prompt = build_prompt(ft, f.name, content, man) try: improved = call_llm(prompt) if improved and len(improved) > 100: dest = out_dir / f.name dest.write_text(improved) print("OK") else: # fallback: копируем оригинал dest = out_dir / f.name shutil.copy2(f, dest) print("SKIP (empty response, copied original)") except Exception as e: dest = out_dir / f.name shutil.copy2(f, dest) print(f"FAILED: {e} (copied original)") failed.append(f.name) time.sleep(1.5) # Копируем index.md index_src = docs_dir / "index.md" if index_src.exists(): shutil.copy2(index_src, out_dir / "index.md") # Копируем 30_registry если есть for sub in ["30_registry", "guides"]: src = docs_dir.parent / sub if not src.exists(): src = docs_dir / sub if src.exists(): dest = out_dir / sub if dest.exists(): shutil.rmtree(dest) shutil.copytree(src, dest) print(f" copied {sub}/") print(f"\nProcessed {total} files. Failed: {len(failed)}") if failed: print(f"Failed: {', '.join(failed)}") if __name__ == "__main__": main()