feat(assist): режим 'редактор файла' POST /<stand>/_api/file (terraform.tfvars)

This commit is contained in:
Repinoid
2026-10-12 08:40:05 +03:00
parent 5d9986271a
commit 1fcfa4f070
2 changed files with 264 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
'use strict';
/**
* filemode.js — режим «редактор файла» для сайта документации Nubes.
*
* Отличие от POST /_api/assist: там — справка по строгому шаблону, здесь —
* работа с содержимым файла юзера (обычно terraform.tfvars). Юзер вставляет
* содержимое файла + инструкцию; LLM возвращает ПОЛНЫЙ обновлённый файл,
* список изменений и предупреждения. Деплой (terraform plan/apply) юзер
* делает сам — этот режим ничего не деплоит и не хранит файл.
*
* Безопасность:
* - секреты сюда НЕ кладут — они живут в отдельном secrets.auto.tfvars
* (см. AGENTS.md); здесь допустимы только заглушки вида <token>;
* - логируем ТОЛЬКО размеры, содержимое файла — никогда;
* - лимит тела FILE_MAX_BODY (64 КБ по умолчанию).
*/
const { llmChat, jsonError } = require('./assist.js');
const { search, stripHtml } = require('./search.js');
const { fetchIndex, clientIp } = require('./orchestrator.js');
const MAX_BODY = intEnv('FILE_MAX_BODY', 65536); // макс. размер файла юзера, байт
const MAX_TOKENS = intEnv('FILE_MAX_TOKENS', 4096); // бюджет ответа LLM (полный файл)
function intEnv(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === null || raw === '') return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}
/**
* Системный промпт режима. Жёсткое требование — вернуть JSON с ПОЛНЫМ файлом
* (не фрагмент), чтобы юзер мог заменить файл целиком. Запрет выдумывать
* имена полей и значения: опора только на документацию Nubes в блоке ниже.
*/
const SYSTEM_PROMPT =
'Ты — ассистент Terraform-провайдера Nubes, редактор terraform.tfvars. ' +
'Пользователь прислал содержимое своего файла и инструкцию. ' +
'Верни СТРОГО JSON-объект вида {"content":"...","changes":["..."],"warnings":["..."]}. ' +
'content — ПОЛНЫЙ обновлённый файл (весь, не фрагмент), сохраняя комментарии и значения, ' +
'которые не менялись; changes — список внесённых изменений; warnings — предупреждения. ' +
'Используй ТОЛЬКО параметры из документации Nubes ниже; не выдумывай имён полей и значений. ' +
'Секреты/токены/пароли не трогай — они в отдельном файле; значения вида <token> оставляй как есть. ' +
'Отвечай на русском.';
/**
* Читает тело запроса со счётчиком байт; при превышении MAX_BODY обрывает поток.
*
* @param {import('http').IncomingMessage} req
* @returns {Promise<{ok:true, body:string}|{ok:false, code:number}>}
*/
function readBody(req) {
return new Promise((resolve) => {
const chunks = [];
let total = 0;
let settled = false;
const done = (r) => { if (!settled) { settled = true; resolve(r); } };
req.on('data', (c) => {
if (settled) return;
total += c.length;
if (total > MAX_BODY) {
req.destroy();
done({ ok: false, code: 413 });
return;
}
chunks.push(c);
});
req.on('end', () => done({ ok: true, body: Buffer.concat(chunks).toString('utf8') }));
req.on('error', () => done({ ok: false, code: 400 }));
});
}
/**
* Извлекает JSON-объект из сырого ответа LLM (устойчиво к markdown-ограждениям).
*
* @param {string} raw
* @returns {object|null}
*/
function extractJson(raw) {
const s = String(raw ?? '').replace(/```(?:json)?/gi, '').replace(/```/g, '').trim();
const a = s.indexOf('{');
const b = s.lastIndexOf('}');
if (a === -1 || b <= a) return null;
try { return JSON.parse(s.slice(a, b + 1)); } catch { return null; }
}
/**
* Главный обработчик POST /<stand>/_api/file.
*
* @param {import('http').IncomingMessage} req
* @param {import('http').ServerResponse} res
* @param {object} config
* @param {string} config.stand
* @param {string} config.vmDocsBaseUrl
* @param {object} config.shared — разделяемые состояния (limiter, semaphore) из orchestrator.
*/
async function handleFile(req, res, config) {
const stand = config.stand;
const vmDocsBaseUrl = config.vmDocsBaseUrl;
const shared = config.shared;
// 1) Content-Type.
const ct = (req.headers['content-type'] || '').toLowerCase();
if (!ct.startsWith('application/json')) {
jsonError(res, 415, 415, 'unsupported media type');
return;
}
// 2) Тело с лимитом + разбор {content, instruction}.
const read = await readBody(req);
if (!read.ok) {
jsonError(res, read.code, read.code, 'body too large');
return;
}
let parsed;
try { parsed = JSON.parse(read.body); } catch {
jsonError(res, 400, 400, 'invalid json');
return;
}
const content = typeof parsed.content === 'string' ? parsed.content : '';
const instruction = typeof parsed.instruction === 'string' ? parsed.instruction.trim() : '';
if (!content.trim()) {
jsonError(res, 400, 400, 'content is required');
return;
}
// 3) Rate-limit (общий limiter с ассистентом; ключ — IP клиента).
const lim = shared.limiter.check(clientIp(req));
if (!lim.ok) {
jsonError(res, 429, 'rate_limited', 'too many requests', { 'Retry-After': String(lim.retryAfterSec) });
return;
}
// 4) Семафор (общий с ассистентом; сверх — 503 busy).
if (!shared.semaphore.tryAcquire()) {
jsonError(res, 503, 'busy', 'server is busy, retry later', { 'Retry-After': '5' });
return;
}
// Отмена вызова при закрытии соединения клиентом.
const controller = new AbortController();
const onClose = () => controller.abort();
req.on('close', onClose);
try {
// 5) Контекст из документации: ищем по инструкции (иначе по содержимому).
const query = instruction || content;
let docsBlock = '';
const index = await fetchIndex(stand, vmDocsBaseUrl);
if (index) {
const hits = search(index, query, { topK: 5 });
docsBlock = hits
.map((h, i) => `[${i + 1}] ${h.title}\n${stripHtml(h.text).slice(0, 1200)}`)
.join('\n\n');
}
if (!docsBlock) {
docsBlock = '(документация не найдена — используй только общие правила, не выдумывай параметры)';
}
const userContent =
'ДОКУМЕНТАЦИЯ:\n' + docsBlock +
'\n\nСОДЕРЖИМОЕ ФАЙЛА:\n' + content +
'\n\nИНСТРУКЦИЯ:\n' + (instruction || '(проверь и исправь ошибки)');
const out = await llmChat({
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userContent },
],
maxTokens: MAX_TOKENS,
temperature: 0.1,
responseFormat: { type: 'json_object' },
controller,
baseUrl: process.env.LLM_BASE_URL || '',
apiKey: process.env.LLM_API_KEY || '',
model: process.env.LLM_MODEL || 'gpt-oss-120b',
});
const obj = extractJson(out);
jsonOk(res, 200, {
content: (obj && typeof obj.content === 'string') ? obj.content : out,
changes: (obj && Array.isArray(obj.changes)) ? obj.changes : [],
warnings: (obj && Array.isArray(obj.warnings)) ? obj.warnings : [],
meta: {
model: process.env.LLM_MODEL || 'gpt-oss-120b',
input_bytes: content.length,
},
});
logFile('ok', stand, content.length, instruction.length);
} catch (err) {
const code = err && err.code ? err.code : 'internal_error';
jsonError(res, statusFor(code), code, err && err.message ? err.message : 'internal error');
logFile('error', stand, content.length, instruction.length);
} finally {
try { shared.semaphore.release(); } catch { /* не должен случиться */ }
req.removeListener('close', onClose);
}
}
/** Отправка JSON-ответа 200 (Content-Length считается явно). */
function jsonOk(res, status, obj) {
const body = JSON.stringify(obj);
if (!res.headersSent) {
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
});
}
res.end(body);
}
/** Маппинг кодов ошибок LLM на HTTP-статусы (аналогично orchestrator.mapPipelineError). */
function statusFor(code) {
switch (code) {
case 'llm_timeout': return 504;
case 'bad_llm_output':
case 'empty_content': return 502;
case 'aborted': return 499;
case 'rate_limited': return 503;
default: return 502;
}
}
/** Однострочный JSON-лог БЕЗ содержимого файла (только размеры). */
function logFile(status, stand, contentLen, instrLen) {
try {
process.stdout.write(JSON.stringify({
t: new Date().toISOString(),
mode: 'file',
status,
stand,
content_len: contentLen,
instr_len: instrLen,
}) + '\n');
} catch { /* лог не должен ронять */ }
}
module.exports = { handleFile };
+24
View File
@@ -83,6 +83,15 @@ try {
assistShared = null;
}
// Режим «редактор файла» (terraform.tfvars): лениво грузим, не роняем статику.
let fileHandler = null;
try {
const filemode = require('./filemode.js');
fileHandler = filemode.handleFile;
} catch (_) {
fileHandler = null;
}
const server = http.createServer((req, res) => {
// POST /<stand>/_api/assist — ветка AI-ассистента (до проверки 405).
if (req.method === 'POST') {
@@ -101,6 +110,21 @@ const server = http.createServer((req, res) => {
});
return;
}
const fileMatch = path.match(/^\/([A-Za-z0-9._-]+)\/_api\/file$/);
if (fileMatch && fileHandler && assistShared) {
const stand = fileMatch[1];
fileHandler(req, res, { stand, vmDocsBaseUrl: VM_DOCS_BASE_URL, shared: assistShared })
.catch(() => {
try {
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'internal_error', message: 'file mode failed' }));
}
} catch (_) { }
});
return;
}
}
if (req.method !== 'GET' && req.method !== 'HEAD') {