docs: 3 стенда (test/dev/prod), приоритет curated-примеров, инцидент генератора nav
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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) Запустить операцию.
|
||||
|
||||
Reference in New Issue
Block a user