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. Архитектура ## 2. Архитектура
``` ```
@@ -154,8 +170,11 @@ tf_docs (Node.js, кластер nodejsk8s) [orchestrator.js]
отбрасывание стоп-слов (рус. + англ.). отбрасывание стоп-слов (рус. + англ.).
- **Транслит** (`TRANSLIT` в `search.js`): «постгрес»→`postgres`, «бакет»→`bucket`, - **Транслит** (`TRANSLIT` в `search.js`): «постгрес»→`postgres`, «бакет»→`bucket`,
«топик»→`topic` и т.д. «топик»→`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` при поиске дополняется - **Подресурс → родитель**: `nubes_postgres_database` при поиске дополняется
токеном `postgres` (`resourceBaseSegment`). токеном `postgres` (`resourceBaseSegment`).
- **Предтокенизация**: токены всех 823 документов считаются один раз при загрузке - **Предтокенизация**: токены всех 823 документов считаются один раз при загрузке
@@ -283,3 +302,26 @@ tf_docs (Node.js, кластер nodejsk8s) [orchestrator.js]
6. Один под (replicas=1) — лимиты/кэш не шарятся между репликами (принято в брифе 7.4). 6. Один под (replicas=1) — лимиты/кэш не шарятся между репликами (принято в брифе 7.4).
7. Задержка доминируется LLM; ускоряется стримингом (SSE) или параллелизацией 7. Задержка доминируется LLM; ускоряется стримингом (SSE) или параллелизацией
этапа 1 с поиском (v2). этапа 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 MODEL = 'gpt-oss-120b';
const QUESTIONS = [ const QUESTIONS = [
'openbao для секретов в кластере', 'openbao для секретов в кластере',
'как создать виртуальную машину', 'как создать виртуальную машину',
'как развернуть rabbitmq', 'как развернуть rabbitmq',
'nodejs приложение задеплоить', 'nodejs приложение задеплоить',
'как развернуть gitea', 'как развернуть gitea',
'pgadmin для управления базами', 'pgadmin для управления базами',
]; ];
const REQUIRED_HEADERS = ['Роль', 'Задача', 'Контекст', 'Требования', 'Ограничения']; const REQUIRED_HEADERS = ['Роль', 'Задача', 'Контекст', 'Требования', 'Ограничения'];
const STAGE2_SYSTEM_PROMPT = const STAGE2_SYSTEM_PROMPT =
'Ты — ассистент документации Terraform-провайдера Nubes. ' + 'Ты — ассистент документации Terraform-провайдера Nubes. ' +
'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' + 'Составь ответ СТРОГО по шаблону с заголовками-маркерами, каждый на новой строке:\n' +
'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' + 'Роль:\nЗадача:\nКонтекст:\nТребования:\nОграничения:\n' +
'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' + 'Используй ТОЛЬКО факты из блока ДОКУМЕНТАЦИЯ ниже. ' +
'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' + 'Не выдумывай ресурсов, атрибутов и значений, которых нет в документации. ' +
'Отвечай на русском языке.'; 'Отвечай на русском языке.';
function findApiKey() { function findApiKey() {
const lines = fs.readFileSync(KEY_FILE, 'utf8').split(/\r?\n/); const lines = fs.readFileSync(KEY_FILE, 'utf8').split(/\r?\n/);
for (const l of lines) { for (const l of lines) {
const t = l.trim(); const t = l.trim();
if (t.startsWith('sk-')) return t; if (t.startsWith('sk-')) return t;
} }
return null; return null;
} }
function httpGet(url) { function httpGet(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const mod = url.startsWith('https') ? https : http; const mod = url.startsWith('https') ? https : http;
const req = mod.get(url, (res) => { const req = mod.get(url, (res) => {
if (res.statusCode !== 200) { res.resume(); reject(new Error('status ' + res.statusCode)); return; } if (res.statusCode !== 200) { res.resume(); reject(new Error('status ' + res.statusCode)); return; }
let d = ''; let d = '';
res.on('data', (c) => { d += c; }); res.on('data', (c) => { d += c; });
res.on('end', () => resolve(d)); 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) { async function llmRaw(messages, maxTokens, apiKey) {
const resp = await fetch(`${BASE_URL}/v1/chat/completions`, { const resp = await fetch(`${BASE_URL}/v1/chat/completions`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, messages, max_tokens: maxTokens, temperature: 0.2 }), body: JSON.stringify({ model: MODEL, messages, max_tokens: maxTokens, temperature: 0.2 }),
signal: AbortSignal.timeout(60000), signal: AbortSignal.timeout(60000),
}); });
const j = await resp.json(); const j = await resp.json();
const msg = j && j.choices && j.choices[0] && j.choices[0].message; 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 }; return { content: msg ? msg.content : null, finish: j.choices && j.choices[0] ? j.choices[0].finish_reason : null };
} }
async function main() { async function main() {
const { loadIndex } = require('../../search.js'); const { loadIndex } = require('../../search.js');
const { mergeSearchResults, buildContext } = require('../../stage2.js'); const { mergeSearchResults, buildContext } = require('../../stage2.js');
const { stage1ExtractResourceType } = require('../../stage1.js'); const { stage1ExtractResourceType } = require('../../stage1.js');
const apiKey = findApiKey(); const apiKey = findApiKey();
const raw = await httpGet(`${VM}/${STAND}/search/search_index.json`); const raw = await httpGet(`${VM}/${STAND}/search/search_index.json`);
const idx = loadIndex(raw); const idx = loadIndex(raw);
const types = idx.listResourceTypes(); const types = idx.listResourceTypes();
console.log('Индекс docs:', idx.docs.length, '| ресурсов:', types.size, '\n'); console.log('Индекс docs:', idx.docs.length, '| ресурсов:', types.size, '\n');
for (const q of QUESTIONS) { for (const q of QUESTIONS) {
console.log('='.repeat(80)); console.log('='.repeat(80));
console.log('ВОПРОС:', q); console.log('ВОПРОС:', q);
const r1 = await stage1ExtractResourceType(q, { resourceTypesSet: types, baseUrl: BASE_URL, apiKey, model: MODEL }); const r1 = await stage1ExtractResourceType(q, { resourceTypesSet: types, baseUrl: BASE_URL, apiKey, model: MODEL });
const rt = r1.ok ? r1.resourceType : null; const rt = r1.ok ? r1.resourceType : null;
console.log('этап1:', JSON.stringify(r1)); console.log('этап1:', JSON.stringify(r1));
const merged = mergeSearchResults(idx, q, rt, { topK: 5 }); const merged = mergeSearchResults(idx, q, rt, { topK: 5 });
console.log('контекст:', merged.map((m) => m.location).join(' , ')); console.log('контекст:', merged.map((m) => m.location).join(' , '));
const ctx = buildContext(merged, 1500); const ctx = buildContext(merged, 1500);
const docsBlock = ctx.map((c, i) => `[${i + 1}] ${c.title}\n${c.text}`).join('\n\n'); 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 userContent = 'ДОКУМЕНТАЦИЯ:\n' + (docsBlock || '(нет релевантных фрагментов)') + '\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n' + q;
const messages = [{ role: 'system', content: STAGE2_SYSTEM_PROMPT }, { role: 'user', content: userContent }]; const messages = [{ role: 'system', content: STAGE2_SYSTEM_PROMPT }, { role: 'user', content: userContent }];
const out = await llmRaw(messages, 1024, apiKey); const out = await llmRaw(messages, 1024, apiKey);
console.log('finish_reason:', out.finish, '| content len:', out.content ? out.content.length : 0); 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; const missing = out.content ? REQUIRED_HEADERS.filter((h) => !out.content.includes(h + ':')) : REQUIRED_HEADERS;
console.log('НЕТ заголовков:', missing.join(', ') || '(нет, всё ок)'); console.log('НЕТ заголовков:', missing.join(', ') || '(нет, всё ок)');
console.log('--- СЫРОЙ ОТВЕТ (первые 500) ---'); console.log('--- СЫРОЙ ОТВЕТ (первые 500) ---');
console.log((out.content || '(null)').slice(0, 500)); console.log((out.content || '(null)').slice(0, 500));
console.log(''); console.log('');
} }
} }
main().catch((e) => { console.error('FATAL', e); process.exit(1); }); 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() { async function main() {
console.log('Инстанс:', UID, '| операция:', ACTION); console.log('Инстанс:', UID, '| операция:', ACTION);
// 1) Состояние инстанса: доступные операции + текущие params. // 1) Состояние инстанса: доступные операции + текущие params.
const st = await api('GET', `/instances/${UID}`); const st = await api('GET', `/instances/${UID}`);
if (st.status !== 200) { if (st.status !== 200) {
console.error('Ошибка GET /instances:', st.status, st.text.slice(0, 300)); console.error('Ошибка GET /instances:', st.status, st.text.slice(0, 300));
process.exit(1); process.exit(1);
} }
const inst = (st.json && st.json.instance) || {}; const inst = (st.json && st.json.instance) || {};
console.log('Статус:', inst.explainedStatus, '| inProgress:', inst.operationIsInProgress, '| pending:', inst.operationIsPending); console.log('Статус:', inst.explainedStatus, '| inProgress:', inst.operationIsInProgress, '| pending:', inst.operationIsPending);
const instanceParams = (inst.state && inst.state.params) || {}; const instanceParams = (inst.state && inst.state.params) || {};
const ops = inst.availableOperations || []; const ops = inst.availableOperations || [];
const op = ops.find((o) => String(o.operation).toLowerCase() === ACTION.toLowerCase()); 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 = uidFromLocation(created.location);
if (!opUid) opUid = created.text.replace(/^"|"$/g, '').trim(); if (!opUid) opUid = created.text.replace(/^"|"$/g, '').trim();
console.log('Создана операция:', created.status, '| opUid:', opUid); console.log('Создана операция:', created.status, '| opUid:', opUid);
if (!opUid || opUid.length < 8) { if (!opUid || opUid.length < 8) {
console.error('Не удалось получить opUid. Ответ:', created.text.slice(0, 300)); console.error('Не удалось получить opUid. Ответ:', created.text.slice(0, 300));
process.exit(1); 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;
} }
const paramValue = typeof val === 'string' ? val : JSON.stringify(val);
const pr = await api('POST', '/instanceOperationCfsParams', { // 2б) Установить требуемые CFS-параметры (например startupConfiguration) из live-состояния.
instanceOperationUid: opUid, const cfs = await api('GET', `/instanceOperations/${opUid}?fields=cfsParams`);
svcOperationCfsParamId: p.svcOperationCfsParamId, const cfsParams = (cfs.json && cfs.json.instanceOperation && cfs.json.instanceOperation.cfsParams) || [];
paramValue: paramValue, for (const p of cfsParams) {
}); const val = instanceParams[p.svcOperationCfsParam];
console.log(' параметр', p.svcOperationCfsParam, '=', paramValue, '->', pr.status); 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) Запустить операцию. // 3) Запустить операцию.