feat(P1): новый LLM-промпт для документации — правила A-E

- value_list → читаемый текст (Допустимые значения)
- regex → описание формата
- пустые описания → заполнять из MAN
- группы map-fixed → 1 предложение о содержимом
- операции без описания → шаблоны
- MAN-контекст для params/ops файлов
- max_tokens 4096 → 8192
- вывод в docs_llm/ вместо перезаписи docs/
This commit is contained in:
“Naeel”
2026-08-09 21:11:03 +04:00
parent ac2ce0ea53
commit c9a881aa84
8 changed files with 696 additions and 48 deletions
+213 -48
View File
@@ -5,9 +5,9 @@ LLM-улучшатель документации Nubes Terraform Provider.
чтобы сделать описания читаемыми и логичными.
Использование:
python3 05_generate_docs_llm.py generated/test/docs
python3 05_generate_docs_llm.py generated/test/docs [--out generated/test/docs_llm]
"""
import json, os, sys, time
import json, os, re, shutil, sys, time
from pathlib import Path
from urllib.request import Request, urlopen
@@ -15,16 +15,94 @@ API_URL = "https://api.aillm.ru/v1/chat/completions"
API_KEY = "sk-ucI5YvOticoOQ9Kuj5K9mQ"
MODEL = "gpt-oss-120b"
SYSTEM_PROMPT = """Ты — технический писатель. Улучши ОДИН .md файл документации Terraform-провайдера: сделай описания грамотными и логичными.
SYSTEM_PROMPT = """Ты — технический писатель Nubes Terraform Provider.
Улучшаешь автогенерированные Markdown-файлы документации.
═══════════════════════════════════════════════════════
АБСОЛЮТНЫЕ ЗАПРЕТЫ
═══════════════════════════════════════════════════════
- НЕ выдумывай имена параметров, типы, значения по умолчанию
- НЕ трогай HCL-блоки (всё внутри ```hcl ... ```)
- НЕ трогай имена параметров в таблицах (snake_case / camelCase из API)
- НЕ трогай навигационные строки вида [Manual](x.md) · [Create params](y.md) ...
- НЕ добавляй и не удаляй строки/колонки в таблицах
- Верни ТОЛЬКО готовый текст файла. Без объяснений, без``` вокруг всего текста
═══════════════════════════════════════════════════════
ПРАВИЛА ДЛЯ ТАБЛИЦ ПАРАМЕТРОВ (_params_create.md, _params_modify.md)
═══════════════════════════════════════════════════════
Таблицы содержат столбцы: ID | Code | Type | Required | Default | Description | Constraints
Можно менять ТОЛЬКО текст в <td>Description</td> и <td>Constraints</td>.
ПРАВИЛО 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 — секция ## MAN)
═══════════════════════════════════════════════════════
Блок ## MAN содержит HTML внутри <div class="man-content">.
Преобразуй 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
"""
ПРАВИЛА:
1. НЕ ВЫДУМЫВАЙ параметры, типы, значения. Только улучшай формулировки.
2. HTML-таблицы — меняй ТОЛЬКО текст внутри <td>...</td>. НЕ ломай теги.
3. HCL-блоки (```hcl ... ```) — НЕ ТРОГАТЬ вообще.
4. Navigation-строки — НЕ ТРОГАТЬ.
5. MAN-секцию с HTML — переведи в читаемый Markdown.
6. Верни ТОЛЬКО улучшенный Markdown. Без JSON, без пояснений, без ``` в начале/конце.
Просто готовый текст файла."""
def call_llm(prompt: str) -> str:
data = json.dumps({
@@ -34,7 +112,7 @@ def call_llm(prompt: str) -> str:
{"role": "user", "content": prompt},
],
"temperature": 0.15,
"max_tokens": 4096,
"max_tokens": 8192,
}).encode()
req = Request(API_URL, data=data, headers={
"Authorization": f"Bearer {API_KEY}",
@@ -45,7 +123,6 @@ def call_llm(prompt: str) -> str:
with urlopen(req, timeout=120) as resp:
result = json.loads(resp.read())
content = result["choices"][0]["message"]["content"].strip()
# Strip markdown fences if present
if content.startswith("```"):
lines = content.split("\n")
if len(lines) > 2:
@@ -56,61 +133,149 @@ def call_llm(prompt: str) -> str:
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)
# Process only main manual pages: Name.md (not _example, _params_*, _outputs, _ops)
# Определяем выходную директорию
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"))
targets = []
services = set()
for f in all_md:
name = f.stem
if name == "index":
continue
# Skip known non-manual suffixes
if any(name.endswith(sfx) for sfx in ["_example", "_params_create", "_params_modify", "_outputs", "_ops", "_params"]):
continue
# Also skip second-level (subresource) example files
if "_" in name:
# Could be subresource manual like "postgres_database"
# Check if there's a matching _example file
targets.append(f)
else:
targets.append(f)
if "_" not in name:
services.add(name)
print(f"Processing {len(targets)} manual pages...")
if not services:
print("No services found", file=sys.stderr)
sys.exit(1)
print(f"Services: {len(services)}")
failed = []
for i, f in enumerate(targets):
svc = f.stem
print(f" [{i+1}/{len(targets)}] {svc}...", end=" ", flush=True)
total = 0
content = f.read_text()
# Truncate very long files
if len(content) > 12000:
content = content[:12000] + "\n\n... (обрезано для LLM)\n"
for svc in sorted(services):
print(f"\n--- {svc} ---")
prompt = f"Улучши этот файл документации:\n\n=== {f.name} ===\n{content}"
# Читаем MAN из главной страницы (raw docs)
main_file = docs_dir / f"{svc}.md"
man_text = ""
if main_file.exists():
man_text = extract_man(main_file.read_text())
try:
improved = call_llm(prompt)
if improved and len(improved) > 100:
f.write_text(improved)
print("OK")
else:
print("SKIP (empty response)")
except Exception as e:
print(f"FAILED: {e}")
failed.append(svc)
# Обрабатываем все файлы сервиса
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
time.sleep(1.5)
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"\nFailed ({len(failed)}): {', '.join(failed)}")
else:
print(f"\nAll {len(targets)} OK")
print(f"Failed: {', '.join(failed)}")
if __name__ == "__main__":
main()