293 lines
15 KiB
Python
293 lines
15 KiB
Python
#!/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()
|