fix(stage2): устранена обрезка ответа (max_tokens 2048 + повтор) и проверка заголовков для кириллицы

This commit is contained in:
Repinoid
2026-10-07 15:43:23 +03:00
parent 3ae8089cb5
commit b99344eed5
7 changed files with 2878 additions and 22 deletions
+109
View File
@@ -0,0 +1,109 @@
'use strict';
/**
* debug_bad_llm_output.js — воспроизводит этап 2 локально для вопросов,
* на которых прод вернул 502 bad_llm_output, и печатает СЫРОЙ ответ LLM.
*
* Использует тот же промпт и тот же индекс (живой), что и прод.
* Запуск: node llm_project/tests/debug_bad_llm_output.js
*/
const fs = require('fs');
const https = require('https');
const http = require('http');
const STAND = 'nubes-test';
const VM = 'http://5.172.178.213';
const KEY_FILE = '/home/naeel/nubes/llm.key';
const BASE_URL = 'https://api.aillm.ru';
const MODEL = 'gpt-oss-120b';
const QUESTIONS = [
'openbao для секретов в кластере',
'как создать виртуальную машину',
'как развернуть rabbitmq',
'nodejs приложение задеплоить',
'как развернуть gitea',
'pgadmin для управления базами',
];
const REQUIRED_HEADERS = ['Роль', 'Задача', 'Контекст', 'Требования', 'Ограничения'];
const STAGE2_SYSTEM_PROMPT =
'Ты — ассистент документации 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;
}
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));
});
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 };
}
async function main() {
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');
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('');
}
}
main().catch((e) => { console.error('FATAL', e); process.exit(1); });