docs: 3 стенда (test/dev/prod), приоритет curated-примеров, инцидент генератора nav

This commit is contained in:
Repinoid
2026-10-07 20:33:44 +03:00
parent 382b7b0e9b
commit f1a774d6ef
3 changed files with 139 additions and 97 deletions
+44 -2
View File
@@ -18,6 +18,22 @@
---
## 1.1 Стенды
Ассистент **stand-aware**: `stand` берётся из первого сегмента пути, индекс —
из `VM_DOCS_BASE_URL/<stand>/search/search_index.json`. Включён на всех трёх стендах:
| Стенд | URL | Виджет | Эндпоинт |
|---|---|---|---|
| `nubes-test` | `/nubes-test/` | ✅ | ✅ |
| `nubes-dev` | `/nubes-dev/` | ✅ | ✅ |
| `nubes` (prod) | `/nubes/` | ✅ | ✅ |
Каждый стенд отвечает по **своему** индексу. Кэш индекса/ответов и лимиты
ключуются по стенду.
---
## 2. Архитектура
```
@@ -154,8 +170,11 @@ tf_docs (Node.js, кластер nodejsk8s) [orchestrator.js]
отбрасывание стоп-слов (рус. + англ.).
- **Транслит** (`TRANSLIT` в `search.js`): «постгрес»→`postgres`, «бакет»→`bucket`,
«топик»→`topic` и т.д.
- **Буст по типу страницы**: страница ресурса ×2.0, руководство (`_manual`) ×0.6,
служебная (`curated`/`30_registry`) ×0.4.
- **Буст по типу страницы** (приоритет — «примеры в первую очередь»):
- `curated/*` (готовые примеры, проверенные на реальных деплоях) — **×2.4**;
- страница ресурса — ×2.0;
- руководство (`_manual`) — ×0.6;
- служебная (`30_registry`) — ×0.4.
- **Подресурс → родитель**: `nubes_postgres_database` при поиске дополняется
токеном `postgres` (`resourceBaseSegment`).
- **Предтокенизация**: токены всех 823 документов считаются один раз при загрузке
@@ -283,3 +302,26 @@ tf_docs (Node.js, кластер nodejsk8s) [orchestrator.js]
6. Один под (replicas=1) — лимиты/кэш не шарятся между репликами (принято в брифе 7.4).
7. Задержка доминируется LLM; ускоряется стримингом (SSE) или параллелизацией
этапа 1 с поиском (v2).
---
## 14. Исправленный инцидент: сборка `nubes-dev`
При публикации dev-документации сборка падала на YAML-ошибке nav:
```
- [DEPRECATED] Публичные IP адреса: ← невалидный YAML
```
**Причина:** генератор `TOOLS/docs-generator/internal/writers/writers.go`
(`WriteNavFragment`) писал `ServiceDisplayName` без кавычек. У `vcexternalip` в
dev имя = `[DEPRECATED] Публичные IP адреса` → ломал парсер.
**Фикс (корень):** в генератор добавлен `yamlScalar()` — заголовки с спецсимволами
(`[ ] : # & *` и др.) кавычатся. Затронул все стенды на будущее.
**Разовый обход:** для немедленной публикации dev строка в
`generated/dev/docs/_nav_fragment.yml` закавычена вручную (файл генерируемый,
перезапишется корректно после фикса генератора).
Коммит фикса: `tf_provider` `322235c`.
+65 -65
View File
@@ -19,91 +19,91 @@ const BASE_URL = 'https://api.aillm.ru';
const MODEL = 'gpt-oss-120b';
const QUESTIONS = [
'openbao для секретов в кластере',
'как создать виртуальную машину',
'как развернуть rabbitmq',
'nodejs приложение задеплоить',
'как развернуть gitea',
'pgadmin для управления базами',
'openbao для секретов в кластере',
'как создать виртуальную машину',
'как развернуть rabbitmq',
'nodejs приложение задеплоить',
'как развернуть gitea',
'pgadmin для управления базами',
];
const REQUIRED_HEADERS = ['Роль', 'Задача', 'Контекст', 'Требования', 'Ограничения'];
const STAGE2_SYSTEM_PROMPT =
'Ты — ассистент документации Terraform-провайдера Nubes. ' +
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
'Отвечай на русском языке.';
'Ты — ассистент документации Terraform-провайдера Nubes. ' +
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
'Отвечай на русском языке.';
function findApiKey() {
const lines = fs.readFileSync(KEY_FILE, 'utf8').split(/\r?\n/);
for (const l of lines) {
const t = l.trim();
if (t.startsWith('sk-')) return t;
}
return null;
const lines = fs.readFileSync(KEY_FILE, 'utf8').split(/\r?\n/);
for (const l of lines) {
const t = l.trim();
if (t.startsWith('sk-')) return t;
}
return null;
}
function httpGet(url) {
return new Promise((resolve, reject) => {
const mod = url.startsWith('https') ? https : http;
const req = mod.get(url, (res) => {
if (res.statusCode !== 200) { res.resume(); reject(new Error('status ' + res.statusCode)); return; }
let d = '';
res.on('data', (c) => { d += c; });
res.on('end', () => resolve(d));
return new Promise((resolve, reject) => {
const mod = url.startsWith('https') ? https : http;
const req = mod.get(url, (res) => {
if (res.statusCode !== 200) { res.resume(); reject(new Error('status ' + res.statusCode)); return; }
let d = '';
res.on('data', (c) => { d += c; });
res.on('end', () => resolve(d));
});
req.on('error', reject);
req.setTimeout(15000, () => req.destroy(new Error('timeout')));
});
req.on('error', reject);
req.setTimeout(15000, () => req.destroy(new Error('timeout')));
});
}
async function llmRaw(messages, maxTokens, apiKey) {
const resp = await fetch(`${BASE_URL}/v1/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, messages, max_tokens: maxTokens, temperature: 0.2 }),
signal: AbortSignal.timeout(60000),
});
const j = await resp.json();
const msg = j && j.choices && j.choices[0] && j.choices[0].message;
return { content: msg ? msg.content : null, finish: j.choices && j.choices[0] ? j.choices[0].finish_reason : null };
const resp = await fetch(`${BASE_URL}/v1/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, messages, max_tokens: maxTokens, temperature: 0.2 }),
signal: AbortSignal.timeout(60000),
});
const j = await resp.json();
const msg = j && j.choices && j.choices[0] && j.choices[0].message;
return { content: msg ? msg.content : null, finish: j.choices && j.choices[0] ? j.choices[0].finish_reason : null };
}
async function main() {
const { loadIndex } = require('../../search.js');
const { mergeSearchResults, buildContext } = require('../../stage2.js');
const { stage1ExtractResourceType } = require('../../stage1.js');
const { loadIndex } = require('../../search.js');
const { mergeSearchResults, buildContext } = require('../../stage2.js');
const { stage1ExtractResourceType } = require('../../stage1.js');
const apiKey = findApiKey();
const raw = await httpGet(`${VM}/${STAND}/search/search_index.json`);
const idx = loadIndex(raw);
const types = idx.listResourceTypes();
console.log('Индекс docs:', idx.docs.length, '| ресурсов:', types.size, '\n');
const apiKey = findApiKey();
const raw = await httpGet(`${VM}/${STAND}/search/search_index.json`);
const idx = loadIndex(raw);
const types = idx.listResourceTypes();
console.log('Индекс docs:', idx.docs.length, '| ресурсов:', types.size, '\n');
for (const q of QUESTIONS) {
console.log('='.repeat(80));
console.log('ВОПРОС:', q);
const r1 = await stage1ExtractResourceType(q, { resourceTypesSet: types, baseUrl: BASE_URL, apiKey, model: MODEL });
const rt = r1.ok ? r1.resourceType : null;
console.log('этап1:', JSON.stringify(r1));
const merged = mergeSearchResults(idx, q, rt, { topK: 5 });
console.log('контекст:', merged.map((m) => m.location).join(' , '));
const ctx = buildContext(merged, 1500);
const docsBlock = ctx.map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`).join('\n\n');
const userContent = 'ДОКУМЕНТАЦИЯ:\n' + (docsBlock || '(нет релевантных фрагментов)') + '\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' + q;
const messages = [{ role: 'system', content: STAGE2_SYSTEM_PROMPT }, { role: 'user', content: userContent }];
for (const q of QUESTIONS) {
console.log('='.repeat(80));
console.log('ВОПРОС:', q);
const r1 = await stage1ExtractResourceType(q, { resourceTypesSet: types, baseUrl: BASE_URL, apiKey, model: MODEL });
const rt = r1.ok ? r1.resourceType : null;
console.log('этап1:', JSON.stringify(r1));
const merged = mergeSearchResults(idx, q, rt, { topK: 5 });
console.log('контекст:', merged.map((m) => m.location).join(' , '));
const ctx = buildContext(merged, 1500);
const docsBlock = ctx.map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`).join('\n\n');
const userContent = 'ДОКУМЕНТАЦИЯ:\n' + (docsBlock || '(нет релевантных фрагментов)') + '\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' + q;
const messages = [{ role: 'system', content: STAGE2_SYSTEM_PROMPT }, { role: 'user', content: userContent }];
const out = await llmRaw(messages, 1024, apiKey);
console.log('finish_reason:', out.finish, '| content len:', out.content ? out.content.length : 0);
const missing = out.content ? REQUIRED_HEADERS.filter((h) => !out.content.includes(h + ':')) : REQUIRED_HEADERS;
console.log('НЕТ заголовков:', missing.join(', ') || '(нет, всё ок)');
console.log('--- СЫРОЙ ОТВЕТ (первые 500) ---');
console.log((out.content || '(null)').slice(0, 500));
console.log('');
}
const out = await llmRaw(messages, 1024, apiKey);
console.log('finish_reason:', out.finish, '| content len:', out.content ? out.content.length : 0);
const missing = out.content ? REQUIRED_HEADERS.filter((h) => !out.content.includes(h + ':')) : REQUIRED_HEADERS;
console.log('НЕТ заголовков:', missing.join(', ') || '(нет, всё ок)');
console.log('--- СЫРОЙ ОТВЕТ (первые 500) ---');
console.log((out.content || '(null)').slice(0, 500));
console.log('');
}
}
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
+30 -30
View File
@@ -51,15 +51,15 @@ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function main() {
console.log('Инстанс:', UID, '| операция:', ACTION);
// 1) Состояние инстанса: доступные операции + текущие params.
const st = await api('GET', `/instances/${UID}`);
if (st.status !== 200) {
console.error('Ошибка GET /instances:', st.status, st.text.slice(0, 300));
process.exit(1);
}
const inst = (st.json && st.json.instance) || {};
console.log('Статус:', inst.explainedStatus, '| inProgress:', inst.operationIsInProgress, '| pending:', inst.operationIsPending);
const instanceParams = (inst.state && inst.state.params) || {};
// 1) Состояние инстанса: доступные операции + текущие params.
const st = await api('GET', `/instances/${UID}`);
if (st.status !== 200) {
console.error('Ошибка GET /instances:', st.status, st.text.slice(0, 300));
process.exit(1);
}
const inst = (st.json && st.json.instance) || {};
console.log('Статус:', inst.explainedStatus, '| inProgress:', inst.operationIsInProgress, '| pending:', inst.operationIsPending);
const instanceParams = (inst.state && inst.state.params) || {};
const ops = inst.availableOperations || [];
const op = ops.find((o) => String(o.operation).toLowerCase() === ACTION.toLowerCase());
@@ -81,28 +81,28 @@ async function main() {
}
if (!opUid) opUid = uidFromLocation(created.location);
if (!opUid) opUid = created.text.replace(/^"|"$/g, '').trim();
console.log('Создана операция:', created.status, '| opUid:', opUid);
if (!opUid || opUid.length < 8) {
console.error('Не удалось получить opUid. Ответ:', created.text.slice(0, 300));
process.exit(1);
}
// 2б) Установить требуемые CFS-параметры (например startupConfiguration) из live-состояния.
const cfs = await api('GET', `/instanceOperations/${opUid}?fields=cfsParams`);
const cfsParams = (cfs.json && cfs.json.instanceOperation && cfs.json.instanceOperation.cfsParams) || [];
for (const p of cfsParams) {
const val = instanceParams[p.svcOperationCfsParam];
if (val === undefined) {
console.log(' параметр', p.svcOperationCfsParam, '(id=' + p.svcOperationCfsParamId + ') — в состоянии нет, пропуск');
continue;
console.log('Создана операция:', created.status, '| opUid:', opUid);
if (!opUid || opUid.length < 8) {
console.error('Не удалось получить opUid. Ответ:', created.text.slice(0, 300));
process.exit(1);
}
const paramValue = typeof val === 'string' ? val : JSON.stringify(val);
const pr = await api('POST', '/instanceOperationCfsParams', {
instanceOperationUid: opUid,
svcOperationCfsParamId: p.svcOperationCfsParamId,
paramValue: paramValue,
});
console.log(' параметр', p.svcOperationCfsParam, '=', paramValue, '->', pr.status);
// 2б) Установить требуемые CFS-параметры (например startupConfiguration) из live-состояния.
const cfs = await api('GET', `/instanceOperations/${opUid}?fields=cfsParams`);
const cfsParams = (cfs.json && cfs.json.instanceOperation && cfs.json.instanceOperation.cfsParams) || [];
for (const p of cfsParams) {
const val = instanceParams[p.svcOperationCfsParam];
if (val === undefined) {
console.log(' параметр', p.svcOperationCfsParam, '(id=' + p.svcOperationCfsParamId + ') — в состоянии нет, пропуск');
continue;
}
const paramValue = typeof val === 'string' ? val : JSON.stringify(val);
const pr = await api('POST', '/instanceOperationCfsParams', {
instanceOperationUid: opUid,
svcOperationCfsParamId: p.svcOperationCfsParamId,
paramValue: paramValue,
});
console.log(' параметр', p.svcOperationCfsParam, '=', paramValue, '->', pr.status);
}
// 3) Запустить операцию.