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