Files
tf_provider/TOOLS/scripts/05_generate_docs_llm.py
T

293 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
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 уже был текст — добавь после него, через пробел или перевод строки (<br/>).
ПРАВИЛО 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 — блок <details>)
═══════════════════════════════════════════════════════
MAN находится внутри `<details><summary>Справка (MAN)</summary>` — НЕ трогай теги details/summary.
Внутри — HTML в `<div class="man-content" markdown="1">`.
Преобразуй HTML → читаемый Markdown:
<h2>/<h3> → ## / ###
<ul><li> → - элемент списка
<strong> → **текст**
<code> → `текст`
<a href="url">текст</a> → [текст](url)
<br/>, <p>, <div> → удали тег, замени переносами строк где нужно
Лишние пустые строки подряд → одна пустая строка
Сохраняй всё смысловое содержание. Не перефразируй, не сокращай.
═══════════════════════════════════════════════════════
ПРАВИЛА ДЛЯ ПРИМЕРОВ (_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(3):
try:
with urlopen(req, timeout=120) 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}/3: {e}", file=sys.stderr)
time.sleep(5)
raise RuntimeError("LLM failed after 3 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
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()