Files
tf_docs/filemode.js

255 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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');
// Лимит на тело запроса (JSON {content, instruction}). terraform.tfvars — файл
// ПАРАМЕТРОВ: в примерах он ≤ ~2.5 КБ; 8 КБ даёт запас на комментарии. Больше —
// это «запихнул весь проект», такое отклоняем понятным сообщением.
const MAX_BODY = intEnv('FILE_MAX_BODY', 8192);
const MAX_TOKENS = intEnv('FILE_MAX_TOKENS', 4096); // бюджет ответа LLM (полный файл)
const TOO_BIG_MSG = 'слишком много данных: это файл параметров (terraform.tfvars), ' +
'а не весь проект. Лимит ' + MAX_BODY + ' байт.';
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;
}
// 1б) Content-Length больше лимита — отказ БЕЗ чтения тела. Иначе при
// превышении в процессе чтения мы вынуждены оборвать поток (req.destroy()),
// и клиент получает обрыв соединения вместо чистого 413. См. assist.js.
const declaredLen = Number.parseInt(req.headers['content-length'], 10);
if (Number.isFinite(declaredLen) && declaredLen > MAX_BODY) {
jsonError(res, 413, 413, TOO_BIG_MSG);
return;
}
// 2) Тело с лимитом + разбор {content, instruction}.
const read = await readBody(req);
if (!read.ok) {
jsonError(res, read.code, read.code, read.code === 413 ? TOO_BIG_MSG : 'bad request body');
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 };