From 81a3d1216a5be36dfab59646f89a7ddf0cbe9a7e Mon Sep 17 00:00:00 2001 From: timofey Date: Wed, 27 May 2026 15:55:36 +0300 Subject: [PATCH] First commit --- .env | 5 + .env.example | 5 + README.md | 103 ++++ frontend/app.js | 512 ++++++++++++++++++ frontend/index.html | 108 ++++ frontend/styles.css | 473 ++++++++++++++++ requirements.txt | 4 + server.py | 192 +++++++ .../__pycache__/meta_prompt.cpython-312.pyc | Bin 0 -> 3610 bytes services/__pycache__/yandex.cpython-312.pyc | Bin 0 -> 7736 bytes services/meta_prompt.py | 44 ++ services/yandex.py | 167 ++++++ 12 files changed, 1613 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 README.md create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/styles.css create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 services/__pycache__/meta_prompt.cpython-312.pyc create mode 100644 services/__pycache__/yandex.cpython-312.pyc create mode 100644 services/meta_prompt.py create mode 100644 services/yandex.py diff --git a/.env b/.env new file mode 100644 index 0000000..020abea --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# Скопируй этот файл в .env и заполни своими значениями +# cp .env.example .env + +YANDEX_API_KEY=AQVN3txsRFUT7CzISouIsWdt9OjHBiY5LUElzDAL +YANDEX_FOLDER_ID=b1g86m7ujd7bebiuah91 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..32d2feb --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Скопируй этот файл в .env и заполни своими значениями +# cp .env.example .env + +YANDEX_API_KEY=ну тут ключ кароч, он и так в правильном файле есть, так что нечего сюда смотреть +YANDEX_FOLDER_ID=b1g86m7ujd7bebiuah91 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5001ac7 --- /dev/null +++ b/README.md @@ -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" +} +``` diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..9b22b8c --- /dev/null +++ b/frontend/app.js @@ -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: '', + model: '', + meta: '' +}; + +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 = ` + Краткая общая оценка: ${escapeHtml(comment)} +
+
+
Фактическая корректность
+
${factual}/10
+
+
+
Полнота ответа
+
${completeness}/10
+
+
+
Логическая согласованность
+
${logic}/10
+
+
+
Итоговая оценка
+
${overall}/10
+
+
+ Выявленные недостатки: + + Рекомендация: ${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"); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4f9de17 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,108 @@ + + + + + + SUNT - мета-мониторинг ответов БЯМ + + + + +
+
+
+ +
+

SUNT: мета-мониторинг ответов БЯМ

+

Демонстрационный веб-интерфейс чат-бота

+
+
+
+ + Готов к работе +
+
+ +
+
+
+
+
+

Задайте вопрос, получите ответ и проверьте его качество.

+

После обычной генерации появится отдельный запуск мета-мониторинга с оценкой фактической корректности, полноты и логической согласованности.

+
+
+
+ + + +
+
+
+
+ + + Стандартный ответ + +
+ +
+ +
+ + + +
+ +
+ До 2000 символов + Enter отправить, Shift + Enter новая строка +
+
+
+
+
+
+ + + + + + + + diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..bf023ee --- /dev/null +++ b/frontend/styles.css @@ -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; } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bd8916a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +openai==1.82.0 +python-dotenv==1.1.0 diff --git a/server.py b/server.py new file mode 100644 index 0000000..72a11be --- /dev/null +++ b/server.py @@ -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." + ) diff --git a/services/__pycache__/meta_prompt.cpython-312.pyc b/services/__pycache__/meta_prompt.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..803f3342b7a8c40f84c6849e4f856332f4b712c9 GIT binary patch literal 3610 zcmb7H-EUK696tS6H!65R6cfF9Q@>ck8VAZnvn-j63rz+K+l_NtYI`8XuAS$!kOfRO z1`e~~z(ANF8%|?1nyie~eHZV&dC#5crTPcd;FVWC&+oKdI~S29Jsf>_!?f0q%ygL>gs(f9L)Uzn|dCGejB}^^h2y($LeLh=}hWX1%sO~Q@|jH*Whv- zZq}Wt>V!P}jKK@N6*xJp^sIO;I1`w1VfB{gLr(}TemNB@f^H_%9o#}9Hd{=#< zdHui*R{^mCX64%ix=omMJt1?<5$AbiQi+xWjAc<4!G+TIiJ3FyT*1=ITfyU-Gl_v( zMF`+(I2hFPjF>fJ#g-5waEc&uNe>wjU;w`id#to%;Y`)}ZE%7as8hAC`%rGVKy*aj_(ad2JC3Ny)sI)IUh z#KNnn4`WyaS;p~!72$NYNrjrI9X_+MsTM1uIBuCnvE+&@kWn(3WoB2xVVl5R0Et{b zVp!S@9~LPfSc+zQJ*+ZkO)Hj4MQzZ>bW@WSC(Q*cSpT`c2G-B$1*LDZ3+6F$6W6aJ!M5!(iQ!p&NB@W>nI&ef2rN|^ z?d`tf$0#+BW{D6a28?YY6c8Y3PiGp*Z&op**j9MEf%wNkc@SVl4aKYv6Pa`AHhjzj zCs+PpF3gzjvS4UFxXL34C?{%i^5n@gb|#J0^v73MbkY2<`=G9gNOf45g8_DPC~4a{ z(+;`qdKHvFq#I-pB1Pp%T=Jr**JP7oXKsq7P?AMrsAm+?X+|Tt)rOfWLKO;7=&2za zn7^M_*uiFq@HQYP&Q)29RAKZYkb&w#f{-d?U2=k%il{5Jnd~$abR^Gx5oTP`LZ?qu zvnav>sEDed)zB^Sz^Ut??gUp;by~Lo0aZc}fgv-U4Y3j-sF}PbV%cOSU4co`chjMJ zc%zO1Cs)_$9XXNWaQnaa$hyWCu806`-yCYx=$3*(y>D$51(0hXKcG~R_u?PpdQCf!Wg7WbwDaU|1j=S-F)*u0uVC`%y zo3jBTNiREJA{6|%v^!^;76|hZgdtnnJ(Nk9sc0;1pEIpez%sKrD;@V%4#&JT;pfBe z(KEQaVDPu9)A#ox1=JLwEE-B$zvZxoEW7W}x%} zmi|E5$IMy%g+lwy>Kn3P1`ED)7tA5KB4n%Kc!)|sB1Bl3Z;els7YHU#W#Er{ep!~X zO+i4QE|1HJbi{nB9;HJzUC0i%p*9?TT%@T|h4A-AtJh-1ESAaUlTv@bd?Yg16HBHC zhldAydk0bTv&pz=4~lXOTVO;sJUmi*?t@%1m59oy5Mv0ZyVz;dYJr(oXm39*qeeMk zJas7Fkq?&*PnYNLuZQ2vzmLamkLfM@yt{byHw7MHu5qljsni^eCNlA8w6w3Uzw20c z*NN_E-#dN%M|+~ZCyw{@_Luhd9PRJgHs-UlmiV>!&zXh)7YwTzmr_dySei?joz~NM z_26f}flJxv@p%69dOiMfi}8H7UmN?%ffx2QX=87>Bj5>q_hQ+=ZKY3cKllB`H!=GU D5Rn(i literal 0 HcmV?d00001 diff --git a/services/__pycache__/yandex.cpython-312.pyc b/services/__pycache__/yandex.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bbb93d67f07ee2c9fd75fe80c6ca42db26ac0c87 GIT binary patch literal 7736 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, + "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-формате." + ) diff --git a/services/yandex.py b/services/yandex.py new file mode 100644 index 0000000..398228d --- /dev/null +++ b/services/yandex.py @@ -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