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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user