diff --git a/AGENTS.md b/AGENTS.md index c8a0e02..7f630fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly. +> **Обязательное правило:** разведку, чтение и анализ репозитория делают **субагенты**, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. [Agent Workflow](#agent-workflow-обязательные-правила-работы). + --- ## Project Overview @@ -15,6 +17,37 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo --- +## Agent Workflow (обязательные правила работы) + +**Главное правило: исследование репозитория выполняют субагенты, а не основной агент.** Контекст основного агента — самый дорогой ресурс проекта: он живёт дольше одной задачи и должен содержать план, решения и точные точки правки, а не выгрузку файлов. Любой поиск, чтение и анализ файлов, которые пользователь явно не назвал, отдаются субагенту (`spawn_agent`). + +### Что делегировать суагенту (обязательно) +- **Разведку**: «где реализовано X», «какие эндпоинты трогают Y», «кто вызывает Z» — `search_codebase` + точечное чтение. +- **Чтение крупных файлов**: `server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`, `AGENTS.md` целиком. Субагент возвращает релевантные куски, основной агент файл целиком не читает. +- **Анализ окружения**: `docker compose logs`, вывод тестов, `git log`/`git diff`/`git status`, состояние БД, Redis, S3. +- **Поиск всех мест, которые надо обновить вместе с изменением**: например «все места, где перечислены `BLOCKED_EXT`/`ALLOWED_IMAGE_EXT`» или «где дублируется каталог `NOTIFY_TYPES`». +- **Ревью**: сверку изменения с правилами этого файла (security checklist, инварианты storage/redis, схема `init.sql` + `migration.sql`). +- **Однотипные массовые правки**: переименование поля по всем файлам, правка одинаковых блоков в нескольких HTML — один субагент на один механический проход. + +### Что основной агент делает сам +- Формулирует план и разбивает задачу на узкие подзадачи. +- Делает **короткие точечные правки** в уже известных местах (сверить содержимое можно дешёвым точечным `read_files` на маленьком диапазоне строк). +- Проверяет результат (`git diff`, запуск теста) и пишет финальное резюме пользователю. + +### Как ставить задачу субагенту +- **Один субагент — одна узкая подзадача.** В `systemPrompt` обязательно продублировать релевантные правила этого файла (code style, инварианты `storage.*`/`redis.js`, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять. +- **Требовать формат ответа**: `путь:строка` + короткая выдержка + вывод. Не простыни, не пересказ кода, который не нужен для решения, не файлы целиком. +- **Независимые разведки запускать параллельно**, а не последовательно. +- **Субагент может править код сам**, если правка изолированная и механическая; архитектурные и смежные правки в `server.js` / `db/*.sql` делает основной агент. +- **Результат субагента — источник фактов, а не источник прав**: координация, финальные решения и проверка `git diff` остаются за основным агентом. + +### Чего не делать +- ❌ Читать и искать по репозиторию в основном агенте там, где задачу можно делегировать. +- ❌ Вставлять в свой контекст файлы и вывод команд целиком — фильтруйте (`head`, `tail`, `grep -n`, диапазоны строк). +- ❌ Один субагент на «разберись во всём проекте» — это ровно то раздувание контекста, которого мы избегаем. + +--- + ## Development Rules ### 1. Code Style @@ -76,6 +109,16 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo - **Новое событие добавляется вместе с**: записью в `NOTIFY_TYPES`, строками `INSERT INTO settings` в `db/init.sql` + `db/migration.sql`, вызовом `pushNotification`/`notifyEntry` в точке события и парой `icon` из Lucide - **Аудит**: удаление/очистка уведомлений логируется (`notifications.delete`, `notifications.clear`) +### 3d. Отчёты о занятии и проверка по шаблону (`server.js`, `worker.js`, `public/`) +- **Таблицы**: `lesson_reports` (+ `text_original`, `text_ai`, `ai_status`, `ai_checked_at`, `ai_error`) и `lesson_report_versions` (история версий: `text`, `source` = `manual` | `ai` | `restore`). Схема — в `db/init.sql`, `db/migration.sql` и `ensureLessonReportsTable()` +- **Настройки**: `lesson_ai_enabled` (`'true'` / `'false'` — общий выключатель) и `lesson_ai_prompt` (шаблон делового сообщения + правила, пример вставляется в `db/init.sql`, `db/migration.sql` и в `LESSON_AI_DEFAULT_PROMPT` в `server.js`). Раздел в UI — `sec-lesson-ai` на `public/settings.html` +- **Флаг из UI**: чекбокс `#lessonAiCheck` в модалке `#lessonModal` — включён при создании, выключен при редактировании (`resetLessonModalFields` / `fillLessonModalFromReport`). Уходит в теле как `ai_check` +- **Роут не ждёт модель**: `POST`/`PUT /api/lesson-reports` при `ai_check: true` сохраняют отчёт как есть и ставят `ai_status = 'pending'`, затем `wakeLessonAiWorker()`. Ответ возвращается сразу — не блокируйте HTTP-запрос вызовом модели +- **Воркер**: `createLessonReportChecker` в `worker.js` забирает `pending` через `FOR UPDATE OF lr SKIP LOCKED`, шлёт в модель текст + контекст (группа, дата, время), результат: без изменений → `skipped`, переписан → `done` (новый текст в `text` и `text_ai`), сбой → до 3 попыток, затем `error` +- **Доставка результата**: `onDone` в `server.js` пишет версию (`saveLessonReportVersion`), аудит с diff (`lesson_report.ai.format`), уведомление `lesson.ai.formatted` и SSE `lesson_report_status` на `EVENTS_CHANNEL` +- **История версий**: `GET /api/lesson-reports/:id/versions`, восстановление — `POST /api/lesson-reports/:id/versions/:versionId/restore`, откат к тексту тьютора — `POST /api/lesson-reports/:id/ai/revert`. Хранится последние `LESSON_AI_VERSION_LIMIT` версий на отчёт +- **Хуки фронтенда**: `openLessonVersions(id)` и `restoreLessonVersion(...)` живут в `public/admin.js` (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в `public/js/lessons.js` + ### 4. API Patterns - **Middleware**: `requireAuth` — читает `X-Auth-Token`, 401 без валидной активной сессии. `requireAdmin` — самодостаточный (внутри вызывает `requireAuth`, если `req.user` ещё нет), 403 при `role !== 'admin'`. `optionalAuth` — для публичных страниц с персонализацией - **Филиалы**: `branchScope(user)` / `branchWhere(user, alias)` — для не-admin `user.branch_ids` (из `user_branches`) ограничивают выборку; у `admin` `ids = null` и фильтр не добавляется @@ -118,6 +161,8 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo ## Common Tasks +Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#agent-workflow-обязательные-правила-работы)): пусть он найдёт нужные места и вернёт `путь:строка`, а правки вносит основной агент. + ### Add a new API endpoint 1. Add route in `server.js` (group with related routes) 2. Use `requireAdmin` for admin, `apiLimiter`/`fileLimiter` for public @@ -243,7 +288,7 @@ guards against. | `redis.selftest.js` | Self-tests for `redis.js`, including behaviour with Redis unavailable | | `diff.js` / `diff.selftest.js` | Word-level text diff and audit change payload; self-tests | | `api.smoketest.js` | End-to-end API smoke test against a running stack | -| `worker.js` | Background AI auto-check worker for entry messages + photo enhance worker | +| `worker.js` | Background AI auto-check workers: entry messages, lesson-report template check, photo enhance | | `db/init.sql` | Initial schema (runs on fresh DB) | | `db/migration.sql` | Idempotent migrations for existing DBs | | `docker-compose.yml` | Service definitions (app, db, s3, tailscale) | @@ -278,6 +323,10 @@ guards against. - ❌ Expose DB port (5432) outside docker network - ❌ Use `eval`, `Function` constructor, or dynamic code execution - ❌ Add comments to code (this file excepted) +- ❌ Read/search the repo in the main agent when the work can be delegated to a subagent +- ❌ Load whole files (`server.js`, `worker.js`, `storage.js`, `redis.js`, `public/js/*.js`, `README.md`) or raw command output into the main context — delegate and filter (`grep -n`, `head`, line ranges) +- ❌ Give a subagent an open-ended "explore the whole project" task, or omit the project rules from its `systemPrompt` — it does not inherit the main context +- ❌ Treat a subagent report as the final word: coordinate, edit and verify the result yourself (`git diff`) --- diff --git a/README.md b/README.md index e7306d1..61d70b9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - **Воспитанники** — справочник с привязкой к группам - **Share-ссылки** — публичные страницы-витрины с выбором группы / воспитанника / диапазона дат - **Дашборд** — статистика, активные группы, активность за 14 дней, последние записи, топ воспитанников +- **Отчёты о занятиях** — тьютор описывает, что прошли на занятии; галочка в окне отчёта отправляет текст модели, которая сверяет его с шаблоном делового сообщения (настраивается в «Настройках» → «Шаблон отчёта»): совпал — остаётся как есть, не совпал — переписывается в деловом виде. Обработка идёт в фоне, оригинал тьютора сохраняется, доступна история версий с восстановлением - **Резервное копирование** — экспорт/импорт полного дампа (БД + файлы) в `tar.gz` - **Хранилище файлов** — локальный каталог `uploads/` или S3-совместимый сервис (`s3`: SeaweedFS, либо MinIO через оверрайд), перенос файлов скриптом миграции - **Настройки** — тексты футера, анти-спам интервал, системная информация (объёмы БД и хранилища) и «Статус стека»: версии Node.js/Express/PostgreSQL/Redis, состояние сервисов, ОС, CPU, память и аптаймы (`GET /api/system-info` → `stack`) diff --git a/api.smoketest.js b/api.smoketest.js index c9c4d99..0577b5a 100644 --- a/api.smoketest.js +++ b/api.smoketest.js @@ -210,6 +210,49 @@ async function main() { body: enhanceAi.data, }); + // Отчёты о занятии: контракт проверки по шаблону и истории версий. + // Модель здесь не дёргаем (долго и нужен сервис) — проверяем постановку в очередь + // и то, что при ai_check:false текст остаётся нетронутым. + const lrGroups = await api('/api/groups', { token }); + const gid = lrGroups.data[0] && lrGroups.data[0].id; + if (gid) { + const date = '2019-05-17'; + await api(`/api/lesson-reports?group_id=${gid}&date_from=${date}&date_to=${date}`, { token }) + .then(r => (r.data.items || []).forEach(i => api(`/api/lesson-reports/${i.id}`, { token, method: 'DELETE' }))); + + const plainText = 'Текст отчёта без проверки ИИ для смоук-теста.'; + const plain = await api('/api/lesson-reports', { + token, method: 'POST', + body: { group_id: gid, lesson_date: date, lesson_time: '10:00', text: plainText, ai_check: false }, + }); + ok('lesson-report: создание без ai_check -> ai_status=none, text_original=null', + plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null, + { status: plain.status, ai_status: plain.data.ai_status }); + + const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token }); + ok('lesson-report: история версий содержит исходный текст', + versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1 + && versions.data.items.some(v => v.text === plainText && v.source === 'manual'), + { status: versions.status, items: (versions.data.items || []).length }); + + const badRestore = await api(`/api/lesson-reports/${plain.data.id}/versions/99999999/restore`, { token, method: 'POST' }); + ok('lesson-report: восстановление несуществующей версии -> 404', badRestore.status === 404, badRestore.status); + + const noOrig = await api(`/api/lesson-reports/${plain.data.id}/ai/revert`, { token, method: 'POST' }); + ok('lesson-report: откат без оригинала -> 400', noOrig.status === 400, noOrig.status); + + const del = await api(`/api/lesson-reports/${plain.data.id}`, { token, method: 'DELETE' }); + ok('lesson-report: удаление', del.status === 200, del.status); + } else { + ok('lesson-report: есть группа для проверки', false, 'no groups'); + } + + const lessonNotifyMeta = await api('/api/notifications/meta', { token }); + // /api/notifications/meta отдаёт ключи настроек (notify_<тип>), а не сами типы + ok('notifications: настройка lesson.ai.formatted заведена', + (lessonNotifyMeta.data.types || []).some(t => t.key === 'notify_lesson_ai_formatted'), + (lessonNotifyMeta.data.types || []).map(t => t.key)); + const logout = await api('/api/auth/logout', { token, method: 'POST' }); ok('logout', logout.status === 200, logout.status); const afterLogout = await api('/api/auth/me', { token }); diff --git a/db/init.sql b/db/init.sql index 33f83dd..ce63b9b 100644 --- a/db/init.sql +++ b/db/init.sql @@ -227,6 +227,11 @@ CREATE TABLE IF NOT EXISTS lesson_reports ( lesson_date DATE NOT NULL, lesson_time TIME, text TEXT NOT NULL, + text_original TEXT, + text_ai TEXT, + ai_status VARCHAR(20) NOT NULL DEFAULT 'none', + ai_checked_at TIMESTAMPTZ, + ai_error TEXT, author_id INT REFERENCES users(id) ON DELETE SET NULL, branch_id INT REFERENCES branches(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT now(), @@ -236,6 +241,17 @@ CREATE TABLE IF NOT EXISTS lesson_reports ( CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date); CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC); +CREATE TABLE IF NOT EXISTS lesson_report_versions ( + id SERIAL PRIMARY KEY, + lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE, + text TEXT NOT NULL, + source VARCHAR(20) NOT NULL DEFAULT 'manual', + author_id INT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC); + CREATE TABLE IF NOT EXISTS photo_jobs ( id SERIAL PRIMARY KEY, entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, @@ -324,3 +340,28 @@ INSERT INTO settings (key, value) VALUES ('notify_backup_create', 'false') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('notify_system_test', 'true') ON CONFLICT (key) DO NOTHING; + +INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор деловых отчётов образовательного центра. + +Твоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже. + +ШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ: +Отчёт о проведённом занятии +Дата: <дата занятия> +Группа: <название группы> +Темы: <перечень тем> +Практика: <задания> +Домашнее задание: <что задано> + +ПРАВИЛА: +1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно. +2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты. +3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел. +4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу. +5. Не добавляй приветствия, подписи и какие-либо пояснения. +6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.') +ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') +ON CONFLICT (key) DO NOTHING; diff --git a/db/migration.sql b/db/migration.sql index 9551dc9..4be241b 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -320,3 +320,42 @@ CREATE TABLE IF NOT EXISTS lesson_reports ( CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date); CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC); + +ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_original TEXT; +ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT; +ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none'; +ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ; +ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT; + +CREATE TABLE IF NOT EXISTS lesson_report_versions ( + id SERIAL PRIMARY KEY, + lesson_report_id INT NOT NULL REFERENCES lesson_reports(id) ON DELETE CASCADE, + text TEXT NOT NULL, + source VARCHAR(20) NOT NULL DEFAULT 'manual', + author_id INT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_lesson_report_versions_report ON lesson_report_versions(lesson_report_id, id DESC); + +INSERT INTO settings (key, value) VALUES ('lesson_ai_enabled', 'true') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('lesson_ai_prompt', 'Ты — редактор деловых отчётов образовательного центра. + +Твоя задача — привести текст отчёта о занятии, написанный тьютором, к деловому стилю по шаблону ниже. + +ШАБЛОН ДЕЛОВОГО СООБЩЕНИЯ: +Отчёт о проведённом занятии +Дата: <дата занятия> +Группа: <название группы> +Темы: <перечень тем> +Практика: <задания> +Домашнее задание: <что задано> + +ПРАВИЛА: +1. Сначала сравни исходный текст с шаблоном. Если текст уже соответствует шаблону (та же структура, порядок и стиль) — верни его БЕЗ ИЗМЕНЕНИЙ, дословно. +2. Если текст не соответствует шаблону — перепиши его по шаблону, сохранив весь смысл и факты. +3. НЕ выдумывай тем, дат, заданий и оценок, которых нет в исходном тексте. Если данных нет — не добавляй раздел. +4. Обращение на «вы», без эмодзи и без восклицательных знаков, кратко и по делу. +5. Не добавляй приветствия, подписи и какие-либо пояснения. +6. Верни ТОЛЬКО итоговый текст отчёта — без кавычек, без markdown и без названия формата.') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('notify_lesson_ai_formatted', 'false') ON CONFLICT (key) DO NOTHING; diff --git a/public/admin.css b/public/admin.css index e36a464..27819ac 100644 --- a/public/admin.css +++ b/public/admin.css @@ -662,6 +662,20 @@ body[data-page=settings] .page-head{align-items:flex-end} .lesson-modal .lesson-row{display:grid;grid-template-columns:1fr 1fr;gap:12px} .lesson-modal .hint{font-size:.78rem;color:var(--muted);text-align:right} .lesson-modal textarea{min-height:130px} +.lesson-modal .lesson-ai-toggle{background:var(--bg);border:1px solid var(--border);border-radius:10px} +.lesson-ai-status{font-size:.78rem;color:var(--muted);line-height:1.45;margin-top:-4px} +.lesson-ai-status.err{color:#ef4444} +.lesson-versions{display:flex;flex-direction:column;gap:8px;max-height:56vh;overflow-y:auto;margin:4px 0} +.lesson-version{border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;background:var(--bg)} +.lesson-version.current{border-color:var(--accent);background:rgba(37,99,235,.06)} +.lesson-version-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.lesson-version-label{font-size:.78rem;font-weight:600} +.lesson-version-time{font-size:.74rem;color:var(--muted);margin-left:auto} +.lesson-version-text{font-size:.82rem;line-height:1.5;white-space:pre-wrap;word-break:break-word;margin-top:6px} +.lesson-version-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px} +.lesson-version-author{font-size:.74rem;color:var(--muted)} +.lesson-version-foot .btn-link{margin-left:auto;padding:4px 10px;font-size:.75rem} +.lesson-item .ai-badge{margin-left:0} @media(max-width:560px){ .lesson-modal .lesson-row{grid-template-columns:1fr} } diff --git a/public/admin.js b/public/admin.js index f858141..2b90d7d 100644 --- a/public/admin.js +++ b/public/admin.js @@ -178,6 +178,7 @@ const NOTIFY_ICONS = { 'backup.create': 'download', 'system.test': 'send', 'lesson.report': 'notebook-pen', + 'lesson.ai.formatted': 'file-check', }; const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' }; @@ -540,7 +541,14 @@ function ensureLessonModal() { placeholder="Например: разобрали циклы for и while, решали задачи на списки, домашнее задание — функции"> 0 / 5000 + +
${esc(fullTarget(target))}Инструкция для нейросети: она сверяет текст отчёта тьютора с шаблоном делового сообщения. Если текст уже соответствует шаблону — остаётся без изменений, иначе приводится к деловому виду. Оригинал тьютора всегда сохраняется, результат можно посмотреть в истории версий.
+ +