First commit
This commit is contained in:
commit
81a3d1216a
5
.env
Normal file
5
.env
Normal file
@ -0,0 +1,5 @@
|
||||
# Скопируй этот файл в .env и заполни своими значениями
|
||||
# cp .env.example .env
|
||||
|
||||
YANDEX_API_KEY=AQVN3txsRFUT7CzISouIsWdt9OjHBiY5LUElzDAL
|
||||
YANDEX_FOLDER_ID=b1g86m7ujd7bebiuah91
|
||||
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
# Скопируй этот файл в .env и заполни своими значениями
|
||||
# cp .env.example .env
|
||||
|
||||
YANDEX_API_KEY=ну тут ключ кароч, он и так в правильном файле есть, так что нечего сюда смотреть
|
||||
YANDEX_FOLDER_ID=b1g86m7ujd7bebiuah91
|
||||
103
README.md
Normal file
103
README.md
Normal file
@ -0,0 +1,103 @@
|
||||
# SUNT — бекенд
|
||||
|
||||
Серверная часть системы мета-мониторинга качества ответов больших языковых моделей.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
sunt-backend/
|
||||
├── server.py # Точка входа, FastAPI-приложение
|
||||
├── services/
|
||||
│ ├── yandex.py # Обёртка над Яндекс Cloud API
|
||||
│ └── meta_prompt.py # Промпты для модели
|
||||
├── frontend/ # Сюда кладёшь файлы фронтенда
|
||||
│ ├── index.html
|
||||
│ ├── styles.css
|
||||
│ └── app.js
|
||||
├── .env # API-ключ (не коммитить в git!)
|
||||
├── .env.example # Шаблон для .env
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Установка и запуск
|
||||
|
||||
### 1. Создать виртуальное окружение
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
|
||||
# Windows
|
||||
venv\Scripts\activate
|
||||
|
||||
# Linux / macOS
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 2. Установить зависимости
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. Создать файл .env
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Открыть `.env` и вписать свой API-ключ:
|
||||
|
||||
```
|
||||
YANDEX_API_KEY=ваш_апи_ключ
|
||||
YANDEX_FOLDER_ID=b1g86m7ujd7bebiuah91
|
||||
```
|
||||
|
||||
### 4. Положить файлы фронтенда
|
||||
|
||||
Скопировать `index.html`, `styles.css`, `app.js` в папку `frontend/`.
|
||||
|
||||
### 5. Запустить сервер
|
||||
|
||||
```bash
|
||||
uvicorn server:app --host 0.0.0.0 --port 3000 --reload
|
||||
```
|
||||
|
||||
После этого открыть в браузере: [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
Флаг `--reload` позволяет серверу автоматически перезапускаться при изменении кода — удобно при разработке. На продакшен-сервере его убрать.
|
||||
|
||||
## Деплой на сервер
|
||||
|
||||
Команда для запуска на сервере (без автоперезагрузки, с несколькими воркерами):
|
||||
|
||||
```bash
|
||||
uvicorn server:app --host 0.0.0.0 --port 3000 --workers 2
|
||||
```
|
||||
|
||||
Если используется nginx как reverse proxy, проксировать на `localhost:3000`.
|
||||
|
||||
## API
|
||||
|
||||
### POST /api/message
|
||||
|
||||
Единственный эндпоинт. Принимает JSON двух видов.
|
||||
|
||||
**Стандартный запрос:**
|
||||
```json
|
||||
{
|
||||
"type": "standard_request",
|
||||
"user_request": "Текст запроса пользователя",
|
||||
"mode": "standard"
|
||||
}
|
||||
```
|
||||
|
||||
**Запрос мета-мониторинга:**
|
||||
```json
|
||||
{
|
||||
"type": "meta_monitoring_request",
|
||||
"user_request": "Текст исходного запроса пользователя",
|
||||
"model_answer": "Текст ответа стандартной модели",
|
||||
"mode": "meta_monitoring"
|
||||
}
|
||||
```
|
||||
512
frontend/app.js
Normal file
512
frontend/app.js
Normal file
@ -0,0 +1,512 @@
|
||||
"use strict";
|
||||
|
||||
const CONFIG = {
|
||||
apiEndpoint: window.location.protocol === "file:" ? "http://localhost:3000/api/message" : "/api/message",
|
||||
maxPromptLength: 2000,
|
||||
typingSpeedMs: 14
|
||||
};
|
||||
|
||||
const els = {
|
||||
messages: document.querySelector("#messages"),
|
||||
messagesScroll: document.querySelector("#messagesScroll"),
|
||||
emptyState: document.querySelector("#emptyState"),
|
||||
promptInput: document.querySelector("#promptInput"),
|
||||
chatForm: document.querySelector("#chatForm"),
|
||||
sendButton: document.querySelector("#sendButton"),
|
||||
sendIcon: document.querySelector("#sendIcon"),
|
||||
stopIcon: document.querySelector("#stopIcon"),
|
||||
sendButtonLabel: document.querySelector("#sendButtonLabel"),
|
||||
metaButton: document.querySelector("#metaButton"),
|
||||
metaButtonText: document.querySelector("#metaButtonText"),
|
||||
modePill: document.querySelector("#modePill"),
|
||||
modeText: document.querySelector("#modeText"),
|
||||
statusText: document.querySelector("#statusText"),
|
||||
statusDot: document.querySelector("#statusDot"),
|
||||
notice: document.querySelector("#notice"),
|
||||
limitHint: document.querySelector("#limitHint"),
|
||||
messageTemplate: document.querySelector("#messageTemplate")
|
||||
};
|
||||
|
||||
const state = {
|
||||
phase: "idle",
|
||||
abortController: null,
|
||||
activeTimer: null,
|
||||
activeMessage: null,
|
||||
activeText: "",
|
||||
lastUserRequest: "",
|
||||
lastModelAnswer: "",
|
||||
sessionId: null // присваивается сервером при первом запросе, сбрасывается при перезагрузке
|
||||
};
|
||||
|
||||
const icons = {
|
||||
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 21a8 8 0 0 0-16 0"></path><circle cx="12" cy="7" r="4"></circle></svg>',
|
||||
model: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="4" y="5" width="16" height="14" rx="3"></rect><path d="M9 9h.01"></path><path d="M15 9h.01"></path><path d="M9 14h6"></path><path d="M12 2v3"></path></svg>',
|
||||
meta: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M8 15l3-4 3 2 5-7"></path><circle cx="8" cy="15" r="1"></circle><circle cx="11" cy="11" r="1"></circle><circle cx="14" cy="13" r="1"></circle><circle cx="19" cy="6" r="1"></circle></svg>'
|
||||
};
|
||||
|
||||
function setNotice(text, tone = "warning") {
|
||||
els.notice.textContent = text;
|
||||
els.notice.classList.toggle("is-visible", Boolean(text));
|
||||
els.notice.classList.toggle("error", tone === "error");
|
||||
}
|
||||
|
||||
function setStatus(text, tone = "ready") {
|
||||
const colors = {
|
||||
ready: ["var(--primary)", "var(--primary-soft)"],
|
||||
busy: ["var(--accent)", "var(--accent-soft)"],
|
||||
meta: ["#6d49b8", "var(--meta-bg)"],
|
||||
error: ["var(--danger)", "var(--danger-soft)"]
|
||||
};
|
||||
const pair = colors[tone] || colors.ready;
|
||||
els.statusText.textContent = text;
|
||||
els.statusDot.style.background = pair[0];
|
||||
els.statusDot.style.boxShadow = `0 0 0 4px ${pair[1]}`;
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
const meta = mode === "meta";
|
||||
els.modePill.classList.toggle("meta", meta);
|
||||
els.modeText.textContent = meta ? "Мета-мониторинг" : "Стандартный ответ";
|
||||
}
|
||||
|
||||
function updateInputHeight() {
|
||||
els.promptInput.style.height = "auto";
|
||||
const maxHeight = 180;
|
||||
const nextHeight = Math.min(els.promptInput.scrollHeight, maxHeight);
|
||||
els.promptInput.style.height = `${nextHeight}px`;
|
||||
els.promptInput.style.overflowY = els.promptInput.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
}
|
||||
|
||||
function promptValidation() {
|
||||
const raw = els.promptInput.value;
|
||||
const text = raw.trim();
|
||||
if (!text) {
|
||||
return { ok: false, text, reason: "empty", message: "Пустой запрос не отправляется модели." };
|
||||
}
|
||||
if (raw.length > CONFIG.maxPromptLength) {
|
||||
return {
|
||||
ok: false,
|
||||
text,
|
||||
reason: "too_long",
|
||||
message: `Запрос слишком длинный: ${raw.length} символов из ${CONFIG.maxPromptLength}. Сократите текст.`
|
||||
};
|
||||
}
|
||||
return { ok: true, text };
|
||||
}
|
||||
|
||||
function updatePromptLimit() {
|
||||
const length = els.promptInput.value.length;
|
||||
const tooLong = length > CONFIG.maxPromptLength;
|
||||
els.limitHint.textContent = `${length}/${CONFIG.maxPromptLength} символов`;
|
||||
els.limitHint.classList.toggle("too-long", tooLong);
|
||||
els.promptInput.classList.toggle("input-error", tooLong);
|
||||
}
|
||||
|
||||
function isBusy() {
|
||||
return state.phase === "generating_standard" || state.phase === "generating_meta";
|
||||
}
|
||||
|
||||
function updateControls() {
|
||||
const validation = promptValidation();
|
||||
const canSend = validation.ok && !isBusy();
|
||||
const canStop = state.phase === "generating_standard";
|
||||
|
||||
els.sendButton.disabled = !canSend && !canStop;
|
||||
els.sendButton.classList.toggle("stop", canStop);
|
||||
els.sendIcon.hidden = canStop;
|
||||
els.stopIcon.hidden = !canStop;
|
||||
els.sendButton.title = canStop ? "Остановить генерацию" : "Отправить";
|
||||
els.sendButtonLabel.textContent = canStop ? "Остановить генерацию" : "Отправить сообщение";
|
||||
|
||||
const metaVisible = Boolean(state.lastModelAnswer.trim());
|
||||
els.metaButton.classList.toggle("is-visible", metaVisible);
|
||||
els.metaButton.disabled = !metaVisible || state.phase === "generating_standard" || state.phase === "meta_completed";
|
||||
els.metaButtonText.textContent = state.phase === "generating_meta"
|
||||
? "Остановить мета-мониторинг"
|
||||
: state.phase === "meta_completed"
|
||||
? "Мета-мониторинг выполнен"
|
||||
: "Провести мета-мониторинг ответа";
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
els.messagesScroll.scrollTop = els.messagesScroll.scrollHeight;
|
||||
}
|
||||
|
||||
function hideEmptyState() {
|
||||
if (els.emptyState && els.emptyState.isConnected) {
|
||||
els.emptyState.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function makeMessage(role, sender, text, stateText = "") {
|
||||
hideEmptyState();
|
||||
const node = els.messageTemplate.content.firstElementChild.cloneNode(true);
|
||||
node.classList.add(role);
|
||||
node.querySelector(".avatar").innerHTML = icons[role] || icons.model;
|
||||
node.querySelector(".sender").textContent = sender;
|
||||
node.querySelector(".state").textContent = stateText;
|
||||
node.querySelector(".bubble-text").textContent = text || "";
|
||||
els.messages.appendChild(node);
|
||||
renderMathInNode(node.querySelector(".bubble-text"));
|
||||
scrollToBottom();
|
||||
return node;
|
||||
}
|
||||
|
||||
function setMessageText(node, text, typing = false) {
|
||||
const body = node.querySelector(".bubble-text");
|
||||
body.textContent = text;
|
||||
body.classList.toggle("typing-caret", typing);
|
||||
if (!typing) {
|
||||
renderMathInNode(body);
|
||||
}
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function setMessageState(node, text) {
|
||||
node.querySelector(".state").textContent = text || "";
|
||||
}
|
||||
|
||||
function clearActiveTimer() {
|
||||
if (state.activeTimer) {
|
||||
clearInterval(state.activeTimer);
|
||||
state.activeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeError(error) {
|
||||
if (error.name === "AbortError") {
|
||||
return "Запрос остановлен пользователем.";
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return "Произошла неизвестная ошибка.";
|
||||
}
|
||||
|
||||
async function postJson(payload, expectedType, signal) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(CONFIG.apiEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") throw error;
|
||||
throw new Error("Нет соединения с сервером. Проверьте, что backend запущен и доступен.");
|
||||
}
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
throw new Error("Сервер вернул ответ не в формате JSON.");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.message || `Ошибка сервера: HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
if (!data || data.type !== expectedType) {
|
||||
throw new Error(`Сервер вернул неожиданный тип ответа: ${data && data.type ? data.type : "type отсутствует"}.`);
|
||||
}
|
||||
|
||||
if (data.status && data.status !== "completed") {
|
||||
throw new Error(data.error || data.message || `Операция завершилась со статусом ${data.status}.`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function typeIntoMessage(node, fullText, onDone) {
|
||||
clearActiveTimer();
|
||||
let index = 0;
|
||||
state.activeText = "";
|
||||
|
||||
state.activeTimer = setInterval(() => {
|
||||
index += Math.max(1, Math.round(fullText.length / 180));
|
||||
state.activeText = fullText.slice(0, index);
|
||||
setMessageText(node, state.activeText, index < fullText.length);
|
||||
|
||||
if (index >= fullText.length) {
|
||||
clearActiveTimer();
|
||||
setMessageText(node, fullText, false);
|
||||
onDone(fullText);
|
||||
}
|
||||
}, CONFIG.typingSpeedMs);
|
||||
}
|
||||
|
||||
function renderMetaResult(node, result) {
|
||||
const issues = Array.isArray(result.issues) && result.issues.length
|
||||
? result.issues
|
||||
: Array.isArray(result.deficiencies) && result.deficiencies.length
|
||||
? result.deficiencies
|
||||
: ["Сервер не передал отдельный перечень недостатков."];
|
||||
|
||||
const comment = result.comment || result.summary || "Краткое пояснение оценки не передано сервером.";
|
||||
const recommendations = result.recommendations || "Рекомендации по улучшению ответа не переданы сервером.";
|
||||
const factual = Number.isFinite(Number(result.factual_correctness)) ? Number(result.factual_correctness) : 0;
|
||||
const completeness = Number.isFinite(Number(result.completeness)) ? Number(result.completeness) : 0;
|
||||
const logic = Number.isFinite(Number(result.logical_consistency)) ? Number(result.logical_consistency) : 0;
|
||||
const overall = Number.isFinite(Number(result.overall_score)) ? Number(result.overall_score) : 0;
|
||||
|
||||
const body = node.querySelector(".bubble-text");
|
||||
body.classList.remove("typing-caret");
|
||||
body.innerHTML = `
|
||||
<strong>Краткая общая оценка:</strong> ${escapeHtml(comment)}
|
||||
<div class="score-grid">
|
||||
<div class="score-card">
|
||||
<div class="score-label">Фактическая корректность</div>
|
||||
<div class="score-value">${factual}/10</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="score-label">Полнота ответа</div>
|
||||
<div class="score-value">${completeness}/10</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="score-label">Логическая согласованность</div>
|
||||
<div class="score-value">${logic}/10</div>
|
||||
</div>
|
||||
<div class="score-card">
|
||||
<div class="score-label">Итоговая оценка</div>
|
||||
<div class="score-value">${overall}/10</div>
|
||||
</div>
|
||||
</div>
|
||||
<strong>Выявленные недостатки:</strong>
|
||||
<ul class="meta-list">${issues.map((item) => `<li>${escapeHtml(String(item))}</li>`).join("")}</ul>
|
||||
<strong>Рекомендация:</strong> ${escapeHtml(recommendations)}
|
||||
`;
|
||||
renderMathInNode(body);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function renderMathInNode(node) {
|
||||
if (!node || typeof window.renderMathInElement !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.renderMathInElement(node, {
|
||||
delimiters: [
|
||||
{ left: "$$", right: "$$", display: true },
|
||||
{ left: "\\[", right: "\\]", display: true },
|
||||
{ left: "\\(", right: "\\)", display: false },
|
||||
{ left: "$", right: "$", display: false }
|
||||
],
|
||||
throwOnError: false,
|
||||
strict: "ignore",
|
||||
trust: false
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function startStandardRequest(userRequest) {
|
||||
setNotice("");
|
||||
setMode("standard");
|
||||
setStatus("Ожидание ответа модели", "busy");
|
||||
state.phase = "generating_standard";
|
||||
state.lastUserRequest = userRequest;
|
||||
state.lastModelAnswer = "";
|
||||
state.abortController = new AbortController();
|
||||
updateControls();
|
||||
|
||||
makeMessage("user", "Пользователь", userRequest);
|
||||
const assistant = makeMessage("model", "Языковая модель", "Ожидание ответа сервера...", "ожидание");
|
||||
state.activeMessage = assistant;
|
||||
|
||||
const payload = {
|
||||
type: "standard_request",
|
||||
user_request: userRequest,
|
||||
mode: "standard",
|
||||
session_id: state.sessionId
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await postJson(payload, "standard_answer", state.abortController.signal);
|
||||
const answer = data.answer || "";
|
||||
if (!answer.trim()) {
|
||||
throw new Error("Сервер вернул пустой ответ языковой модели.");
|
||||
}
|
||||
if (data.session_id) { state.sessionId = data.session_id; }
|
||||
|
||||
setStatus("Генерация стандартного ответа", "busy");
|
||||
setMessageState(assistant, "генерация");
|
||||
typeIntoMessage(assistant, answer, (text) => {
|
||||
state.phase = "standard_completed";
|
||||
state.lastModelAnswer = text;
|
||||
state.activeMessage = null;
|
||||
state.abortController = null;
|
||||
setMessageState(assistant, "завершено");
|
||||
setStatus("Готов к мета-мониторингу", "ready");
|
||||
updateControls();
|
||||
});
|
||||
} catch (error) {
|
||||
handleRequestError(error, assistant);
|
||||
}
|
||||
}
|
||||
|
||||
async function startMetaRequest() {
|
||||
if (!state.lastModelAnswer.trim()) {
|
||||
setNotice("Мета-мониторинг доступен только после получения ответа стандартной модели.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setNotice("");
|
||||
setMode("meta");
|
||||
setStatus("Выполняется мета-мониторинг", "meta");
|
||||
state.phase = "generating_meta";
|
||||
state.abortController = new AbortController();
|
||||
updateControls();
|
||||
|
||||
const meta = makeMessage("meta", "Агент мета-мониторинга", "Ожидание результата проверки...", "анализ");
|
||||
state.activeMessage = meta;
|
||||
|
||||
const payload = {
|
||||
type: "meta_monitoring_request",
|
||||
user_request: state.lastUserRequest,
|
||||
model_answer: state.lastModelAnswer,
|
||||
mode: "meta_monitoring",
|
||||
session_id: state.sessionId
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await postJson(payload, "meta_monitoring_result", state.abortController.signal);
|
||||
renderMetaResult(meta, data);
|
||||
setMessageState(meta, "завершено");
|
||||
state.phase = "meta_completed";
|
||||
state.activeMessage = null;
|
||||
state.abortController = null;
|
||||
setStatus("Мета-мониторинг завершен", "ready");
|
||||
setMode("standard");
|
||||
updateControls();
|
||||
} catch (error) {
|
||||
handleRequestError(error, meta);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRequestError(error, node) {
|
||||
clearActiveTimer();
|
||||
const message = normalizeError(error);
|
||||
const aborted = error.name === "AbortError";
|
||||
const wasStandard = state.phase === "generating_standard";
|
||||
|
||||
if (node) {
|
||||
setMessageText(node, aborted ? "Операция остановлена пользователем." : message, false);
|
||||
setMessageState(node, aborted ? "остановлено" : "ошибка");
|
||||
}
|
||||
|
||||
if (wasStandard) {
|
||||
state.lastModelAnswer = aborted ? state.activeText : "";
|
||||
}
|
||||
|
||||
state.phase = aborted && wasStandard && state.lastModelAnswer.trim() ? "standard_completed" : "idle";
|
||||
state.activeMessage = null;
|
||||
state.abortController = null;
|
||||
setNotice(message, aborted ? "warning" : "error");
|
||||
setStatus(aborted ? "Операция остановлена" : "Ошибка выполнения", aborted ? "ready" : "error");
|
||||
setMode("standard");
|
||||
updateControls();
|
||||
}
|
||||
|
||||
function stopActiveOperation() {
|
||||
if (state.phase === "generating_standard" && state.activeTimer) {
|
||||
clearActiveTimer();
|
||||
state.lastModelAnswer = state.activeText || "";
|
||||
|
||||
if (state.activeMessage) {
|
||||
setMessageText(
|
||||
state.activeMessage,
|
||||
state.lastModelAnswer || "Генерация остановлена до получения текста.",
|
||||
false
|
||||
);
|
||||
setMessageState(state.activeMessage, "остановлено");
|
||||
}
|
||||
|
||||
state.phase = state.lastModelAnswer.trim() ? "standard_completed" : "idle";
|
||||
state.activeMessage = null;
|
||||
state.abortController = null;
|
||||
setStatus("Генерация остановлена", "ready");
|
||||
setMode("standard");
|
||||
updateControls();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.abortController) {
|
||||
state.abortController.abort();
|
||||
}
|
||||
clearActiveTimer();
|
||||
}
|
||||
|
||||
els.promptInput.addEventListener("input", () => {
|
||||
updateInputHeight();
|
||||
updatePromptLimit();
|
||||
|
||||
const validation = promptValidation();
|
||||
if (validation.reason === "too_long") {
|
||||
setNotice(validation.message, "error");
|
||||
} else if (!isBusy()) {
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
updateControls();
|
||||
});
|
||||
|
||||
els.promptInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
els.chatForm.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
els.chatForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (state.phase === "generating_standard") {
|
||||
stopActiveOperation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.phase === "generating_meta") {
|
||||
setNotice("Дождитесь завершения мета-мониторинга или остановите его отдельной кнопкой.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = promptValidation();
|
||||
if (!validation.ok) {
|
||||
setNotice(validation.message, validation.reason === "empty" ? "warning" : "error");
|
||||
updateControls();
|
||||
return;
|
||||
}
|
||||
|
||||
els.promptInput.value = "";
|
||||
updateInputHeight();
|
||||
updatePromptLimit();
|
||||
startStandardRequest(validation.text);
|
||||
});
|
||||
|
||||
els.metaButton.addEventListener("click", () => {
|
||||
if (state.phase === "generating_meta") {
|
||||
stopActiveOperation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBusy()) {
|
||||
setNotice("Нельзя запускать мета-мониторинг во время другой операции.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
startMetaRequest();
|
||||
});
|
||||
|
||||
updateInputHeight();
|
||||
updatePromptLimit();
|
||||
updateControls();
|
||||
setStatus("Готов к работе", "ready");
|
||||
108
frontend/index.html
Normal file
108
frontend/index.html
Normal file
@ -0,0 +1,108 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SUNT - мета-мониторинг ответов БЯМ</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 7h16"></path>
|
||||
<path d="M7 4v16"></path>
|
||||
<path d="M17 4v16"></path>
|
||||
<path d="M4 17h16"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<h1 class="brand-title">SUNT: мета-мониторинг ответов БЯМ</h1>
|
||||
<p class="brand-subtitle">Демонстрационный веб-интерфейс чат-бота</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-strip" aria-live="polite">
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<span class="status-text" id="statusText">Готов к работе</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="chat-shell" aria-label="Диалог с языковой моделью">
|
||||
<div class="messages" id="messagesScroll">
|
||||
<div class="messages-inner" id="messages">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<h2 class="empty-title">Задайте вопрос, получите ответ и проверьте его качество.</h2>
|
||||
<p class="empty-text">После обычной генерации появится отдельный запуск мета-мониторинга с оценкой фактической корректности, полноты и логической согласованности.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notice" id="notice" role="alert"></div>
|
||||
|
||||
<form class="composer" id="chatForm" novalidate>
|
||||
<div class="composer-inner">
|
||||
<div class="mode-actions">
|
||||
<div class="mode-indicator" aria-live="polite">
|
||||
<span class="mode-pill" id="modePill">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"></path>
|
||||
</svg>
|
||||
<span id="modeText">Стандартный ответ</span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="meta-button" id="metaButton" type="button" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M9 11l3 3L22 4"></path>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>
|
||||
<span id="metaButtonText">Провести мета-мониторинг ответа</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-frame">
|
||||
<label class="sr-only" for="promptInput">Текстовый запрос пользователя</label>
|
||||
<textarea class="chat-input" id="promptInput" rows="1" placeholder="Введите запрос..."></textarea>
|
||||
<button class="send-button" id="sendButton" type="submit" disabled title="Отправить">
|
||||
<svg id="sendIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M22 2L11 13"></path>
|
||||
<path d="M22 2l-7 20-4-9-9-4 20-7z"></path>
|
||||
</svg>
|
||||
<svg id="stopIcon" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true" hidden>
|
||||
<rect x="7" y="7" width="10" height="10" rx="1.5"></rect>
|
||||
</svg>
|
||||
<span class="sr-only" id="sendButtonLabel">Отправить сообщение</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hint-row">
|
||||
<span id="limitHint">До 2000 символов</span>
|
||||
<span><span class="kbd">Enter</span> отправить, <span class="kbd">Shift</span> + <span class="kbd">Enter</span> новая строка</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<template id="messageTemplate">
|
||||
<article class="message">
|
||||
<div class="avatar" aria-hidden="true"></div>
|
||||
<div class="bubble">
|
||||
<div class="bubble-head">
|
||||
<span class="sender"></span>
|
||||
<span class="state"></span>
|
||||
</div>
|
||||
<div class="bubble-text"></div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
473
frontend/styles.css
Normal file
473
frontend/styles.css
Normal file
@ -0,0 +1,473 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--text: #151922;
|
||||
--muted: #626b79;
|
||||
--line: #d9dee7;
|
||||
--line-strong: #c6ceda;
|
||||
--primary: #166534;
|
||||
--primary-strong: #0f4a27;
|
||||
--primary-soft: #e8f5ed;
|
||||
--accent: #1d4ed8;
|
||||
--accent-soft: #e8f0ff;
|
||||
--danger: #b42318;
|
||||
--danger-soft: #fdebea;
|
||||
--warning: #915f00;
|
||||
--warning-soft: #fff4d8;
|
||||
--meta-bg: #f5f0ff;
|
||||
--meta-line: #c7b9f2;
|
||||
--meta-text: #38236d;
|
||||
--shadow: 0 18px 50px rgba(24, 32, 46, 0.08);
|
||||
--radius: 8px;
|
||||
--header-h: 68px;
|
||||
--composer-h: 120px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html {
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
body { min-height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.78), rgba(246,247,249,0.92)),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
color: var(--text);
|
||||
font-family: Inter, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button, textarea { font: inherit; }
|
||||
button { border: 0; cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; }
|
||||
svg { width: 18px; height: 18px; stroke-width: 2; }
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: var(--header-h) 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 0 28px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #9ec5aa;
|
||||
border-radius: var(--radius);
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.brand-copy { min-width: 0; }
|
||||
.brand-title {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #101623;
|
||||
font-size: 16px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-subtitle {
|
||||
margin: 1px 0 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 190px;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 0 4px var(--primary-soft);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
min-height: calc(100vh - var(--header-h));
|
||||
}
|
||||
.chat-shell {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
min-height: calc(100vh - var(--header-h));
|
||||
}
|
||||
.messages {
|
||||
overflow-y: auto;
|
||||
padding: 30px 18px 24px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.messages-inner {
|
||||
width: min(980px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: calc(100vh - var(--header-h) - var(--composer-h) - 90px);
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 18px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.empty-title {
|
||||
max-width: 720px;
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 5vw, 52px);
|
||||
line-height: 1.04;
|
||||
letter-spacing: 0;
|
||||
font-weight: 780;
|
||||
}
|
||||
.empty-text {
|
||||
max-width: 620px;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 17px;
|
||||
}
|
||||
.message {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.message.user {
|
||||
grid-template-columns: minmax(0, 1fr) 38px;
|
||||
}
|
||||
.message.user .avatar {
|
||||
grid-column: 2;
|
||||
background: #edf2f7;
|
||||
color: #293344;
|
||||
}
|
||||
.message.user .bubble {
|
||||
grid-column: 1;
|
||||
justify-self: end;
|
||||
max-width: min(760px, 100%);
|
||||
background: #e9eef5;
|
||||
border-color: #d4dbe6;
|
||||
}
|
||||
.message.user .bubble-head {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--accent);
|
||||
}
|
||||
.message.meta .avatar {
|
||||
border-color: var(--meta-line);
|
||||
background: var(--meta-bg);
|
||||
color: var(--meta-text);
|
||||
}
|
||||
.bubble {
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 10px 28px rgba(24, 32, 46, 0.05);
|
||||
}
|
||||
.message.meta .bubble {
|
||||
border-color: var(--meta-line);
|
||||
background: var(--meta-bg);
|
||||
}
|
||||
.bubble-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.message.meta .bubble-head { color: var(--meta-text); }
|
||||
.bubble-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.bubble-text .katex {
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
.bubble-text .katex-display {
|
||||
margin: 0.8em 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.bubble-text .katex-display > .katex {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.typing-caret::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 1.1em;
|
||||
margin-left: 3px;
|
||||
vertical-align: -0.18em;
|
||||
background: currentColor;
|
||||
animation: blink 0.8s steps(2, start) infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.score-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.score-card {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(88, 65, 151, 0.22);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
.score-label {
|
||||
color: #665b86;
|
||||
font-size: 12px;
|
||||
}
|
||||
.score-value {
|
||||
margin-top: 4px;
|
||||
color: var(--meta-text);
|
||||
font-size: 22px;
|
||||
font-weight: 780;
|
||||
}
|
||||
.meta-list {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.meta-list li { margin: 4px 0; }
|
||||
|
||||
.notice {
|
||||
display: none;
|
||||
width: min(980px, calc(100% - 36px));
|
||||
margin: 0 auto 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: var(--radius);
|
||||
background: var(--warning-soft);
|
||||
color: #4d3300;
|
||||
}
|
||||
.notice.is-visible { display: block; }
|
||||
.notice.error {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
color: #5f1510;
|
||||
}
|
||||
|
||||
.composer {
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(246, 247, 249, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 14px 18px 18px;
|
||||
}
|
||||
.composer-inner {
|
||||
width: min(980px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.mode-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
}
|
||||
.mode-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.mode-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: #2d3646;
|
||||
font-weight: 650;
|
||||
}
|
||||
.mode-pill.meta {
|
||||
border-color: var(--meta-line);
|
||||
background: var(--meta-bg);
|
||||
color: var(--meta-text);
|
||||
}
|
||||
.meta-button {
|
||||
display: none;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--meta-line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--meta-bg);
|
||||
color: var(--meta-text);
|
||||
font-weight: 680;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-button.is-visible { display: inline-flex; }
|
||||
.meta-button:hover { background: #fbf9ff; }
|
||||
.meta-button:disabled { opacity: 0.62; }
|
||||
|
||||
.input-frame {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.chat-input {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
max-height: 180px;
|
||||
resize: none;
|
||||
overflow-y: hidden;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 6px 2px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.chat-input::placeholder { color: #7a8494; }
|
||||
.chat-input.input-error { color: var(--danger); }
|
||||
|
||||
.send-button {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
.send-button:hover { background: var(--primary-strong); }
|
||||
.send-button:disabled {
|
||||
background: #d4dbe6;
|
||||
color: #7a8494;
|
||||
}
|
||||
.send-button.stop { background: var(--danger); }
|
||||
.send-button.stop:hover { background: #8f1d14; }
|
||||
.hint-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.hint-row .too-long { color: var(--danger); font-weight: 700; }
|
||||
.kbd {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: #3b4656;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
:root { --header-h: 74px; }
|
||||
.topbar { padding: 0 14px; gap: 10px; }
|
||||
.brand-title { font-size: 14px; }
|
||||
.brand-subtitle, .status-strip { font-size: 11px; }
|
||||
.status-strip { min-width: auto; }
|
||||
.status-text { display: none; }
|
||||
.messages { padding: 20px 12px 18px; }
|
||||
.empty-title { font-size: 32px; }
|
||||
.empty-text { font-size: 15px; }
|
||||
.message, .message.user {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
}
|
||||
.message.user .avatar { grid-column: 1; }
|
||||
.message.user .bubble {
|
||||
grid-column: 2;
|
||||
justify-self: stretch;
|
||||
}
|
||||
.message.user .bubble-head { justify-content: flex-start; }
|
||||
.avatar { width: 32px; height: 32px; }
|
||||
.bubble { padding: 12px; }
|
||||
.score-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.composer { padding: 10px 12px 14px; }
|
||||
.mode-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mode-indicator, .meta-button { width: 100%; }
|
||||
.meta-button { white-space: normal; }
|
||||
.hint-row { display: none; }
|
||||
}
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
openai==1.82.0
|
||||
python-dotenv==1.1.0
|
||||
192
server.py
Normal file
192
server.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""
|
||||
SUNT — бекенд сервер.
|
||||
Запуск: uvicorn server:app --host 0.0.0.0 --port 3000 --reload
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from services.yandex import get_standard_answer, get_meta_monitoring_result
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="SUNT Backend", docs_url=None, redoc_url=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Хранилище сессий (в памяти — очищается при перезапуске сервера)
|
||||
#
|
||||
# Структура одной сессии:
|
||||
# {
|
||||
# "history": [
|
||||
# {"role": "user", "content": "..."},
|
||||
# {"role": "assistant", "content": "..."},
|
||||
# ...
|
||||
# ],
|
||||
# "meta_feedbacks": [
|
||||
# "Рекомендация 1 из мета-мониторинга",
|
||||
# "Рекомендация 2 из мета-мониторинга",
|
||||
# ...
|
||||
# ]
|
||||
# }
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
sessions: dict[str, dict] = {}
|
||||
|
||||
MAX_HISTORY_MESSAGES = 20 # максимум сообщений передаём модели (чтобы не раздувать контекст)
|
||||
MAX_META_FEEDBACKS = 5 # максимум рекомендаций от мета-мониторинга
|
||||
|
||||
|
||||
def get_or_create_session(session_id: str | None) -> tuple[str, dict]:
|
||||
"""Возвращает (session_id, данные сессии). Создаёт новую если не найдена."""
|
||||
if session_id and session_id in sessions:
|
||||
return session_id, sessions[session_id]
|
||||
new_id = str(uuid.uuid4())
|
||||
sessions[new_id] = {"history": [], "meta_feedbacks": []}
|
||||
logger.info("Создана новая сессия: %s", new_id)
|
||||
return new_id, sessions[new_id]
|
||||
|
||||
|
||||
def trim_history(history: list) -> list:
|
||||
"""Оставляет только последние MAX_HISTORY_MESSAGES сообщений."""
|
||||
if len(history) > MAX_HISTORY_MESSAGES:
|
||||
return history[-MAX_HISTORY_MESSAGES:]
|
||||
return history
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Единственный API-маршрут
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/message")
|
||||
async def handle_message(request: Request) -> JSONResponse:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Тело запроса должно быть валидным JSON."},
|
||||
)
|
||||
|
||||
request_type = body.get("type")
|
||||
session_id, session = get_or_create_session(body.get("session_id"))
|
||||
logger.info("Тип запроса: %s | Сессия: %s | Сообщений в истории: %d",
|
||||
request_type, session_id, len(session["history"]))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Стандартный запрос к модели
|
||||
# ------------------------------------------------------------------
|
||||
if request_type == "standard_request":
|
||||
user_request = (body.get("user_request") or "").strip()
|
||||
if not user_request:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Поле user_request не может быть пустым."},
|
||||
)
|
||||
|
||||
# Добавляем сообщение пользователя в историю сессии
|
||||
session["history"].append({"role": "user", "content": user_request})
|
||||
|
||||
try:
|
||||
answer = await get_standard_answer(
|
||||
history=trim_history(session["history"]),
|
||||
meta_feedbacks=session["meta_feedbacks"],
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error("Ошибка стандартного запроса: %s", e)
|
||||
# Убираем последнее сообщение пользователя — оно не было обработано
|
||||
session["history"].pop()
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"error": str(e)},
|
||||
)
|
||||
|
||||
# Добавляем ответ ассистента в историю
|
||||
session["history"].append({"role": "assistant", "content": answer})
|
||||
|
||||
return JSONResponse(content={
|
||||
"type": "standard_answer",
|
||||
"answer": answer,
|
||||
"session_id": session_id,
|
||||
"status": "completed",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Запрос мета-мониторинга
|
||||
# ------------------------------------------------------------------
|
||||
if request_type == "meta_monitoring_request":
|
||||
user_request = (body.get("user_request") or "").strip()
|
||||
model_answer = (body.get("model_answer") or "").strip()
|
||||
|
||||
if not user_request or not model_answer:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Поля user_request и model_answer обязательны для мета-мониторинга."},
|
||||
)
|
||||
|
||||
try:
|
||||
result = await get_meta_monitoring_result(user_request, model_answer)
|
||||
except RuntimeError as e:
|
||||
logger.error("Ошибка мета-мониторинга: %s", e)
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"error": str(e)},
|
||||
)
|
||||
|
||||
# Сохраняем рекомендацию из мета-мониторинга в сессию
|
||||
recommendation = result.get("recommendations", "").strip()
|
||||
if recommendation:
|
||||
session["meta_feedbacks"].append(recommendation)
|
||||
# Оставляем только последние MAX_META_FEEDBACKS рекомендаций
|
||||
if len(session["meta_feedbacks"]) > MAX_META_FEEDBACKS:
|
||||
session["meta_feedbacks"] = session["meta_feedbacks"][-MAX_META_FEEDBACKS:]
|
||||
logger.info("Сохранена рекомендация мета-мониторинга в сессию %s", session_id)
|
||||
|
||||
return JSONResponse(content={
|
||||
"type": "meta_monitoring_result",
|
||||
"factual_correctness": result.get("factual_correctness", 0),
|
||||
"completeness": result.get("completeness", 0),
|
||||
"logical_consistency": result.get("logical_consistency", 0),
|
||||
"overall_score": result.get("overall_score", 0),
|
||||
"comment": result.get("comment", ""),
|
||||
"issues": result.get("issues", []),
|
||||
"recommendations": result.get("recommendations", ""),
|
||||
"session_id": session_id,
|
||||
"status": "completed",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Неизвестный тип запроса
|
||||
# ------------------------------------------------------------------
|
||||
logger.warning("Неизвестный тип запроса: %s", request_type)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": f"Неизвестный тип запроса: {request_type!r}."},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Раздача статики фронтенда
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FRONTEND_DIR = Path(__file__).parent / "frontend"
|
||||
|
||||
if FRONTEND_DIR.exists():
|
||||
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
|
||||
logger.info("Фронтенд раздаётся из папки: %s", FRONTEND_DIR)
|
||||
else:
|
||||
logger.warning(
|
||||
"Папка frontend/ не найдена. Создай её и положи туда index.html, styles.css, app.js."
|
||||
)
|
||||
BIN
services/__pycache__/meta_prompt.cpython-312.pyc
Normal file
BIN
services/__pycache__/meta_prompt.cpython-312.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/yandex.cpython-312.pyc
Normal file
BIN
services/__pycache__/yandex.cpython-312.pyc
Normal file
Binary file not shown.
44
services/meta_prompt.py
Normal file
44
services/meta_prompt.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""
|
||||
Промпты для взаимодействия с Яндекс Cloud API.
|
||||
"""
|
||||
|
||||
STANDARD_SYSTEM_PROMPT = """Ты — полезный интеллектуальный ассистент. Отвечай чётко, по существу и на том языке, на котором написан вопрос пользователя."""
|
||||
|
||||
|
||||
META_SYSTEM_PROMPT = """Ты — агент мета-мониторинга качества ответов больших языковых моделей.
|
||||
|
||||
Твоя задача: проанализировать ответ языковой модели на запрос пользователя и оценить его качество по заданным критериям.
|
||||
|
||||
КРИТЕРИИ ОЦЕНКИ (каждый по шкале от 1 до 10):
|
||||
1. factual_correctness — фактическая корректность: насколько ответ соответствует реальным фактам и условиям запроса.
|
||||
2. completeness — полнота: насколько ответ охватывает все существенные аспекты запроса.
|
||||
3. logical_consistency — логическая согласованность: отсутствие внутренних противоречий и нарушений причинно-следственных связей.
|
||||
4. overall_score — итоговая оценка качества: общая взвешенная оценка.
|
||||
|
||||
ТРЕБОВАНИЯ К ОТВЕТУ:
|
||||
- Отвечай ТОЛЬКО валидным JSON без каких-либо пояснений, преамбулы и markdown-разметки.
|
||||
- Не добавляй ```json или ``` вокруг ответа.
|
||||
- Поле "issues" — это массив строк с конкретными выявленными недостатками (минимум 1 элемент, даже если ответ хороший — укажи что можно было бы улучшить).
|
||||
- Поля "comment" и "recommendations" — краткий текст на том языке, на котором написан запрос пользователя.
|
||||
|
||||
ФОРМАТ ОТВЕТА:
|
||||
{
|
||||
"type": "meta_monitoring_result",
|
||||
"factual_correctness": <целое число от 1 до 10>,
|
||||
"completeness": <целое число от 1 до 10>,
|
||||
"logical_consistency": <целое число от 1 до 10>,
|
||||
"overall_score": <целое число от 1 до 10>,
|
||||
"comment": "<краткая общая оценка ответа в 1-2 предложения>",
|
||||
"issues": ["<недостаток 1>", "<недостаток 2>"],
|
||||
"recommendations": "<краткая рекомендация по улучшению ответа>",
|
||||
"status": "completed"
|
||||
}"""
|
||||
|
||||
|
||||
def build_meta_user_message(user_request: str, model_answer: str) -> str:
|
||||
"""Формирует пользовательское сообщение для агента мета-мониторинга."""
|
||||
return (
|
||||
f"ЗАПРОС ПОЛЬЗОВАТЕЛЯ:\n{user_request}\n\n"
|
||||
f"ОТВЕТ ЯЗЫКОВОЙ МОДЕЛИ:\n{model_answer}\n\n"
|
||||
"Проанализируй этот ответ и верни результат мета-мониторинга в указанном JSON-формате."
|
||||
)
|
||||
167
services/yandex.py
Normal file
167
services/yandex.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""
|
||||
Обёртка над Яндекс Cloud API (через openai-совместимый клиент).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from openai import AsyncOpenAI, APIConnectionError, APIStatusError, APITimeoutError
|
||||
|
||||
from services.meta_prompt import (
|
||||
STANDARD_SYSTEM_PROMPT,
|
||||
META_SYSTEM_PROMPT,
|
||||
build_meta_user_message,
|
||||
)
|
||||
|
||||
YANDEX_BASE_URL = "https://ai.api.cloud.yandex.net/v1"
|
||||
YANDEX_MODEL_NAME = "gpt-oss-120b/latest"
|
||||
|
||||
|
||||
def _get_client() -> AsyncOpenAI:
|
||||
api_key = os.getenv("YANDEX_API_KEY")
|
||||
folder_id = os.getenv("YANDEX_FOLDER_ID")
|
||||
if not api_key or not folder_id:
|
||||
raise RuntimeError(
|
||||
"Переменные окружения YANDEX_API_KEY и YANDEX_FOLDER_ID не заданы. "
|
||||
"Проверь файл .env."
|
||||
)
|
||||
return AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=YANDEX_BASE_URL,
|
||||
default_headers={"x-folder-id": folder_id},
|
||||
)
|
||||
|
||||
|
||||
def _model_uri() -> str:
|
||||
folder_id = os.getenv("YANDEX_FOLDER_ID", "")
|
||||
return f"gpt://{folder_id}/{YANDEX_MODEL_NAME}"
|
||||
|
||||
|
||||
async def _call_llm(
|
||||
system_prompt: str,
|
||||
user_message: str,
|
||||
temperature: float = 0.4,
|
||||
max_tokens: int = 2000,
|
||||
) -> str:
|
||||
"""Базовый вызов LLM. Возвращает текст ответа модели."""
|
||||
client = _get_client()
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=_model_uri(),
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
)
|
||||
except APIConnectionError as e:
|
||||
raise RuntimeError(
|
||||
"Не удалось подключиться к Яндекс Cloud API. "
|
||||
"Проверьте сетевое соединение."
|
||||
) from e
|
||||
except APITimeoutError as e:
|
||||
raise RuntimeError(
|
||||
"Яндекс Cloud API не ответил в отведённое время. Попробуйте позже."
|
||||
) from e
|
||||
except APIStatusError as e:
|
||||
# Передаём статус и тело ошибки от Яндекса в понятном виде
|
||||
raise RuntimeError(
|
||||
f"Яндекс Cloud API вернул ошибку {e.status_code}: {e.message}"
|
||||
) from e
|
||||
|
||||
text = response.choices[0].message.content or ""
|
||||
if not text.strip():
|
||||
raise RuntimeError("Модель вернула пустой ответ.")
|
||||
return text
|
||||
|
||||
|
||||
async def get_standard_answer(history: list[dict], meta_feedbacks: list[str]) -> str:
|
||||
"""
|
||||
Получить ответ модели с учётом истории диалога и рекомендаций мета-мониторинга.
|
||||
|
||||
history — список {"role": "user"/"assistant", "content": "..."} за текущую сессию.
|
||||
meta_feedbacks — список рекомендаций от предыдущих мета-мониторингов в этой сессии.
|
||||
"""
|
||||
client = _get_client()
|
||||
|
||||
# Формируем системный промпт: базовый + рекомендации мета-мониторинга если есть
|
||||
system_content = STANDARD_SYSTEM_PROMPT
|
||||
if meta_feedbacks:
|
||||
joined = "\n".join(f"- {fb}" for fb in meta_feedbacks)
|
||||
system_content += (
|
||||
"\n\nВ этой сессии агент мета-мониторинга уже оценивал твои предыдущие ответы "
|
||||
"и выдал следующие рекомендации по улучшению. Учитывай их в своих ответах:\n"
|
||||
+ joined
|
||||
)
|
||||
|
||||
messages = [{"role": "system", "content": system_content}] + history
|
||||
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=_model_uri(),
|
||||
temperature=0.4,
|
||||
max_tokens=2000,
|
||||
messages=messages,
|
||||
)
|
||||
except APIConnectionError as e:
|
||||
raise RuntimeError(
|
||||
"Не удалось подключиться к Яндекс Cloud API. Проверьте сетевое соединение."
|
||||
) from e
|
||||
except APITimeoutError as e:
|
||||
raise RuntimeError(
|
||||
"Яндекс Cloud API не ответил в отведённое время. Попробуйте позже."
|
||||
) from e
|
||||
except APIStatusError as e:
|
||||
raise RuntimeError(
|
||||
f"Яндекс Cloud API вернул ошибку {e.status_code}: {e.message}"
|
||||
) from e
|
||||
|
||||
text = response.choices[0].message.content or ""
|
||||
if not text.strip():
|
||||
raise RuntimeError("Модель вернула пустой ответ.")
|
||||
return text
|
||||
|
||||
|
||||
async def get_meta_monitoring_result(user_request: str, model_answer: str) -> dict:
|
||||
"""
|
||||
Запустить мета-мониторинг ответа модели.
|
||||
Возвращает распарсенный словарь с результатами оценки.
|
||||
"""
|
||||
user_message = build_meta_user_message(user_request, model_answer)
|
||||
raw = await _call_llm(
|
||||
system_prompt=META_SYSTEM_PROMPT,
|
||||
user_message=user_message,
|
||||
temperature=0.2, # меньше случайности — нужен стабильный JSON
|
||||
max_tokens=1500,
|
||||
)
|
||||
|
||||
# Очищаем на случай если модель всё же добавила markdown-обёртку
|
||||
cleaned = raw.strip()
|
||||
if cleaned.startswith("```"):
|
||||
lines = cleaned.splitlines()
|
||||
# Убираем первую и последнюю строки с ```
|
||||
cleaned = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
||||
|
||||
try:
|
||||
result = json.loads(cleaned)
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(
|
||||
f"Агент мета-мониторинга вернул невалидный JSON. "
|
||||
f"Ошибка парсинга: {e}. Сырой ответ: {raw[:300]}"
|
||||
) from e
|
||||
|
||||
# Гарантируем обязательные поля
|
||||
required = {"factual_correctness", "completeness", "logical_consistency",
|
||||
"overall_score", "comment", "issues", "recommendations"}
|
||||
missing = required - result.keys()
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f"В ответе мета-мониторинга отсутствуют поля: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
# Убеждаемся что issues — это список
|
||||
if not isinstance(result.get("issues"), list):
|
||||
result["issues"] = [str(result.get("issues", "Недостатки не указаны"))]
|
||||
|
||||
return result
|
||||
Loading…
x
Reference in New Issue
Block a user