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("\n\n") + for _, sp := range p.SubParams { + req := "no" + if sp.Required { + req = "**yes**" + } + b.WriteString("") + b.WriteString(fmt.Sprintf("", escapeText(formatID(sp.ID)))) + b.WriteString(fmt.Sprintf("", formatParamCode(sp.Code))) + b.WriteString(fmt.Sprintf("", formatTypeCell(sp))) + b.WriteString(fmt.Sprintf("", req)) + b.WriteString(fmt.Sprintf("", defaultCell(sp.Default))) + b.WriteString(fmt.Sprintf("", escapeText(pickTextTable(sp)))) + b.WriteString(fmt.Sprintf("", escapeText(collectConstraints(sp)))) + b.WriteString("\n") + } + b.WriteString("
IDCodeTypeRequiredDefaultDescriptionConstraints
%s%s%s%s%s%s%s
\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()