diff --git a/TOOLS/docs-generator/internal/types/types.go b/TOOLS/docs-generator/internal/types/types.go
index 447119c..41d7a28 100644
--- a/TOOLS/docs-generator/internal/types/types.go
+++ b/TOOLS/docs-generator/internal/types/types.go
@@ -3,23 +3,36 @@ package types
// ParamSpec — параметр операции.
type ParamSpec struct {
- ID int `yaml:"id"`
- Code string `yaml:"code"`
- DataType string `yaml:"data_type"`
- Type string `yaml:"type"`
- Required bool `yaml:"required"`
- Default interface{} `yaml:"default"`
- Descr string `yaml:"descr"`
- Man string `yaml:"man"`
- RefSvcID *int `yaml:"ref_svc_id"`
- Func string `yaml:"func"`
- MinValue *int `yaml:"minvalue"`
- MaxValue *int `yaml:"maxvalue"`
- Regex string `yaml:"regex"`
- ValueList []string `yaml:"value_list"`
- Unique string `yaml:"unique_scope"`
- MaxLength *int `yaml:"maxlength"`
- MinLength *int `yaml:"minlength"`
+ ID int `yaml:"id"`
+ Code string `yaml:"code"`
+ DataType string `yaml:"data_type"`
+ Type string `yaml:"type"`
+ Required bool `yaml:"required"`
+ Default interface{} `yaml:"default"`
+ Descr string `yaml:"descr"`
+ Man string `yaml:"man"`
+ RefSvcID *int `yaml:"ref_svc_id"`
+ Func string `yaml:"func"`
+ MinValue *int `yaml:"minvalue"`
+ MaxValue *int `yaml:"maxvalue"`
+ Regex string `yaml:"regex"`
+ ValueList []string `yaml:"value_list"`
+ Unique string `yaml:"unique_scope"`
+ MaxLength *int `yaml:"maxlength"`
+ MinLength *int `yaml:"minlength"`
+ IsJson bool `yaml:"is_json"`
+ HasSubParams bool `yaml:"has_sub_params"`
+ SubParams []ParamSpec `yaml:"sub_params"`
+}
+
+// FixupSubParams устанавливает HasSubParams=true если есть sub_params.
+func FixupSubParams(params []ParamSpec) {
+ for i := range params {
+ if len(params[i].SubParams) > 0 {
+ params[i].HasSubParams = true
+ FixupSubParams(params[i].SubParams)
+ }
+ }
}
// OperationSpec — операция сервиса.
diff --git a/TOOLS/docs-generator/internal/writers/writers.go b/TOOLS/docs-generator/internal/writers/writers.go
index feaba90..192bf8d 100644
--- a/TOOLS/docs-generator/internal/writers/writers.go
+++ b/TOOLS/docs-generator/internal/writers/writers.go
@@ -182,13 +182,13 @@ func exampleBlock(spec types.ServiceSpec, version string, apiEndpoint string) st
b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name))
for _, p := range requiredParams {
- b.WriteString(formatParamLine(p, " ", true))
+ b.WriteString(formatParamOrBlock(p, " ", true))
}
if len(defaultParams) > 0 {
b.WriteString("\n # Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте\n")
for _, p := range defaultParams {
- b.WriteString(formatParamLine(p, " ", false))
+ b.WriteString(formatParamOrBlock(p, " ", false))
}
}
@@ -205,9 +205,15 @@ func buildCreateParamsPage(spec types.ServiceSpec, nav string) string {
b.WriteString("**Обязательные параметры (вводимые пользователем)**\n\n")
b.WriteString(renderParamTable(requiredParams, true, true))
+ for _, p := range requiredParams {
+ b.WriteString(renderNestedParams(p))
+ }
b.WriteString("\n**Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте**\n")
b.WriteString(renderParamTable(defaultParams, false, false))
+ for _, p := range defaultParams {
+ b.WriteString(renderNestedParams(p))
+ }
lifecycle := spec.Lifecycle
if lifecycle.SuspendOnDestroyDefault || lifecycle.AdoptExistingOnCreateDefault {
@@ -521,6 +527,57 @@ func formatParamLine(p types.ParamSpec, indent string, requiredOnly bool) string
return fmt.Sprintf("%s%s = %s\n", indent, paramCode, value)
}
+// formatParamOrBlock форматирует параметр или вложенный HCL-блок (map-fixed).
+func formatParamOrBlock(p types.ParamSpec, indent string, requiredOnly bool) string {
+ if requiredOnly && !p.Required {
+ return ""
+ }
+ // Для map-fixed генерируем вложенный HCL-блок
+ if p.HasSubParams && len(p.SubParams) > 0 && !p.IsJson {
+ paramCode := ToSnake(p.Code)
+ var b strings.Builder
+ b.WriteString(fmt.Sprintf("%s%s {\n", indent, paramCode))
+ for _, sp := range p.SubParams {
+ spVal := sampleValue(sp)
+ spCode := ToSnake(sp.Code)
+ spComment := strings.TrimSpace(stripHTML(sp.Descr))
+ if spComment == "" {
+ spComment = strings.TrimSpace(stripHTML(sp.Man))
+ }
+ if spComment != "" {
+ b.WriteString(fmt.Sprintf("%s %s = %s # %s\n", indent, spCode, spVal, spComment))
+ } else {
+ b.WriteString(fmt.Sprintf("%s %s = %s\n", indent, spCode, spVal))
+ }
+ }
+ b.WriteString(fmt.Sprintf("%s}\n", indent))
+ return b.String()
+ }
+ // Для array-map-fixed генерируем dynamic блок
+ if p.HasSubParams && len(p.SubParams) > 0 && p.IsJson {
+ paramCode := ToSnake(p.Code)
+ var b strings.Builder
+ b.WriteString(fmt.Sprintf("%s%s {\n", indent, paramCode))
+ b.WriteString(fmt.Sprintf("%s # Каждый элемент массива — объект с полями:\n", indent))
+ for _, sp := range p.SubParams {
+ spVal := sampleValue(sp)
+ spCode := ToSnake(sp.Code)
+ spComment := strings.TrimSpace(stripHTML(sp.Descr))
+ if spComment == "" {
+ spComment = strings.TrimSpace(stripHTML(sp.Man))
+ }
+ if spComment != "" {
+ b.WriteString(fmt.Sprintf("%s %s = %s # %s\n", indent, spCode, spVal, spComment))
+ } else {
+ b.WriteString(fmt.Sprintf("%s %s = %s\n", indent, spCode, spVal))
+ }
+ }
+ b.WriteString(fmt.Sprintf("%s}\n", indent))
+ return b.String()
+ }
+ return formatParamLine(p, indent, requiredOnly)
+}
+
func sampleValue(p types.ParamSpec) string {
if hasDefault(p.Default) {
return formatLiteral(p.Default)
@@ -857,3 +914,37 @@ func LoadCloudOutputSnapshot(root string) map[int]types.CloudOutputSnapshot {
return out
}
+
+// renderNestedParams рендерит вложенные параметры (map-fixed/array-map-fixed).
+func renderNestedParams(p types.ParamSpec) string {
+ if !p.HasSubParams || len(p.SubParams) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ label := p.Code
+ if p.IsJson {
+ label += " (array-map-fixed) — элемент"
+ } else {
+ label += " (map-fixed)"
+ }
+ b.WriteString(fmt.Sprintf("\n### %s\n\n", label))
+ b.WriteString("
\n")
+ b.WriteString("| ID | Code | Type | Required | Default | Description | Constraints |
\n\n")
+ for _, sp := range p.SubParams {
+ req := "no"
+ if sp.Required {
+ req = "**yes**"
+ }
+ b.WriteString("")
+ b.WriteString(fmt.Sprintf("| %s | ", escapeText(formatID(sp.ID))))
+ b.WriteString(fmt.Sprintf("%s | ", formatParamCode(sp.Code)))
+ b.WriteString(fmt.Sprintf("%s | ", formatTypeCell(sp)))
+ b.WriteString(fmt.Sprintf("%s | ", req))
+ b.WriteString(fmt.Sprintf("%s | ", defaultCell(sp.Default)))
+ b.WriteString(fmt.Sprintf("%s | ", escapeText(pickTextTable(sp))))
+ b.WriteString(fmt.Sprintf("%s | ", escapeText(collectConstraints(sp))))
+ b.WriteString("
\n")
+ }
+ b.WriteString("
\n")
+ return b.String()
+}
diff --git a/TOOLS/docs-generator/main.go b/TOOLS/docs-generator/main.go
index a73dde3..d701f64 100644
--- a/TOOLS/docs-generator/main.go
+++ b/TOOLS/docs-generator/main.go
@@ -130,6 +130,9 @@ func loadSpecs(dir string, ordered []types.ServiceMeta) []types.ServiceSpec {
if err := yaml.Unmarshal(b, &spec); err != nil {
return err
}
+ for i := range spec.Operations {
+ types.FixupSubParams(spec.Operations[i].Params)
+ }
specsByID[spec.ServiceID] = spec
return nil
})
diff --git a/TOOLS/scripts/05_generate_docs_llm.py b/TOOLS/scripts/05_generate_docs_llm.py
index baee6e1..b8a431d 100644
--- a/TOOLS/scripts/05_generate_docs_llm.py
+++ b/TOOLS/scripts/05_generate_docs_llm.py
@@ -1,69 +1,40 @@
#!/usr/bin/env python3
-"""LLM-генератор документации для Nubes Terraform Provider.
-Читает YAML-спеки, отправляет в LLM, сохраняет Markdown.
"""
-import json, os, sys, time, yaml
+LLM-улучшатель документации Nubes Terraform Provider.
+Прогоняет сгенерированные docs-generator'ом .md файлы через LLM,
+чтобы сделать описания читаемыми и логичными.
+
+Использование:
+ python3 05_generate_docs_llm.py generated/test/docs
+"""
+import json, os, sys, time
from pathlib import Path
from urllib.request import Request, urlopen
-from urllib.error import URLError
API_URL = "https://api.aillm.ru/v1/chat/completions"
API_KEY = "sk-ucI5YvOticoOQ9Kuj5K9mQ"
MODEL = "gpt-oss-120b"
-PROMPT = """Ты — генератор документации Terraform-провайдера Nubes Cloud.
-Отвечай ТОЛЬКО Markdown-кодом. Никаких пояснений до или после.
+SYSTEM_PROMPT = """Ты — технический писатель. Улучши ОДИН .md файл документации Terraform-провайдера: сделай описания грамотными и логичными.
-⛔ ЖЁСТКИЕ ПРАВИЛА (нарушать нельзя):
+ПРАВИЛА:
+1. НЕ ВЫДУМЫВАЙ параметры, типы, значения. Только улучшай формулировки.
+2. HTML-таблицы — меняй ТОЛЬКО текст внутри ... | . НЕ ломай теги.
+3. HCL-блоки (```hcl ... ```) — НЕ ТРОГАТЬ вообще.
+4. Navigation-строки — НЕ ТРОГАТЬ.
+5. MAN-секцию с HTML — переведи в читаемый Markdown.
+6. Верни ТОЛЬКО улучшенный Markdown. Без JSON, без пояснений, без ``` в начале/конце.
+ Просто готовый текст файла."""
-1. НЕ ВЫДУМЫВАЙ параметры. Только те, что есть в YAML.
- Если у параметра нет description — напиши "—".
- НИКОГДА не придумывай password, role, encoding и т.п.
-
-2. Action-операции. ТОЛЬКО redeploy включается в документацию.
- restart, recovery, reconcile — ИСКЛЮЧИТЬ.
-
-3. Subresource-операции. Каждый subresource → отдельная секция.
- Имя ресурса: nubes_{service}_{subresource}.
-
-4. Lifecycle. suspend_on_destroy=true → "terraform destroy = Suspend".
- adopt_existing_on_create → "terraform apply может подхватить существующий".
-
-5. Outputs. Все поля из outputs.params. vault_secrets помечать как 🔒.
-
-6. MAN. Если service_man есть — вставить как есть в секцию ## MAN.
-
-7. ВЛОЖЕННЫЕ ПАРАМЕТРЫ (map-fixed/array-map-fixed).
- Для каждого map-fixed параметра — показывать ВСЕ его sub_params как вложенную таблицу.
- Для array-map-fixed — показывать структуру элемента.
- Пример:
- ### clusterConfiguration (map-fixed)
- | Параметр | Тип | Обязательный | По умолчанию | Описание |
- |---|---|---|---|---|
- | cpu | integer > 0 | да | 500 | Количество CPU в милликорах |
- | memory | integer > 0 | да | 512 | Память в MB |
-
-ФОРМАТ:
-# Resource nubes_{name}
-## MAN (если есть)
-## Instance Operations (таблица)
-## Create Parameters (таблица — для каждого map-fixed показывать sub_params)
-## Modify Parameters (таблица)
-## Subresources (таблица по каждому)
-## Lifecycle
-## Outputs (таблица)
-
-YAML-спек:
-```yaml
-{yaml_content}
-```"""
-
-def call_llm(prompt_text: str) -> str:
+def call_llm(prompt: str) -> str:
data = json.dumps({
"model": MODEL,
- "messages": [{"role": "user", "content": prompt_text}],
- "temperature": 0.1,
- "max_tokens": 8192,
+ "messages": [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "user", "content": prompt},
+ ],
+ "temperature": 0.15,
+ "max_tokens": 4096,
}).encode()
req = Request(API_URL, data=data, headers={
"Authorization": f"Bearer {API_KEY}",
@@ -73,62 +44,73 @@ def call_llm(prompt_text: str) -> str:
try:
with urlopen(req, timeout=120) as resp:
result = json.loads(resp.read())
- return result["choices"][0]["message"]["content"]
+ content = result["choices"][0]["message"]["content"].strip()
+ # Strip markdown fences if present
+ 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 strip_service_man(yaml_text: str) -> str:
- """Убирает service_man (HTML-руководство) из YAML для сокращения токенов."""
- lines = yaml_text.split('\n')
- out = []
- skip = False
- for line in lines:
- if line.strip().startswith('service_man:'):
- out.append('service_man: "" # omitted for LLM token limit')
- skip = True
- continue
- if skip:
- # service_man может быть многострочным (HTML с отступами)
- if line and line[0] not in (' ', '\t') and ':' in line:
- skip = False
- out.append(line)
- continue
- out.append(line)
- return '\n'.join(out)
-
def main():
- yaml_dir = sys.argv[1] if len(sys.argv) > 1 else "generated/test/resources_yaml"
- docs_dir = sys.argv[2] if len(sys.argv) > 2 else "generated/test/docs_llm"
-
- os.makedirs(docs_dir, exist_ok=True)
- yamls = sorted(Path(yaml_dir).glob("*.yaml"))
- print(f"Processing {len(yamls)} services...")
-
+ 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)
+ all_md = sorted(docs_dir.glob("*.md"))
+ targets = []
+ 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)
+
+ print(f"Processing {len(targets)} manual pages...")
+
failed = []
- for i, yf in enumerate(yamls):
- svc_name = yf.stem.split("_", 1)[1] if "_" in yf.stem else yf.stem
- print(f" [{i+1}/{len(yamls)}] {svc_name}...", end=" ", flush=True)
-
- yaml_text = strip_service_man(yf.read_text())
- prompt = PROMPT.replace("{yaml_content}", yaml_text)
-
+ for i, f in enumerate(targets):
+ svc = f.stem
+ print(f" [{i+1}/{len(targets)}] {svc}...", end=" ", flush=True)
+
+ content = f.read_text()
+ # Truncate very long files
+ if len(content) > 12000:
+ content = content[:12000] + "\n\n... (обрезано для LLM)\n"
+
+ prompt = f"Улучши этот файл документации:\n\n=== {f.name} ===\n{content}"
+
try:
- md = call_llm(prompt)
- out = Path(docs_dir) / f"{yf.stem}.md"
- out.write_text(md)
- print("OK")
+ 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_name)
-
- time.sleep(2) # rate limit
-
+ failed.append(svc)
+
+ time.sleep(1.5)
+
if failed:
print(f"\nFailed ({len(failed)}): {', '.join(failed)}")
else:
- print(f"\nAll {len(yamls)} OK")
+ print(f"\nAll {len(targets)} OK")
if __name__ == "__main__":
main()