feat(feedback): personal AI models, AI run tracking and profiles/options endpoint
- user_settings allowlist: feedback_models (up to 10 personal model configs) - normalizeFeedbackModels() validation, UI block "Мои модели" in #fbPromptModal - GET /api/ai/profiles/options for tutors (no api_key), used by feedback/shorts - feedbacks columns ai_prompt/ai_model/ai_duration_ms + backup/restore support - "ИИ-запрос" popover on feedback card with status, model, timing and prompt - audit: feedback.update / feedback.ai.run / feedback.ai.error labels
This commit is contained in:
@@ -189,10 +189,16 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
||||
### 3h. Персональные настройки и промпт отзыва (`server.js`, `worker.js`, `public/feedback.html`, `public/js/feedback.js`)
|
||||
|
||||
- **Таблица `user_settings`** (`user_id`, `key`, `value`, `updated_at`, `PK(user_id, key)`, FK на `users` с `ON DELETE CASCADE`) — персональные настройки пользователя. Схема в трёх местах: `db/init.sql`, `db/migration.sql` и `ensureUserTables()` в `server.js`
|
||||
- **Эндпоинты `GET`/`PUT /api/user-settings`** под `requireAuth` — это **не** admin-only `PUT /api/settings`, глобальную таблицу `settings` они не трогают. Allowlist ключей: `feedback_prompt` (≤8000 символов) и `feedback_profile` (≤100); неизвестный ключ — 400; пустое значение удаляет строку (семантика «как в системе»). Аудит `user_settings.update`
|
||||
- **Эндпоинты `GET`/`PUT /api/user-settings`** под `requireAuth` — это **не** admin-only `PUT /api/settings`, глобальную таблицу `settings` они не трогают. Allowlist ключей: `feedback_prompt` (≤8000 символов), `feedback_profile` (≤100) и `feedback_models` (≤16000, структурная валидация); неизвестный ключ — 400; пустое значение удаляет строку (семантика «как в системе»). Аудит `user_settings.update`
|
||||
- **Личные модели (`feedback_models`)**: JSON-массив `{ id, name, base_url, model, api_key, max_tokens }` в `user_settings`, до 10 штук; валидация — `normalizeFeedbackModels()` в `server.js` (id `^[\w-]+$` ≤40 и без дублей, name 1–64, model 1–100, base_url только http/https ≤300, api_key ≤256, max_tokens 16–131072 или null), ошибка — 400. UI — блок «Мои модели» в `#fbPromptModal` (`#fbModelsList` + форма `#fbModelEdit`), правки сохраняются вместе с «Сохранить» в `savePromptSettings()`. Селекты `#fbModel` и `#fbDefaultModel` собираются с optgroup «Мои модели» **первым**, затем «Системные» (`loadAiProfiles()`) — личные имеют приоритет, системные (`ai_profiles`/native) остаются фолбэком. Резолв в feedback-воркере: `resolveActiveProfile(profileId, authorId)` → личные модели автора (`getPersonalModels` по `feedbacks.author_id`) → системные `ai_profiles` → активная → native
|
||||
- **Наполнение селектов — только `GET /api/ai/profiles/options`** (`requireAuth`, отдаёт `active`, `native{id,name,model}` и `profiles[{id,name,model}]` **без** `base_url`/`api_key`). `GET /api/ai/profiles` остаётся `requireAdmin` (в нём есть `api_key`) — на него тьютор получает 403, и раньше `loadAiProfiles()` молча оставлял селекты пустыми: `savePromptSettings()` отправлял `feedback_profile: ''`, сервер удалял строку в `user_settings`, и выбор модели «не сохранялся в БД». Тот же эндпоинт используется в `public/js/shorts.js` (`openShortsAi()`)
|
||||
- **Сохранённое значение не должно теряться**: в `openPromptModal()` → `fillDefaultModelOptions()` значение из `user_settings`, отсутствующее в опциях (удалённый/переименованный профиль), добавляется как опция «… (недоступна)» — без неё `dst.value = X` молча обнулялся, и следующее сохранение стирало `feedback_profile`. После добавления/удаления личной модели в модалке селекты пересобираются (`refreshModelSelects()`), иначе новая модель недоступна как «Модель по умолчанию» до перезагрузки страницы
|
||||
- **Цепочка промпта отзыва** (`resolveFeedbackPrompt` в `worker.js`): персональный `user_settings.feedback_prompt` по `feedbacks.author_id` → глобальный `feedback_ai_prompt` → `FEEDBACK_AI_DEFAULT_PROMPT`. Шаблон индивидуален для каждого пользователя и применяется ко всем отзывам, которые он авторит
|
||||
- **Ключи подстановки**: `{group}`, `{date}`, `{resident}`, `{topic}`, `{past_review}`, `{shorts}`, `{context}`, `{text}`. Если шаблон содержит хотя бы один из них — значения подставляются по ключам и блок `КОНТЕКСТ:` в конец **не** добавляется; без ключей — добавляется (поведение до появления ключей). Набор ключей продублирован в трёх местах: `worker.js` (regex + карта `keys`), `public/feedback.html` (`#fbPromptModal`, список с описаниями) и здесь — при изменении набора править все три
|
||||
- **UI**: шестерёнка `#fbPromptBtn` в шапке `#feedbackModal`, модалка `#fbPromptModal` («Модель по умолчанию» → `feedback_profile`, шаблон → `feedback_prompt`, описание ключей, счётчик 8000, «Сбросить» очищает оба поля). Персональная модель применяется в `resetForm()` к `#fbModel`, пустое значение = активная модель системы
|
||||
- **UI**: шестерёнка `#fbPromptBtn` в шапке `#feedbackModal`, модалка `#fbPromptModal` («Модель по умолчанию» → `feedback_profile`, шаблон → `feedback_prompt`, описание ключей, счётчик 8000, «Сбросить» очищает оба поля). Персональная модель применяется в `resetForm()` к `#fbModel`, пустое значение = активная модель системы. Селект `#fbModel` отправляется как `ai_profile` и при создании, и при редактировании; `PUT /api/feedbacks/:id` сохраняет его (раньше терял), а при редактировании `loadFeedback()` подставляет модель из записи
|
||||
- **Учёт запуска ИИ**: колонки `feedbacks.ai_prompt` (итоговый system-промпт после подстановки ключей), `ai_model` (имя модели, реально вызванной через `callModel`), `ai_duration_ms` (время ответа). Пишет их `finish()` воркера; схема — в `db/init.sql`, `db/migration.sql`, `ensureFeedbacksTable()`, SELECT `GET /api/feedbacks` и restore-INSERT
|
||||
- **Кнопка «ИИ-запрос»** на карточке отзыва (`data-aiinfo` + поповер `.fb-ai-pop` в `public/js/feedback.js`): показывает статус, модель, время ответа, время проверки, ошибку и отправленный промпт. Появляется при `ai_status !== 'none'` или наличии `ai_prompt`; попадает в разметку `fbItemHtml()`, закрывается по клику вне/Escape
|
||||
- **Аудит запуска**: воркер пишет `feedback.ai.run` (id, status, attempt, model, profile, duration_ms, prompt ≤12000, input ≤6000, output ≤6000, changed, len_before/len_after) или `feedback.ai.error` (id, attempt, error ≤500, prompt, input) — `feedback.ai.generate` из `onFeedbackDone` удалён как дублирующий. `PUT /api/feedbacks/:id` пишет `feedback.update` (id, ai_check, ai_profile); `feedback.create` включает `ai_profile`
|
||||
- **Бэкап**: `user_settings` входит в `BACKUP_TABLES`, дамп в `buildBackupArchive`, нормализацию в `normalizeRestoreData`, `DELETE` перед `DELETE FROM users` и `INSERT` после `users` в restore (с проверкой существования пользователя)
|
||||
|
||||
---
|
||||
|
||||
@@ -1873,6 +1873,14 @@ off = parseInt(offset, 10); if (off > 0) { OFFSET $n }
|
||||
- **Ошибки:** 401/403 — от `requireAdmin`
|
||||
- **Примечания:** профили лежат в `settings.ai_profiles` как JSON-массив (`getAiProfiles()`); `api_key` профиля возвращается в ответе (см. «?»)
|
||||
|
||||
### `GET /api/ai/profiles/options`
|
||||
|
||||
- **Доступ:** `requireAuth` — любой авторизованный пользователь, включая тьютора; **филиалы:** не применимо
|
||||
- **Тело:** нет
|
||||
- **Ответ 200:** `{ active, native: { id:'native', name:'Нативная (llama.cpp в Docker)', model: AI_MODEL }, profiles: [{ id, name, model }] }` — **без** `base_url` и `api_key`
|
||||
- **Ошибки:** 401 — от `requireAuth`
|
||||
- **Примечания:** лёгкий вариант `GET /api/ai/profiles` для наполнения селектов моделей на страницах, доступных тьюторам (`public/js/feedback.js` → `loadAiProfiles()`, `public/js/shorts.js` → `openShortsAi()`). Полная версия остаётся `requireAdmin`, потому что в ней отдаётся `api_key`; не заменяйте её в админ-формах
|
||||
|
||||
### `POST /api/ai/profiles`
|
||||
|
||||
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
|
||||
@@ -2547,6 +2555,7 @@ off = parseInt(offset, 10); if (off > 0) { OFFSET $n }
|
||||
| `DELETE /api/entries/:id/photo/enhance-ai/preview` | 34. |
|
||||
| `POST /api/ai/correct` | 35. |
|
||||
| `GET /api/ai/profiles` | 35. |
|
||||
| `GET /api/ai/profiles/options` | 35. |
|
||||
| `POST /api/ai/profiles` | 35. |
|
||||
| `PUT /api/ai/profiles/:id` | 35. |
|
||||
| `DELETE /api/ai/profiles/:id` | 35. |
|
||||
|
||||
+4
-4
@@ -110,9 +110,9 @@
|
||||
- [ ] `GET /api/feedbacks/:id` — с JOIN `groups`, проверка филиала
|
||||
- [ ] `POST /api/feedbacks` — валидация полей (дата `YYYY-MM-DD`, `resident` max 200, `topic` max 500, `text` max 5000, `group_id` int, `past_review` max 2000, `short_message_ids` массив int уникальные, `ai_check` boolean, `ai_profile` opt). Проверка доступа к группе. INSERT. Если `ai_check && feedback_ai_enabled!=='false'` → `ai_status='pending'`, `text=text||''`. **Пометить выбранные short_messages** `used=true, used_at=CURRENT_DATE` (только `deleted_at IS NULL`, можно `AND used=false`). `logAudit('feedback.create', {..., short_count})`. `invalidateFeedbacks()` + `invalidateShorts()`. Если `pending` → `wakeFeedbackWorker()`. 201
|
||||
- [ ] `PUT /api/feedbacks/:id` — редактирование по образцу, без смены `ai_status` по умолчанию. `logAudit`, `invalidateFeedbacks()`
|
||||
- [ ] `DELETE /api/feedbacks/:id` — мягкое удаление (`deleted_at`, `purge_at = now()+trash_purge_days`) по образцу `short_messages:2794–2810`
|
||||
- [ ] `PUT /api/feedbacks/:id/restore` (requireAdmin) — снять `deleted_at`, `purge_at` (2811–2827)
|
||||
- [ ] `DELETE /api/feedbacks/:id/permanent` (requireAdmin) — плановое удаление (2828–2843)
|
||||
- [ ] `DELETE /api/feedbacks/:id` — мягкое удаление (`deleted_at`, `purge_at = now()+trash_purge_days`) по образцу `short_messages:2794–2810`, `feedbackById` (филиал), 404 если уже удалён, `logAudit('feedback.delete')`
|
||||
- [ ] `PUT /api/feedbacks/:id/restore` (requireAuth + `feedbackById`) — снять `deleted_at`, `purge_at`, 404 если не удалён, `logAudit('feedback.restore')`
|
||||
- [ ] `DELETE /api/feedbacks/:id/permanent` (requireAuth + `feedbackById`) — немедленный `DELETE FROM feedbacks WHERE deleted_at IS NOT NULL`, 404 если не удалён, `logAudit('feedback.permanent-delete')`. Мягко удалённые чистит `purgeScheduledDeletions()` по `purge_at`
|
||||
- [ ] `POST /api/feedbacks/:id/ai/retry` (requireAuth) — `ai_status='error'→'pending'`, сброс `ai_error/ai_checked_at`, `wakeFeedbackWorker()`, `invalidateFeedbacks()`
|
||||
- [ ] `POST /api/feedbacks/requeue-failed` (requireAuth, requireAdmin) — массовый перевод `error→pending`, wake + invalidate
|
||||
- [ ] Хелпер `feedbackById(user,id)` по образцу `lessonReportById` (JOIN `groups g`, `branchScope`)
|
||||
@@ -168,7 +168,7 @@
|
||||
- [ ] `connectFeedbackLive()` — SSE `/api/events`, обработка `feedbacks_changed` (debounce) и `feedback_status` (обновить строку)
|
||||
- [ ] Делегирование кликов `[data-edit]/[data-del]/[data-retry]`
|
||||
- [ ] Фильтры с debounce 350мс, `page=1` при смене
|
||||
- [ ] `initFeedback()` — `checkAuth()`, `buildSidebar('feedback')`, `loadGroups()`, загрузка профилей ИИ в `#fbModel` (`GET /api/ai/profiles`, первый `native`), `loadFeedbacks()`, `connectFeedbackLive()`, `lucide.createIcons()`
|
||||
- [ ] `initFeedback()` — `checkAuth()`, `buildSidebar('feedback')`, `loadGroups()`, загрузка профилей ИИ в `#fbModel` (`GET /api/ai/profiles/options`, первый `native`), `loadFeedbacks()`, `connectFeedbackLive()`, `lucide.createIcons()`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -530,6 +530,9 @@ function normalizeRestoreData(data) {
|
||||
ai_checked_at: optTs(r.ai_checked_at),
|
||||
ai_error: optStr(r.ai_error),
|
||||
ai_profile: optStr(r.ai_profile),
|
||||
ai_prompt: optText(r.ai_prompt, 100000),
|
||||
ai_model: optStr(r.ai_model, 200),
|
||||
ai_duration_ms: optInt(r.ai_duration_ms, 0, 86400000),
|
||||
sent_at: optTs(r.sent_at),
|
||||
author_id: optInt(r.author_id),
|
||||
branch_id: optInt(r.branch_id),
|
||||
|
||||
@@ -529,6 +529,9 @@ CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
ai_profile VARCHAR(100),
|
||||
ai_prompt TEXT,
|
||||
ai_model VARCHAR(200),
|
||||
ai_duration_ms INT,
|
||||
sent_at TIMESTAMPTZ,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
|
||||
@@ -631,6 +631,9 @@ ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DE
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_error TEXT;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_profile VARCHAR(100);
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_prompt TEXT;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_model VARCHAR(200);
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_duration_ms INT;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL;
|
||||
|
||||
+70
-4
@@ -17,6 +17,26 @@
|
||||
.fb-keys { display:flex; flex-direction:column; gap:6px; }
|
||||
.fb-keys .fb-key { display:flex; gap:10px; align-items:baseline; font-size:13px; line-height:1.35; }
|
||||
.fb-keys code { background:rgba(127,127,127,.12); border:1px solid var(--border); border-radius:6px; padding:1px 6px; font-size:12px; white-space:nowrap; }
|
||||
.fb-keys-details { border:1px solid var(--border); border-radius:10px; background:rgba(127,127,127,.05); }
|
||||
.fb-keys-details summary { cursor:pointer; list-style:none; display:flex; align-items:center; gap:8px; padding:10px 14px; font-size:.86rem; font-weight:600; color:var(--text); user-select:none; }
|
||||
.fb-keys-details summary::-webkit-details-marker { display:none; }
|
||||
.fb-keys-details summary::before { content:''; width:7px; height:7px; flex:none; border-right:2px solid var(--muted); border-bottom:2px solid var(--muted); transform:rotate(-45deg); transition:transform .15s; margin-left:2px; }
|
||||
.fb-keys-details[open] summary::before { transform:rotate(45deg); }
|
||||
.fb-keys-details[open] summary { border-bottom:1px solid var(--border); }
|
||||
.fb-keys-details .fb-keys-body { display:flex; flex-direction:column; gap:10px; padding:12px 14px; }
|
||||
#fbPromptText { min-height:48vh; resize:vertical; }
|
||||
#fbPromptModal { overflow:auto; align-items:flex-start; padding:4vh 16px; }
|
||||
#fbPromptModal > .edit-modal { margin:auto; }
|
||||
.feedback-item { position:relative; }
|
||||
.fb-ai-pop { position:absolute; left:12px; right:12px; top:calc(100% + 6px); z-index:40; display:flex; flex-direction:column; gap:8px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius); box-shadow:0 14px 34px rgba(0,0,0,.28); padding:14px 16px; }
|
||||
.fb-ai-pop[hidden] { display:none; }
|
||||
.fb-ai-row { display:flex; align-items:center; gap:10px; font-size:.85rem; flex-wrap:wrap; }
|
||||
.fb-ai-row > span { color:var(--muted); min-width:118px; }
|
||||
.fb-ai-row .ai-badge { margin-left:0; }
|
||||
.fb-ai-label { font-size:.75rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; }
|
||||
.fb-ai-pop pre { margin:0; max-height:240px; overflow:auto; white-space:pre-wrap; word-break:break-word; font-size:12px; line-height:1.45; background:rgba(127,127,127,.08); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||
.fb-ai-err { font-size:.83rem; line-height:1.4; color:#ef4444; background:rgba(239,68,68,.08); border:1px solid rgba(239,68,68,.3); border-radius:8px; padding:8px 10px; white-space:pre-wrap; }
|
||||
.fb-ai-empty { font-size:.83rem; color:var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="feedback">
|
||||
@@ -60,7 +80,7 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="feedbackModal">
|
||||
<div class="edit-modal" style="max-width:680px">
|
||||
<div class="edit-modal" style="max-width:960px;width:96%;max-height:92dvh;overflow-y:auto">
|
||||
<div class="fb-modal-head">
|
||||
<h3 id="fbModalTitle">Добавить отзыв</h3>
|
||||
<button type="button" class="icon-btn" id="fbPromptBtn" title="Настройки ИИ-промпта"><i data-lucide="settings"></i></button>
|
||||
@@ -131,7 +151,7 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="fbPromptModal">
|
||||
<div class="edit-modal" style="max-width:720px">
|
||||
<div class="edit-modal" style="max-width:900px">
|
||||
<div class="fb-modal-head">
|
||||
<h3>Настройки ИИ-промпта</h3>
|
||||
<button type="button" class="icon-btn" id="fbPromptClose" title="Закрыть"><i data-lucide="x"></i></button>
|
||||
@@ -142,13 +162,57 @@
|
||||
<select id="fbDefaultModel" class="settings-input"></select>
|
||||
<div class="hint">Подставляется в поле «Модель ИИ» при добавлении отзыва. Пусто — активная модель системы</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<details class="fb-keys-details" id="fbModelsDetails">
|
||||
<summary>Мои модели<span id="fbModelsCount" class="hint"></span></summary>
|
||||
<div class="fb-keys-body">
|
||||
<div id="fbModelsList"></div>
|
||||
<div class="hint">Свои подключения к ИИ. Личные модели идут в списках первыми, системные остаются запасными</div>
|
||||
<div id="fbModelEdit" class="settings-stack" hidden>
|
||||
<div class="settings-stack-grid">
|
||||
<div class="settings-field">
|
||||
<label>Название</label>
|
||||
<input id="fbMName" class="settings-input" maxlength="64" placeholder="Моя модель">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Модель</label>
|
||||
<input id="fbMModel" class="settings-input" maxlength="100" placeholder="gpt-oss:120b">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Base URL</label>
|
||||
<input id="fbMUrl" class="settings-input" maxlength="300" placeholder="http://host:11434/v1">
|
||||
</div>
|
||||
<div class="settings-stack-grid">
|
||||
<div class="settings-field">
|
||||
<label>API-ключ</label>
|
||||
<input id="fbMKey" class="settings-input" maxlength="256" placeholder="необязательно">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>max_tokens</label>
|
||||
<input id="fbMMaxTok" class="settings-input" type="number" min="16" max="131072" placeholder="авто">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="fbMCancel">Отмена</button>
|
||||
<button type="button" class="btn-primary" id="fbMSave">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn-primary ghost" id="fbModelAdd">+ Добавить модель</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Шаблон промпта</label>
|
||||
<textarea id="fbPromptText" class="settings-input" rows="14" maxlength="8000" placeholder="Загрузка…"></textarea>
|
||||
<textarea id="fbPromptText" class="settings-input" rows="24" maxlength="8000" placeholder="Загрузка…"></textarea>
|
||||
<div class="hint"><span id="fbPromptCount">0 / 8000</span> · Пусто — используется системный шаблон</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Ключи подстановки в шаблон</label>
|
||||
<details class="fb-keys-details" id="fbPromptKeys">
|
||||
<summary>Ключи подстановки в шаблон</summary>
|
||||
<div class="fb-keys-body">
|
||||
<div class="fb-keys">
|
||||
<div class="fb-key"><code>{group}</code><span>название группы</span></div>
|
||||
<div class="fb-key"><code>{date}</code><span>дата занятия (ГГГГ-ММ-ДД)</span></div>
|
||||
@@ -161,6 +225,8 @@
|
||||
</div>
|
||||
<div class="hint">Если шаблон содержит хотя бы один ключ — данные подставляются по ним, и автоматический блок «КОНТЕКСТ» в конец промпта не добавляется. Без ключей блок «КОНТЕКСТ» добавляется в конец автоматически.</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="ai-status" id="fbPromptStatus" hidden></div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="fbPromptReset">Сбросить</button>
|
||||
|
||||
@@ -94,6 +94,9 @@ const ACTION_LABELS = {
|
||||
'lesson_report.ai.error': 'Ошибка проверки отчёта по шаблону',
|
||||
'lesson_report.ai.revert': 'Возвращён оригинал отчёта',
|
||||
'lesson_report.version.restore': 'Восстановлена версия отчёта',
|
||||
'feedback.update': 'Изменён отзыв',
|
||||
'feedback.ai.run': 'ИИ сформировал отзыв',
|
||||
'feedback.ai.error': 'Ошибка ИИ при формировании отзыва',
|
||||
};
|
||||
|
||||
const AUDIT_PAGE_SIZE = 50;
|
||||
|
||||
+205
-10
@@ -1,7 +1,7 @@
|
||||
(async function() {
|
||||
const API = location.origin;
|
||||
const PAGE_SIZE = 20;
|
||||
let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = '', userSettings = { settings: {}, defaults: {} }, aiActive = 'native';
|
||||
let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = '', userSettings = { settings: {}, defaults: {} }, aiActive = 'native', myModels = [], modelEditIdx = null;
|
||||
const topicReportCache = new Map();
|
||||
|
||||
function hdr() { const t = sessionStorage.getItem('authToken'); return t ? { 'X-Auth-Token': t } : {}; }
|
||||
@@ -27,6 +27,7 @@
|
||||
const text = (f.text||'').trim();
|
||||
const del = f.deleted_at ? ' style="opacity:.5"' : '';
|
||||
const day = fmtDay(f.feedback_date);
|
||||
const hasAiInfo = f.ai_status !== 'none' || !!f.ai_prompt;
|
||||
return `<div class="lesson-item feedback-item" data-id="${f.id}"${del}>
|
||||
<div class="lesson-item-head">
|
||||
<span class="lesson-author">${escapeHtml(f.group_name||'')}</span>
|
||||
@@ -35,6 +36,7 @@
|
||||
${aiBadge(f.ai_status)}
|
||||
${f.sent_at ? '<span class="ai-badge ai-badge-sent">Отправлено</span>' : ''}
|
||||
<div class="lesson-actions">
|
||||
${hasAiInfo ? `<button type="button" class="btn-link" data-aiinfo="${f.id}">ИИ-запрос</button>` : ''}
|
||||
${f.ai_status === 'error' ? `<button type="button" class="btn-link" data-retry="${f.id}">Повторить</button>` : ''}
|
||||
${f.deleted_at ? '' : `<button type="button" class="btn-link" data-sent="${f.id}">${f.sent_at ? 'Вернуть' : 'Отправлено'}</button>`}
|
||||
<button type="button" class="btn-link" data-edit="${f.id}">Изменить</button>
|
||||
@@ -45,9 +47,46 @@
|
||||
${f.resident ? `<div class="lesson-group">${escapeHtml(f.resident)}</div>` : ''}
|
||||
${f.topic ? `<div class="lesson-topic">${escapeHtml(f.topic)}</div>` : ''}
|
||||
${text ? `<div class="lesson-text">${escapeHtml(text)}</div>` : ''}
|
||||
<div class="fb-ai-pop" id="fbAiPop-${f.id}" hidden>${fbAiInfoHtml(f)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function fmtDurMs(ms) {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '';
|
||||
return ms >= 1000 ? `${(ms / 1000).toFixed(1)} с` : `${Math.round(ms)} мс`;
|
||||
}
|
||||
|
||||
function fmtTs(v) {
|
||||
if (!v) return '';
|
||||
try { if (typeof fmtFull === 'function') return fmtFull(v) || String(v); } catch (e) {}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function fbAiInfoHtml(f) {
|
||||
let html = `<div class="fb-ai-row"><span>Статус</span>${aiBadge(f.ai_status)}</div>`;
|
||||
html += `<div class="fb-ai-row"><span>Модель</span><b>${escapeHtml(f.ai_model || '—')}</b></div>`;
|
||||
const dur = fmtDurMs(f.ai_duration_ms);
|
||||
html += `<div class="fb-ai-row"><span>Время ответа</span><b>${escapeHtml(dur || '—')}</b></div>`;
|
||||
if (f.ai_checked_at) html += `<div class="fb-ai-row"><span>Проверено</span><b>${escapeHtml(fmtTs(f.ai_checked_at))}</b></div>`;
|
||||
if (f.ai_error) html += `<div class="fb-ai-err"><b>Ошибка:</b> ${escapeHtml(f.ai_error)}</div>`;
|
||||
if (f.ai_prompt) {
|
||||
html += `<div class="fb-ai-label">Промпт, отправленный в модель</div><pre>${escapeHtml(f.ai_prompt)}</pre>`;
|
||||
} else {
|
||||
html += '<div class="fb-ai-empty">Нет данных о запуске ИИ</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function closeAiPops() {
|
||||
document.querySelectorAll('.fb-ai-pop').forEach(p => { p.hidden = true; });
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.closest('[data-aiinfo]') || e.target.closest('.fb-ai-pop')) return;
|
||||
closeAiPops();
|
||||
});
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAiPops(); });
|
||||
|
||||
function ensureModal() {
|
||||
let m = document.getElementById('feedbackModal');
|
||||
if (m) return m;
|
||||
@@ -89,18 +128,120 @@
|
||||
const j = await r.json();
|
||||
userSettings.settings = j.settings || {};
|
||||
userSettings.defaults = j.defaults || {};
|
||||
parseMyModels();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function openPromptModal() {
|
||||
function parseMyModels() {
|
||||
try { myModels = JSON.parse(userSettings.settings.feedback_models || '[]'); } catch (e) { myModels = []; }
|
||||
if (!Array.isArray(myModels)) myModels = [];
|
||||
}
|
||||
|
||||
function renderModelsList() {
|
||||
const el = document.getElementById('fbModelsList');
|
||||
if (!el) return;
|
||||
const cnt = document.getElementById('fbModelsCount');
|
||||
if (cnt) cnt.textContent = myModels.length ? ` (${myModels.length})` : '';
|
||||
if (!myModels.length) {
|
||||
el.innerHTML = '<div class="hint">Своих моделей нет — используются системные</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = myModels.map((m, i) => `
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;align-items:center;padding:4px 0">
|
||||
<span>${escapeHtml(m.name)} — <span class="muted">${escapeHtml(m.model)}</span></span>
|
||||
<span style="display:flex;gap:6px">
|
||||
<button type="button" class="btn-xs" data-medit="${i}">Изменить</button>
|
||||
<button type="button" class="btn-xs" data-mdel="${i}">Удалить</button>
|
||||
</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function showModelForm(idx) {
|
||||
modelEditIdx = idx;
|
||||
const m = idx == null ? null : myModels[idx];
|
||||
document.getElementById('fbMName').value = m ? m.name : '';
|
||||
document.getElementById('fbMModel').value = m ? m.model : '';
|
||||
document.getElementById('fbMUrl').value = m ? m.base_url : '';
|
||||
document.getElementById('fbMKey').value = m ? (m.api_key || '') : '';
|
||||
document.getElementById('fbMMaxTok').value = m && m.max_tokens ? m.max_tokens : '';
|
||||
document.getElementById('fbModelEdit').hidden = false;
|
||||
document.getElementById('fbMName').focus();
|
||||
}
|
||||
|
||||
function hideModelForm() {
|
||||
const el = document.getElementById('fbModelEdit');
|
||||
if (el) el.hidden = true;
|
||||
modelEditIdx = null;
|
||||
}
|
||||
|
||||
function saveModelForm() {
|
||||
const name = document.getElementById('fbMName').value.trim();
|
||||
const model = document.getElementById('fbMModel').value.trim();
|
||||
const base = document.getElementById('fbMUrl').value.trim().replace(/\/+$/, '');
|
||||
const key = document.getElementById('fbMKey').value.trim();
|
||||
const tok = document.getElementById('fbMMaxTok').value.trim();
|
||||
if (!name) return alert('Укажите название модели');
|
||||
if (!model) return alert('Укажите имя модели');
|
||||
try {
|
||||
const u = new URL(base);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad');
|
||||
} catch (e) { return alert('Base URL должен начинаться с http:// или https://'); }
|
||||
let maxTokens = null;
|
||||
if (tok) {
|
||||
maxTokens = parseInt(tok, 10);
|
||||
if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 131072) return alert('max_tokens: 16–131072');
|
||||
}
|
||||
if (modelEditIdx == null && myModels.length >= 10) return alert('Не больше 10 моделей');
|
||||
const entry = {
|
||||
id: modelEditIdx == null ? `me-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` : myModels[modelEditIdx].id,
|
||||
name, model, base_url: base, api_key: key, max_tokens: maxTokens
|
||||
};
|
||||
if (modelEditIdx == null) myModels.push(entry); else myModels[modelEditIdx] = entry;
|
||||
hideModelForm();
|
||||
renderModelsList();
|
||||
refreshModelSelects();
|
||||
}
|
||||
|
||||
function deleteModel(i) {
|
||||
const m = myModels[i];
|
||||
if (!m) return;
|
||||
if (!confirm(`Удалить модель «${m.name}»?`)) return;
|
||||
myModels.splice(i, 1);
|
||||
const dst = document.getElementById('fbDefaultModel');
|
||||
if (dst && dst.value === m.id) dst.value = '';
|
||||
renderModelsList();
|
||||
refreshModelSelects();
|
||||
}
|
||||
|
||||
function fillDefaultModelOptions(preserve) {
|
||||
const src = document.getElementById('fbModel');
|
||||
const dst = document.getElementById('fbDefaultModel');
|
||||
if (!src || !dst) return;
|
||||
dst.innerHTML = '<option value="">Активная модель по умолчанию</option>' + src.innerHTML;
|
||||
dst.value = userSettings.settings.feedback_profile || '';
|
||||
dst.value = preserve || '';
|
||||
if (preserve && dst.value !== preserve) {
|
||||
const o = document.createElement('option');
|
||||
o.value = preserve;
|
||||
o.textContent = `${preserve} (недоступна)`;
|
||||
dst.appendChild(o);
|
||||
dst.value = preserve;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModelSelects() {
|
||||
const keep = document.getElementById('fbDefaultModel').value;
|
||||
await loadAiProfiles();
|
||||
fillDefaultModelOptions(keep);
|
||||
}
|
||||
|
||||
function openPromptModal() {
|
||||
fillDefaultModelOptions(userSettings.settings.feedback_profile);
|
||||
const ta = document.getElementById('fbPromptText');
|
||||
ta.value = userSettings.settings.feedback_prompt || '';
|
||||
ta.placeholder = userSettings.defaults.feedback_prompt || 'Системный шаблон';
|
||||
document.getElementById('fbPromptStatus').hidden = true;
|
||||
hideModelForm();
|
||||
renderModelsList();
|
||||
updatePromptCount();
|
||||
document.getElementById('fbPromptModal').classList.add('open');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
@@ -108,10 +249,16 @@
|
||||
|
||||
async function savePromptSettings() {
|
||||
const st = document.getElementById('fbPromptStatus');
|
||||
if (!document.getElementById('fbModel').options.length) {
|
||||
st.hidden = false;
|
||||
st.innerHTML = '<span class="ai-badge error">Список моделей не загружен — сохранение отменено</span>';
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
settings: {
|
||||
feedback_prompt: document.getElementById('fbPromptText').value.trim(),
|
||||
feedback_profile: document.getElementById('fbDefaultModel').value || '',
|
||||
feedback_models: myModels.length ? JSON.stringify(myModels) : '',
|
||||
}
|
||||
};
|
||||
try {
|
||||
@@ -124,6 +271,9 @@
|
||||
}
|
||||
userSettings.settings = j.settings || {};
|
||||
userSettings.defaults = j.defaults || userSettings.defaults;
|
||||
parseMyModels();
|
||||
renderModelsList();
|
||||
await loadAiProfiles();
|
||||
st.hidden = false;
|
||||
st.innerHTML = '<span class="ai-badge done">Сохранено</span>';
|
||||
setTimeout(closePromptModal, 700);
|
||||
@@ -224,16 +374,32 @@
|
||||
|
||||
async function loadAiProfiles() {
|
||||
try {
|
||||
const r = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
|
||||
const r = await fetch(`${API}/api/ai/profiles/options`, { headers: hdr() });
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
const sel = document.getElementById('fbModel');
|
||||
if (!sel) return;
|
||||
const keep = sel.value;
|
||||
sel.innerHTML = '';
|
||||
const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sel.appendChild(nativeOpt);
|
||||
(d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sel.appendChild(o); });
|
||||
if (d.active) sel.value = d.active;
|
||||
if (myModels.length) {
|
||||
const mine = document.createElement('optgroup');
|
||||
mine.label = 'Мои модели';
|
||||
myModels.forEach(m => {
|
||||
const o = document.createElement('option');
|
||||
o.value = m.id;
|
||||
o.textContent = m.name;
|
||||
mine.appendChild(o);
|
||||
});
|
||||
sel.appendChild(mine);
|
||||
}
|
||||
const sys = document.createElement('optgroup');
|
||||
sys.label = 'Системные';
|
||||
const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sys.appendChild(nativeOpt);
|
||||
(d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sys.appendChild(o); });
|
||||
sel.appendChild(sys);
|
||||
aiActive = d.active || 'native';
|
||||
if (keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
|
||||
else sel.value = aiActive;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -317,6 +483,8 @@
|
||||
lastFbAutoText = '';
|
||||
document.getElementById('fbTextHint').textContent = `${(f.text||'').length} / 5000`;
|
||||
document.getElementById('fbAiCheck').checked = (f.ai_status === 'pending' || f.ai_status === 'processing' || f.ai_status === 'none') ? true : false;
|
||||
const fms = document.getElementById('fbModel');
|
||||
if (f.ai_profile && [...fms.options].some(o => o.value === f.ai_profile)) fms.value = f.ai_profile;
|
||||
document.getElementById('fbModalTitle').textContent = 'Редактировать отзыв';
|
||||
document.getElementById('fbSubmit').textContent = 'Сохранить';
|
||||
if (f.short_message_ids && f.short_message_ids.length) {
|
||||
@@ -396,6 +564,15 @@
|
||||
updatePromptCount();
|
||||
});
|
||||
document.getElementById('fbPromptSave').addEventListener('click', savePromptSettings);
|
||||
document.getElementById('fbModelAdd').addEventListener('click', () => showModelForm(null));
|
||||
document.getElementById('fbMCancel').addEventListener('click', hideModelForm);
|
||||
document.getElementById('fbMSave').addEventListener('click', saveModelForm);
|
||||
document.getElementById('fbModelsList').addEventListener('click', e => {
|
||||
const ed = e.target.closest('[data-medit]');
|
||||
if (ed) { showModelForm(parseInt(ed.dataset.medit, 10)); return; }
|
||||
const del = e.target.closest('[data-mdel]');
|
||||
if (del) deleteModel(parseInt(del.dataset.mdel, 10));
|
||||
});
|
||||
document.getElementById('feedbackModal').addEventListener('click', e => { if (e.target.id === 'feedbackModal') closeModal(); });
|
||||
document.getElementById('fbForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
@@ -436,6 +613,11 @@
|
||||
});
|
||||
document.getElementById('fbTopic').addEventListener('change', applyTopicText);
|
||||
document.getElementById('feedbackList').addEventListener('click', async e => {
|
||||
const aiinfo = e.target.closest('[data-aiinfo]'); if (aiinfo) {
|
||||
const pop = document.getElementById(`fbAiPop-${aiinfo.dataset.aiinfo}`);
|
||||
if (pop) { const wasOpen = !pop.hidden; closeAiPops(); pop.hidden = wasOpen; }
|
||||
return;
|
||||
}
|
||||
const sentBtn = e.target.closest('[data-sent]'); if (sentBtn) {
|
||||
const id = parseInt(sentBtn.dataset.sent, 10);
|
||||
if (!id) return;
|
||||
@@ -448,14 +630,27 @@
|
||||
const del = e.target.closest('[data-del]'); if (del) {
|
||||
const id = parseInt(del.dataset.id||del.dataset.del,10);
|
||||
if (!id) return;
|
||||
if (!confirm('Удалить отзыв?')) return;
|
||||
try { const r = await fetch(`${API}/api/feedbacks/${id}`, { method:'DELETE', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {}
|
||||
const row = feedbacks.find(x => x.id === id);
|
||||
const perm = !!(row && row.deleted_at);
|
||||
if (!confirm(perm ? 'Удалить отзыв навсегда? Это действие нельзя отменить.' : 'Удалить отзыв?')) return;
|
||||
const url = perm ? `${API}/api/feedbacks/${id}/permanent` : `${API}/api/feedbacks/${id}`;
|
||||
try {
|
||||
const r = await fetch(url, { method:'DELETE', headers: hdr() });
|
||||
if (r.ok) { loadFeedbacks(); return; }
|
||||
const err = await r.json().catch(() => null);
|
||||
alert((err && err.error) || `Ошибка ${r.status}`);
|
||||
} catch (err) { alert('Ошибка сети'); }
|
||||
return;
|
||||
}
|
||||
const ret = e.target.closest('[data-restore]'); if (ret) {
|
||||
const id = parseInt(ret.dataset.id||ret.dataset.restore,10);
|
||||
if (!id) return;
|
||||
try { const r = await fetch(`${API}/api/feedbacks/${id}/restore`, { method:'PUT', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {}
|
||||
try {
|
||||
const r = await fetch(`${API}/api/feedbacks/${id}/restore`, { method:'PUT', headers: hdr() });
|
||||
if (r.ok) { loadFeedbacks(); return; }
|
||||
const err = await r.json().catch(() => null);
|
||||
alert((err && err.error) || `Ошибка ${r.status}`);
|
||||
} catch (err) { alert('Ошибка сети'); }
|
||||
return;
|
||||
}
|
||||
const retry = e.target.closest('[data-retry]'); if (retry) {
|
||||
|
||||
+1
-1
@@ -292,7 +292,7 @@ async function openShortsAi() {
|
||||
document.getElementById('aiCorrectModal').classList.add('open');
|
||||
const sel = document.getElementById('aiModelSelect');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
|
||||
const res = await fetch(`${API}/api/ai/profiles/options`, { headers: hdr() });
|
||||
if (!res.ok) throw new Error('Профили недоступны');
|
||||
const data = await res.json();
|
||||
const profiles = data.profiles || [];
|
||||
|
||||
@@ -424,7 +424,7 @@
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileMaxTokens">Max tokens (лимит ответа модели)</label>
|
||||
<input type="number" id="aiProfileMaxTokens" class="settings-input" min="16" max="32768" placeholder="Авто (1024–4096)">
|
||||
<span class="hint">Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 1024+ — иначе рассуждения не уместятся и ответ придёт пустым.</span>
|
||||
<span class="hint">Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 4096+ — иначе рассуждения не уместятся и ответ придёт пустым.</span>
|
||||
</div>
|
||||
<div class="ai-test-status" id="aiProfileTestStatus"></div>
|
||||
<div class="actions" style="flex-wrap:wrap">
|
||||
|
||||
@@ -1396,13 +1396,16 @@ async function purgeScheduledDeletions() {
|
||||
}
|
||||
const sres = await pool.query('DELETE FROM short_messages WHERE purge_at IS NOT NULL AND purge_at <= now()');
|
||||
const scount = sres.rowCount;
|
||||
if (erefs.rowCount || gcount || ccount || scount) {
|
||||
const fbres = await pool.query('DELETE FROM feedbacks WHERE purge_at IS NOT NULL AND purge_at <= now()');
|
||||
const fbcount = fbres.rowCount;
|
||||
if (erefs.rowCount || gcount || ccount || scount || fbcount) {
|
||||
invalidateEntries();
|
||||
invalidateGroups();
|
||||
invalidateStats();
|
||||
}
|
||||
if (scount) invalidateShorts();
|
||||
return { entries: erefs.rowCount, groups: gcount, chat_threads: ccount, short_messages: scount };
|
||||
if (fbcount) invalidateFeedbacks();
|
||||
return { entries: erefs.rowCount, groups: gcount, chat_threads: ccount, short_messages: scount, feedbacks: fbcount };
|
||||
}
|
||||
|
||||
function safeUnlink(relPath) {
|
||||
@@ -2681,6 +2684,9 @@ async function ensureFeedbacksTable() {
|
||||
ai_checked_at TIMESTAMPTZ,
|
||||
ai_error TEXT,
|
||||
ai_profile VARCHAR(100),
|
||||
ai_prompt TEXT,
|
||||
ai_model VARCHAR(200),
|
||||
ai_duration_ms INT,
|
||||
author_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
branch_id INT REFERENCES branches(id) ON DELETE SET NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
@@ -2699,6 +2705,9 @@ async function ensureFeedbacksTable() {
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_error TEXT`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_profile VARCHAR(100)`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_prompt TEXT`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_model VARCHAR(200)`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ai_duration_ms INT`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS author_id INT REFERENCES users(id) ON DELETE SET NULL`);
|
||||
await pool.query(`ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS branch_id INT REFERENCES branches(id) ON DELETE SET NULL`);
|
||||
@@ -3011,7 +3020,7 @@ app.get('/api/feedbacks', requireAuth, async (req, res) => {
|
||||
params.push(limit, offset);
|
||||
const count = await pool.query(`SELECT COUNT(*) FROM feedbacks f ${where}`, countParams);
|
||||
const items = await pool.query(
|
||||
`SELECT f.id, f.feedback_date, f.group_id, f.resident, f.topic, f.past_review, f.short_message_ids, f.text, f.ai_status, f.ai_checked_at, f.ai_error, f.ai_profile, f.sent_at, f.author_id, f.branch_id, f.deleted_at, f.purge_at, f.created_at, f.updated_at, g.name AS group_name
|
||||
`SELECT f.id, f.feedback_date, f.group_id, f.resident, f.topic, f.past_review, f.short_message_ids, f.text, f.ai_status, f.ai_checked_at, f.ai_error, f.ai_profile, f.ai_prompt, f.ai_model, f.ai_duration_ms, f.sent_at, f.author_id, f.branch_id, f.deleted_at, f.purge_at, f.created_at, f.updated_at, g.name AS group_name
|
||||
FROM feedbacks f
|
||||
LEFT JOIN groups g ON g.id = f.group_id
|
||||
${where}
|
||||
@@ -3069,7 +3078,7 @@ app.post('/api/feedbacks', requireAuth, async (req, res) => {
|
||||
await client.query(`UPDATE short_messages SET used = true, used_at = CURRENT_DATE WHERE id = ANY($1::int[]) AND deleted_at IS NULL`, [shortIds]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
await logAudit(req, 'feedback.create', { id: fb.id, group_id: groupId, resident, ai_check: aiCheck, ai_status: fb.ai_status, short_count: shortIds.length });
|
||||
await logAudit(req, 'feedback.create', { id: fb.id, group_id: groupId, resident, ai_check: aiCheck, ai_profile: aiProfile || null, ai_status: fb.ai_status, short_count: shortIds.length });
|
||||
invalidateFeedbacks();
|
||||
invalidateShorts();
|
||||
if (aiCheck) wakeFeedbackWorker();
|
||||
@@ -3252,11 +3261,19 @@ app.put('/api/feedbacks/:id', requireAuth, async (req, res) => {
|
||||
const ok = await groupBelongsToBranches(req.user, groupId);
|
||||
if (!ok) return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||
}
|
||||
const aiCheck = !!req.body?.ai_check;
|
||||
const aiProfile = optStr(req.body?.ai_profile, 100);
|
||||
await pool.query(
|
||||
`UPDATE feedbacks SET feedback_date=$1, group_id=$2, resident=$3, topic=$4, past_review=$5, short_message_ids=$6, text=$7, updated_at=now() WHERE id=$8`,
|
||||
[feedbackDate, groupId, resident, topic, pastReview, shortIds, text || '', id]
|
||||
`UPDATE feedbacks SET feedback_date=$1, group_id=$2, resident=$3, topic=$4, past_review=$5, short_message_ids=$6, text=$7, ai_profile=$8,
|
||||
ai_status = CASE WHEN $9::boolean AND ai_status IN ('none','error','done','skipped') THEN 'pending' ELSE ai_status END,
|
||||
ai_error = CASE WHEN $9::boolean AND ai_status IN ('none','error','done','skipped') THEN NULL ELSE ai_error END,
|
||||
ai_checked_at = CASE WHEN $9::boolean AND ai_status IN ('none','error','done','skipped') THEN NULL ELSE ai_checked_at END,
|
||||
updated_at=now() WHERE id=$10`,
|
||||
[feedbackDate, groupId, resident, topic, pastReview, shortIds, text || '', aiProfile || null, aiCheck, id]
|
||||
);
|
||||
invalidateFeedbacks();
|
||||
if (aiCheck) wakeFeedbackWorker();
|
||||
await logAudit(req, 'feedback.update', { id, ai_check: aiCheck, ai_profile: aiProfile || null });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -3268,23 +3285,35 @@ app.delete('/api/feedbacks/:id', requireAuth, async (req, res) => {
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
const d = await pool.query('SELECT COALESCE((SELECT value::int FROM settings WHERE key=\'trash_purge_days\'),30) AS d');
|
||||
const days = d.rows[0]?.d || 30;
|
||||
await pool.query('UPDATE feedbacks SET deleted_at=now(), purge_at = now() + ($1 || \' days\')::interval WHERE id=$2 AND deleted_at IS NULL', [days, id]);
|
||||
const upd = await pool.query('UPDATE feedbacks SET deleted_at=now(), purge_at = now() + ($1 || \' days\')::interval WHERE id=$2 AND deleted_at IS NULL', [days, id]);
|
||||
if (!upd.rowCount) return res.status(404).json({ error: 'Уже удалён' });
|
||||
await logAudit(req, 'feedback.delete', { id });
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/feedbacks/:id/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||
app.put('/api/feedbacks/:id/restore', requireAuth, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
await pool.query('UPDATE feedbacks SET deleted_at=NULL, purge_at=NULL WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
|
||||
const fb = await feedbackById(req.user, id);
|
||||
if (!fb) return res.status(404).json({ error: 'Not found' });
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
const upd = await pool.query('UPDATE feedbacks SET deleted_at=NULL, purge_at=NULL WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
|
||||
if (!upd.rowCount) return res.status(404).json({ error: 'Не удалён' });
|
||||
await logAudit(req, 'feedback.restore', { id });
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/feedbacks/:id/permanent', requireAuth, requireAdmin, async (req, res) => {
|
||||
app.delete('/api/feedbacks/:id/permanent', requireAuth, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' });
|
||||
await pool.query('UPDATE feedbacks SET purge_at = now() + (COALESCE((SELECT value::int FROM settings WHERE key=\'trash_purge_days\'),30) || \' days\')::interval WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
|
||||
const fb = await feedbackById(req.user, id);
|
||||
if (!fb) return res.status(404).json({ error: 'Not found' });
|
||||
if (fb.forbidden) return res.status(403).json({ error: 'Нет доступа' });
|
||||
const del = await pool.query('DELETE FROM feedbacks WHERE id=$1 AND deleted_at IS NOT NULL', [id]);
|
||||
if (!del.rowCount) return res.status(404).json({ error: 'Не удалён' });
|
||||
await logAudit(req, 'feedback.permanent-delete', { id });
|
||||
invalidateFeedbacks();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
@@ -3620,8 +3649,43 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
|
||||
});
|
||||
|
||||
// --- User settings (персональные настройки пользователя) ---
|
||||
const USER_SETTINGS_KEYS = new Set(['feedback_prompt', 'feedback_profile']);
|
||||
const USER_SETTINGS_LIMITS = { feedback_prompt: 8000, feedback_profile: 100 };
|
||||
const USER_SETTINGS_KEYS = new Set(['feedback_prompt', 'feedback_profile', 'feedback_models']);
|
||||
const USER_SETTINGS_LIMITS = { feedback_prompt: 8000, feedback_profile: 100, feedback_models: 16000 };
|
||||
|
||||
function normalizeFeedbackModels(value) {
|
||||
let arr;
|
||||
try { arr = JSON.parse(value); } catch (e) { throw new Error('Некорректный JSON моделей'); }
|
||||
if (!Array.isArray(arr)) throw new Error('Ожидался список моделей');
|
||||
if (arr.length > 10) throw new Error('Не больше 10 моделей');
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const m of arr) {
|
||||
if (!m || typeof m !== 'object') throw new Error('Некорректная модель');
|
||||
const id = String(m.id || '').trim();
|
||||
const name = String(m.name || '').trim();
|
||||
const baseUrl = String(m.base_url || '').trim();
|
||||
const model = String(m.model || '').trim();
|
||||
const apiKey = String(m.api_key || '').trim();
|
||||
if (!id || id.length > 40 || !/^[\w-]+$/.test(id)) throw new Error('Некорректный id модели');
|
||||
if (!name || name.length > 64) throw new Error('Название модели: 1–64 символов');
|
||||
if (!model || model.length > 100) throw new Error('Имя модели: 1–100 символов');
|
||||
if (apiKey.length > 256) throw new Error('Ключ длиннее 256 символов');
|
||||
if (baseUrl.length > 300) throw new Error('URL длиннее 300 символов');
|
||||
try {
|
||||
const u = new URL(baseUrl);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad');
|
||||
} catch (e) { throw new Error('Некорректный base_url (нужен http/https)'); }
|
||||
let maxTokens = null;
|
||||
if (m.max_tokens != null && m.max_tokens !== '') {
|
||||
maxTokens = parseInt(m.max_tokens, 10);
|
||||
if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 131072) throw new Error('max_tokens: 16–131072');
|
||||
}
|
||||
if (seen.has(id)) throw new Error('Дублирующийся id модели');
|
||||
seen.add(id);
|
||||
out.push({ id, name, base_url: baseUrl, model, api_key: apiKey, max_tokens: maxTokens });
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
async function userSettingsPayload(userId) {
|
||||
const { rows } = await pool.query('SELECT key, value FROM user_settings WHERE user_id = $1', [userId]);
|
||||
@@ -3645,6 +3709,14 @@ app.put('/api/user-settings', requireAuth, async (req, res) => {
|
||||
if (value.length > USER_SETTINGS_LIMITS[key]) {
|
||||
return res.status(400).json({ error: `${key} длиннее ${USER_SETTINGS_LIMITS[key]} символов` });
|
||||
}
|
||||
if (key === 'feedback_models' && value) {
|
||||
try {
|
||||
entries.push([key, normalizeFeedbackModels(value)]);
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: e.message || 'Некорректный список моделей' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
entries.push([key, value]);
|
||||
}
|
||||
try {
|
||||
@@ -3897,9 +3969,6 @@ async function onFeedbackDone({ row, status, text, aiText, error }) {
|
||||
}).catch(() => {});
|
||||
if (status === 'done') {
|
||||
try {
|
||||
const lenBefore = row.text ? row.text.length : 0;
|
||||
const lenAfter = (aiText || '').length;
|
||||
logAudit(null, 'feedback.ai.generate', { id: row.id, len_before: lenBefore, len_after: lenAfter }).catch(() => {});
|
||||
const branchId = row.branch_id ?? null;
|
||||
pushNotification('feedback.ai.ready', { id: row.id, resident: row.resident }, branchId).catch(() => {});
|
||||
} catch (e) {}
|
||||
@@ -4369,8 +4438,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
const okUser = x.author_id == null || (await client.query('SELECT 1 FROM users WHERE id = $1', [x.author_id])).rowCount;
|
||||
const okBranch = x.branch_id == null || (await client.query('SELECT 1 FROM branches WHERE id = $1', [x.branch_id])).rowCount;
|
||||
await client.query(
|
||||
'INSERT INTO feedbacks (id, feedback_date, group_id, resident, topic, past_review, short_message_ids, text, ai_status, ai_checked_at, ai_error, ai_profile, author_id, branch_id, deleted_at, purge_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)',
|
||||
[x.id, x.feedback_date, okGroup ? x.group_id : null, x.resident, x.topic, x.past_review, Array.isArray(x.short_message_ids) ? x.short_message_ids : [], x.text || '', x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.ai_profile, okUser ? x.author_id : null, okBranch ? x.branch_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at]
|
||||
'INSERT INTO feedbacks (id, feedback_date, group_id, resident, topic, past_review, short_message_ids, text, ai_status, ai_checked_at, ai_error, ai_profile, ai_prompt, ai_model, ai_duration_ms, author_id, branch_id, deleted_at, purge_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)',
|
||||
[x.id, x.feedback_date, okGroup ? x.group_id : null, x.resident, x.topic, x.past_review, Array.isArray(x.short_message_ids) ? x.short_message_ids : [], x.text || '', x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.ai_profile, x.ai_prompt, x.ai_model, x.ai_duration_ms, okUser ? x.author_id : null, okBranch ? x.branch_id : null, x.deleted_at, x.purge_at, x.created_at, x.updated_at]
|
||||
);
|
||||
}
|
||||
for (const x of ndata.audit_log) {
|
||||
@@ -8168,6 +8237,16 @@ app.get('/api/ai/profiles', requireAdmin, async (_, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/ai/profiles/options', requireAuth, async (_, res) => {
|
||||
const profiles = await getAiProfiles();
|
||||
const active = await getSetting('ai_active_profile', 'native');
|
||||
res.json({
|
||||
active,
|
||||
native: { id: 'native', name: 'Нативная (llama.cpp в Docker)', model: AI_MODEL },
|
||||
profiles: profiles.map(p => ({ id: p.id, name: p.name, model: p.model })),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/ai/profiles', requireAdmin, async (req, res) => {
|
||||
const v = validateProfileBody(req.body);
|
||||
if (v.error) return res.status(400).json({ error: v.error });
|
||||
|
||||
@@ -1068,6 +1068,7 @@ function createFeedbackWriter(opts) {
|
||||
const MIN_CHARS = 1;
|
||||
const MAX_INPUT_CHARS = 6000;
|
||||
const TEXT_MAX = 5000;
|
||||
const MAX_TOKENS_CAP = 8192;
|
||||
|
||||
let running = false;
|
||||
let timer = null;
|
||||
@@ -1094,13 +1095,29 @@ function createFeedbackWriter(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveActiveProfile(profileId) {
|
||||
async function getPersonalModels(authorId) {
|
||||
if (!authorId) return [];
|
||||
try {
|
||||
const r = await pool.query(`SELECT value FROM user_settings WHERE user_id = $1 AND key = 'feedback_models'`, [authorId]);
|
||||
const raw = r.rows[0] && r.rows[0].value;
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveActiveProfile(profileId, authorId) {
|
||||
try {
|
||||
const active = await getSettingValue('ai_active_profile', 'native');
|
||||
const profilesRaw = await getSettingValue('ai_profiles', '[]');
|
||||
let profiles = [];
|
||||
try { profiles = JSON.parse(profilesRaw); } catch (e) {}
|
||||
if (profileId) {
|
||||
const mine = await getPersonalModels(authorId);
|
||||
const own = mine.find(x => String(x.id) === String(profileId));
|
||||
if (own) return { type: 'profile', profile: own };
|
||||
const p = profiles.find(x => String(x.id) === String(profileId));
|
||||
if (p) return { type: 'profile', profile: p };
|
||||
}
|
||||
@@ -1119,49 +1136,57 @@ function createFeedbackWriter(opts) {
|
||||
return b.includes('/v1') ? b : `${b}/v1`;
|
||||
}
|
||||
|
||||
async function callModel(systemPrompt, userText, profileId) {
|
||||
async function callModel(systemPrompt, userText, profileId, authorId) {
|
||||
const text = String(userText || '');
|
||||
const resolved = await resolveActiveProfile(profileId, authorId);
|
||||
const modelName = resolved.type === 'profile'
|
||||
? (resolved.profile.model || AI_MODEL)
|
||||
: (resolved.model || AI_MODEL);
|
||||
if (text.length > MAX_INPUT_CHARS) {
|
||||
return text.slice(0, TEXT_MAX);
|
||||
return { content: text.slice(0, TEXT_MAX), model: modelName };
|
||||
}
|
||||
const resolved = await resolveActiveProfile(profileId);
|
||||
const controller = new AbortController();
|
||||
const timerAbort = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
let url, headers, body;
|
||||
let url, headers, model, messages, maxTokens;
|
||||
if (resolved.type === 'profile') {
|
||||
const p = resolved.profile;
|
||||
const base = normalizeOpenAiBase(p.base_url || aiUrl);
|
||||
url = `${base}/chat/completions`;
|
||||
headers = { 'Content-Type': 'application/json' };
|
||||
if (p.api_key) headers.Authorization = `Bearer ${p.api_key}`;
|
||||
body = {
|
||||
model: p.model || AI_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: text }
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: p.max_tokens || Math.min(2048, Math.max(256, text.length * 2))
|
||||
};
|
||||
model = p.model || AI_MODEL;
|
||||
maxTokens = parseInt(p.max_tokens, 10) || Math.min(8192, Math.max(4096, text.length * 2 + 512));
|
||||
} else {
|
||||
const base = normalizeOpenAiBase(resolved.baseUrl || aiUrl);
|
||||
url = `${base}/chat/completions`;
|
||||
headers = { 'Content-Type': 'application/json' };
|
||||
body = {
|
||||
model: resolved.model || AI_MODEL,
|
||||
messages: [
|
||||
model = resolved.model || AI_MODEL;
|
||||
maxTokens = Math.min(8192, Math.max(4096, text.length * 2 + 512));
|
||||
}
|
||||
messages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: text }
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: Math.min(2048, Math.max(256, text.length * 2))
|
||||
};
|
||||
}
|
||||
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal });
|
||||
];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ model, messages, temperature: 0.1, max_tokens: maxTokens }),
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.choices?.[0]?.message?.content?.trim() || '';
|
||||
const choice = data.choices && data.choices[0];
|
||||
const content = (choice && choice.message && choice.message.content || '').trim();
|
||||
const finishReason = (choice && choice.finish_reason) || '';
|
||||
if (!content && finishReason === 'length' && maxTokens < MAX_TOKENS_CAP) {
|
||||
maxTokens = Math.min(MAX_TOKENS_CAP, maxTokens * 4);
|
||||
continue;
|
||||
}
|
||||
return { content, finishReason, model: modelName };
|
||||
}
|
||||
return { content: '', finishReason: 'length', model: modelName };
|
||||
} finally {
|
||||
clearTimeout(timerAbort);
|
||||
}
|
||||
@@ -1271,18 +1296,59 @@ function createFeedbackWriter(opts) {
|
||||
const systemPrompt = hasKeys
|
||||
? prompt.replace(/\{(\w+)\}/g, (m, k) => (Object.prototype.hasOwnProperty.call(keys, k) ? String(keys[k]) : m))
|
||||
: prompt + (context ? '\n\nКОНТЕКСТ:\n' + context : '');
|
||||
let out = await callModel(systemPrompt, row.text && row.text.trim() ? row.text : (context || 'Сформируй отзыв по контексту'), row.ai_profile || null);
|
||||
let norm = normalizeOutput(out);
|
||||
if (!norm) return { status: 'error', error: 'Пустой ответ от модели' };
|
||||
const userMsg = row.text && row.text.trim() ? row.text : (context || 'Сформируй отзыв по контексту');
|
||||
const t0 = Date.now();
|
||||
let call;
|
||||
try {
|
||||
call = await callModel(systemPrompt, userMsg, row.ai_profile || null, row.author_id || null);
|
||||
} catch (e) {
|
||||
return { status: 'error', error: e.message || 'error', prompt: systemPrompt, input: userMsg, durationMs: Date.now() - t0 };
|
||||
}
|
||||
const durationMs = Date.now() - t0;
|
||||
const meta = { prompt: systemPrompt, input: userMsg, output: call.content, model: call.model, durationMs };
|
||||
let norm = normalizeOutput(call.content);
|
||||
if (!norm) {
|
||||
const reason = call.finishReason ? ` (finish_reason=${call.finishReason})` : '';
|
||||
return { status: 'error', error: `Пустой ответ от модели${reason}`, ...meta };
|
||||
}
|
||||
if (norm.length > TEXT_MAX) norm = norm.slice(0, TEXT_MAX);
|
||||
const oldText = (row.text || '').trim();
|
||||
if (oldText && norm.trim() === oldText) {
|
||||
return { status: 'skipped', text: oldText };
|
||||
return { status: 'skipped', text: oldText, ...meta };
|
||||
}
|
||||
return { status: 'done', text: norm, aiText: norm };
|
||||
return { status: 'done', text: norm, aiText: norm, ...meta };
|
||||
}
|
||||
|
||||
async function finish(row, res) {
|
||||
function capStr(v, max) {
|
||||
if (v == null) return null;
|
||||
const s = String(v);
|
||||
return s.length > max ? s.slice(0, max) : s;
|
||||
}
|
||||
|
||||
function auditRun(row, res, attempt) {
|
||||
const payload = {
|
||||
id: row.id,
|
||||
status: res.status,
|
||||
attempt: attempt || 1,
|
||||
model: res.model || null,
|
||||
profile: row.ai_profile || 'native',
|
||||
duration_ms: Number.isFinite(res.durationMs) ? Math.round(res.durationMs) : null,
|
||||
prompt: capStr(res.prompt, 12000),
|
||||
input: capStr(res.input, 6000),
|
||||
error: res.error ? String(res.error).slice(0, 500) : null,
|
||||
len_before: (row.text || '').length
|
||||
};
|
||||
if (res.status === 'error') {
|
||||
Promise.resolve(logAudit(null, 'feedback.ai.error', payload)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
payload.output = capStr(res.output, 6000);
|
||||
payload.changed = res.status === 'done';
|
||||
payload.len_after = res.text ? res.text.length : null;
|
||||
Promise.resolve(logAudit(null, 'feedback.ai.run', payload)).catch(() => {});
|
||||
}
|
||||
|
||||
async function finish(row, res, attempt) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
@@ -1292,6 +1358,9 @@ function createFeedbackWriter(opts) {
|
||||
sets.push(`ai_status=$${i++}`); params.push(res.status);
|
||||
sets.push(`ai_checked_at=now()`);
|
||||
if (res.error) { sets.push(`ai_error=$${i++}`); params.push(res.error); } else { sets.push(`ai_error=NULL`); }
|
||||
sets.push(`ai_prompt=$${i++}`); params.push(res.prompt != null ? res.prompt : null);
|
||||
sets.push(`ai_model=$${i++}`); params.push(res.model != null ? res.model : null);
|
||||
sets.push(`ai_duration_ms=$${i++}`); params.push(Number.isFinite(res.durationMs) ? Math.round(res.durationMs) : null);
|
||||
if (res.status === 'done' && res.text) { sets.push(`text=$${i++}`); params.push(res.text); }
|
||||
sets.push(`updated_at=now()`);
|
||||
params.push(row.id);
|
||||
@@ -1303,6 +1372,7 @@ function createFeedbackWriter(opts) {
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
auditRun(row, res, attempt);
|
||||
try { onDone({ row, status: res.status, text: res.text || row.text, aiText: res.aiText, error: res.error }); } catch (e) {}
|
||||
}
|
||||
|
||||
@@ -1341,7 +1411,7 @@ function createFeedbackWriter(opts) {
|
||||
idleMs = IDLE_MIN_MS;
|
||||
continue;
|
||||
}
|
||||
await finish(row, res);
|
||||
await finish(row, res, attempts);
|
||||
idleMs = IDLE_MIN_MS;
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user