First commit

This commit is contained in:
timofey 2026-05-27 15:55:36 +03:00
commit 81a3d1216a
12 changed files with 1613 additions and 0 deletions

5
.env Normal file
View File

@ -0,0 +1,5 @@
# Скопируй этот файл в .env и заполни своими значениями
# cp .env.example .env
YANDEX_API_KEY=AQVN3txsRFUT7CzISouIsWdt9OjHBiY5LUElzDAL
YANDEX_FOLDER_ID=b1g86m7ujd7bebiuah91

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
# Скопируй этот файл в .env и заполни своими значениями
# cp .env.example .env
YANDEX_API_KEY=ну тут ключ кароч, он и так в правильном файле есть, так что нечего сюда смотреть
YANDEX_FOLDER_ID=b1g86m7ujd7bebiuah91

103
README.md Normal file
View 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
View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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
View 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
View 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
View 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
View 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."
)

Binary file not shown.

Binary file not shown.

44
services/meta_prompt.py Normal file
View 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
View 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