diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index 7900e5b..b4b3361 100644 Binary files a/data/scud_orion_ai.db and b/data/scud_orion_ai.db differ diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm deleted file mode 100644 index ed9a005..0000000 Binary files a/data/scud_orion_ai.db-shm and /dev/null differ diff --git a/data/scud_orion_ai.db-wal b/data/scud_orion_ai.db-wal deleted file mode 100644 index 9eb5ef3..0000000 Binary files a/data/scud_orion_ai.db-wal and /dev/null differ diff --git a/data/static_reason_workers.csv b/data/static_reason_workers.csv index 49f9ef7..e5deef5 100644 --- a/data/static_reason_workers.csv +++ b/data/static_reason_workers.csv @@ -10,5 +10,4 @@ fio,reason,note Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка -Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка -Ароян Станислав Месропович,Удаленная работа,Временная удаленка \ No newline at end of file +Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e1ef71a..c1ccf25 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,34 @@ --- +## [2026-08-19] — Инлайн-редактор задач, интеллектуальный ContextManager и стабилизация Function Calling + +### ✨ Добавлено +* **Инлайн-редактирование задач (`task_widget.js` / `tasks.py`)**: + - Добавлена кнопка редактирования (`✏️`) на каждой карточке задачи. + - Карточка трансформируется в инлайн-форму с тремя полями: текст задачи, HTML5-пикер календаря планируемого срока и выпадающий список важности (`LOW`, `MEDIUM`, `HIGH`, `CRITICAL`). + - Добавлен REST API эндпоинт `PATCH /api/v1/tasks/{task_id}` с поддержкой комплексного обновления полей в SQLite. + - В карточки выведено отображение даты создания задачи. +* **Интеллектуальный ContextManager (`context_manager.py`)**: + - Служебные запросы и карточки инструментов сохраняются синхронными парами (`is_ephemeral = 1`) и атомарно удаляются при завершении операций. + - Свободные диалоги оператора (включая темы Topic Drift) сохраняются в постоянную историю (`is_ephemeral = 0`) и не стираются при закрытии сессий промпта/задач. +* **Поддержка Y-снапшотов (`db_snapshots.py`)**: + - Исправлен SQL-запрос `db_get_snapshots` для точного извлечения ночных и фоновых срезов с префиксом `Y` (например, `Y20260817-003`). + +### 🔧 Изменено +* **`modules/web_api/llm/agent.py`**: + - Полностью удалены эвристические текстовые фильтры и принудительные сортировки инструментов. + - Настроен нативный возврат и исполнение инструментов без зацикливания модели. + - Устранена ошибка `UnboundLocalError` при вызове справочных инструментов. +* **`modules/web_api/llm/schemas.py`**: + - Очищены описания инструментов от агрессивных директив и капса, стабилизирована селективность Function Calling для Qwen 2.5. +* **`modules/web_api/llm/core/ollama_client.py`**: + - Оптимизированы параметры генерации для Function Calling (`repeat_penalty: 1.0`, `presence_penalty: 0.0`, `temperature: 0.0`), что исключило спам несколькими вызовами инструментов на один запрос. +* **`modules/web_api/static/js/chat/task_widget.js`**: + - Фильтр по умолчанию переведен на вкладку **«В работе»** (`IN_PROGRESS`). + +--- + ## [2026-08-18] — Двухфазный жизненный цикл сессий, двусторонний Diff и Topic Drift Guard ### ✨ Добавлено diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index 780cc22..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,61 +0,0 @@ -# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI - -## 📌 Зафиксированные вехи и концептуальные архитектурные решения - -Схема веток Gitea для развития проекта: - -main (100% стабильный релиз) - │ - ├──► feature/prompt-relational-nodes ──┐ (Перевод системного промпта на реляционные узлы в SQLite) - │ ▼ - ├──────────────────────────────────► merge to main - │ - ├──► feature/native-function-calling ──┐ (Отказ от регулярных выражений tool_injector.py в пользу нативного FC) - │ ▼ - ├──────────────────────────────────► merge to main - │ - └──► feature/ephemeral-context ────────┐ (Интеллектуальный жизненный цикл эфемерных сообщений) - ▼ - merge to main - ---- - -## 🎯 Текущий активный спринт: Нативный Function Calling и реляционный редактор промпта - -### 1. Архитектура чистого диалогового агента без регулярных выражений -* **Концепция:** Полный отказ от жестких эвристик и регулярных выражений в `tool_injector.py`. Все решения о намерениях оператора, вызове инструментов и переключении тем принимает языковая модель через семантический контекст. -* **Ключевые этапы реализации:** - * **Этап 1 (Санитарная очистка):** Превращение `tool_injector.py` в простой фильтр мусорных тегов без инжекции и перехватов команд. - * **Этап 2 (Семантическое управление сессией):** Инструктаж модели в `role: system` о поведении в состоянии `PROMPT_PREVIEW` (оценка подтверждения, отмены, правок или смены темы диалога). - * **Этап 3 (Жизненный цикл контекста):** Модель видит временные сообщения (`is_ephemeral = 1`) только во время активной ветки; при фиксации решения (`fast_path.py`) контекст очищается через `db_purge_ephemeral_messages`. - * **Этап 4 (Гибридный UI):** Поддержка как диалоговых правок, так и прямого инлайн-редактирования через форму `[✏️ Редактировать]` в окне чата. - ---- - -## 🚀 Масштабирование эфемерного контекста и сервисные инструменты - -### 2. Модуль инспекции контекста и базы данных (`db_cli.py`) -* **Статус:** `Реализовано`. -* Добавлена команда `context [session_id] [--limit N]` для просмотра сырой таблицы сообщений `chat_messages` с пометками об эфемерности. - -### 3. Модуль задач и бэклога (`tasks`) -* **Целевые инструменты:** `db_get_tasks`, `db_add_task`, `db_update_task_status`, `db_delete_task`. -* Вывод интерактивных карточек задач с фильтрами без засорения постоянной истории диалога. - -### 4. Модуль срезов и архива СКУД (`snapshots`) -* **Целевые инструменты:** `db_get_snapshots`, `db_delete_snapshots`. -* Интерактивное подтверждение удаления срезов и автоматическая очистка таблиц срезов из контекста. - -### 5. Модуль базы знаний, правил и аномалий (`kb & anomalies`) -* **Целевые инструменты:** `db_get_rules`, `db_get_anomalies`, `db_get_reference`. -* Изоляция нормативных справочников от постоянной истории диалога для предотвращения залипания LLM. - ---- - -## 🔮 Долгосрочный бэклог (Future Modules) - -### 6. Интерактивный модуль планирования (Checklist & Task Planner Engine) -* Динамические проектные чек-листы с синхронизацией в таблицу `tasks`. - -### 7. Изолированная песочница кода (Code Execution Sandbox Engine) -* Запуск динамических Python/Pandas вычислений в изолированном Docker/gVisor контуре. \ No newline at end of file diff --git a/docs/in_progress/ROADMAP.md b/docs/in_progress/ROADMAP.md new file mode 100644 index 0000000..dec7be6 --- /dev/null +++ b/docs/in_progress/ROADMAP.md @@ -0,0 +1,136 @@ +# План реализации (ROADMAP) + +## 1. Очистка от регулярок и костылей (`tool_injector.py`) +- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4]. +- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 4]. + +--- + +## 2. Настройка контекста и инструкций сессии (`agent.py`) +- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4]. +- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: + - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4]. + - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4]. +- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4]. +- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 4]. +- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 4]. + +--- + +## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) +- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4]. +- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4]. +- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4]. +- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 4]. +- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 4]. + +--- + +## 4. Инлайн-редактор в окне диалога (`core.js`) +- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4]. +- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4]. +- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 4]. +- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 4]. + +--- + +## 5. Тестирование и валидация системного промпта +- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4]. +- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4]. +- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4]. + +--- + +## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) +- [x] **Масштабирование UI задач:** + - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 4]. + - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 4]. + - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 4]. +- [x] **Инлайн-редактирование карточки задачи:** + - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 4]. + - Отображение даты создания задачи[cite: 4]. + - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 4]. +- [x] **Детерминированный Fast-Path и двухфазное удаление:** + - Мгновенная смена статусов без задержек LLM[cite: 4]. + - Карточка подтверждения удаления с автоочисткой контекста[cite: 4]. +- [x] **Генерация отчетов задач в Markdown:** + - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3]. + - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3]. +- [x] **Доменная консолидация задач:** + - Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 3]. + +--- + +## 7. Распространение на модуль снапшотов (`snapshots`) +- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 4]. +- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 4]. +- [x] **Интерактивный UI с чекбоксами и защитой срезов:** + - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3]. + - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3]. + - Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 3]. +- [x] **Детерминированный Fast-Path удаления срезов:** + - Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 3]. + - Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления[cite: 3]. + +--- + +## 8. Архитектурный рефакторинг и декомпозиция монолитов (Текущий этап) +> **Контроль версий:** перед каждым этапом и после его успешной проверки выполняется коммит и фиксация в репозитории **Gitea**. + +- [ ] **Шаг 0: Фиксация базового состояния в Gitea:** + - Создание тега/коммита `v2.5-pre-refactor` в Gitea перед структурными изменениями. +- [ ] **Шаг 1: Разделение слоя данных и ядра (`core/`):** + - Создание `core/connection.py` (единый SQLite Connection Manager с WAL и таймаутами). + - Создание `core/schema.py` (DDL-инициализация всех таблиц и индексов из `database.py`). + - Фиксация контрольной точки в Gitea. +- [ ] **Шаг 2: Выделение доменных сервисов (`services/` / `domain/`):** + - **Задачи (`services/tasks/`):** декомпозиция на `repository.py`, `service.py`, `exporter.py` (правило: до 100–150 строк на файл). + - **Системный промпт (`services/prompts/`):** декомпозиция на `repository.py`, `service.py`, `diff_engine.py`. + - **Снапшоты (`services/snapshots/`):** декомпозиция на `repository.py`, `service.py`. + - **База знаний и правила (`services/knowledge/`):** вынос правил арбитража и словаря синонимов подразделений. + - Фиксация контрольной точки в Gitea. +- [ ] **Шаг 3: Декомпозиция ETL-конвейера (`main_etl.py` и `scud_export.py`):** + - Выделение `services/scud_etl/pipeline.py` (оркестрация без длинных простыней кода). + - Выделение `services/scud_etl/merger.py` (чистая склейка Dataframe 1С ⟷ СКУД). + - Выделение `services/scud_etl/anomaly_detector.py` (алгоритмическая детекция нарушений). + - Выделение `services/scud_etl/sql_queries.py` (вынос длинных T-SQL запросов). + - Фиксация контрольной точки в Gitea. +- [ ] **Шаг 4: Выделение чистого ИИ-оркестратора (`modules/ai_engine/`):** + - Создание компактного `agent.py` (~60–80 строк) без ветвлений `if/elif` на сотни строк. + - Внедрение изолированных обработчиков инструментов: + - `handlers/task_handler.py` + - `handlers/prompt_handler.py` + - `handlers/snapshot_handler.py` + - `handlers/anomaly_handler.py` + - Создание `context_builder.py` (календарь, роли, сборка системного контекста). + - Фиксация контрольной точки в Gitea. +- [ ] **Шаг 5: Облегчение `web_api` и адаптация роутеров:** + - Превращение `web_api` исключительно в тонкий транспортный шлюз (FastAPI + Статика). + - Очистка роутеров от прямого SQL — вызовы только через доменные сервисы. + - Рефакторинг CLI-утилиты `scripts/db_cli.py` (вынос форматирования в `scripts/cli/formatters.py`). + - Финальное сквозное тестирование и фиксация релиза в Gitea (`v3.0-clean-architecture`). + +--- + +## 9. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) +- [ ] **Инструмент построения сводок (`db_generate_scud_report`):** + - Создание инструмента Function Calling для запуска конвейера контроллинга за выбранную дату/снапшот прямо из чата[cite: 4]. + - Поддержка параметров: `target_date`, `snapshot_id`, `mode` (`summary` — дневная сводка, `detailed` — вчерашний отчет)[cite: 4]. +- [ ] **Интерактивная карточка аудита отчета в UI:** + - Вывод статуса генерации, ключевых метрик (всего сотрудников, явка, опоздания, количество аномалий)[cite: 4]. + - Ссылки на сгенерированные файлы Excel (`.xlsx`) и Markdown (`.md`)[cite: 4]. +- [ ] **Диалоговый разбор аномалий и исключений:** + - Возможность в диалоге запросить расшифровку конкретной аномалии[cite: 4]. + - Быстрое добавление сотрудника/отдела в `exceptions.json` или базу знаний прямо из чата по кнопке карточки[cite: 4]. +- [ ] **Экспорт и отправка среза:** + - Команда формирования итоговой выгрузки для руководства в формате PDF/Markdown с передачей файла пользователю[cite: 4]. + +--- + +## 10. Изолированная песочница кода (Code Execution Sandbox Engine) +- [ ] **Docker/gVisor контур:** + - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 4]. + - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 4]. +- [ ] **Динамические Python/Pandas вычисления:** + - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 4]. + - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 4]. \ No newline at end of file diff --git a/docs/in_progress/план_оптимизации_работы_с_контекстом.md b/docs/in_progress/план_оптимизации_работы_с_контекстом.md deleted file mode 100644 index 4587843..0000000 --- a/docs/in_progress/план_оптимизации_работы_с_контекстом.md +++ /dev/null @@ -1,72 +0,0 @@ -# План реализации - -## 1. Очистка от регулярок и костылей (`tool_injector.py`) -- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4]. -- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 4]. - ---- - -## 2. Настройка контекста и инструкций сессии (`agent.py`) -- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4]. -- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: - - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4]. - - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4]. -- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4]. -- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`. -- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов. - ---- - -## 3. Очистка эфемерных сообщений при завершении (`fast_path.py`) -- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4]. -- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4]. -- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4]. -- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста. - ---- - -## 4. Инлайн-редактор в окне диалога (`core.js`) -- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4]. -- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4]. -- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`). -- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика. - ---- - -## 5. Тестирование и валидация системного промпта -- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4]. -- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4]. -- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4]. - ---- - -## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) -- [ ] **Масштабирование UI задач:** - - Увеличить размер окна/виджета просмотра и управления задачами (`TASK_INTERACTIVE_CARD`) по аналогии с окном редактирования системного промпта для удобного управления большим списком. -- [ ] **Интерактивное управление и превью:** - - Карточка предпросмотра параметров создаваемой задачи (`db_add_task`) с кнопками `[Подтвердить]`, `[Отменить]`, `[✏️ Редактировать]`. - - Подтверждение опасных операций (удаление задачи `db_delete_task`) с кнопками валидации. - - Follow-up фаза с выводом актуализированного списка задач из `tool_action_registry`. -- [ ] **Доменная консолидация:** - - Объединить `db_add_task`, `db_update_task_status`, `db_delete_task` в единый диспетчер `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE"], ...)`[cite: 4]. - - Обновить `TOOLS_SCHEMA` в `schemas.py`[cite: 4]. - ---- - -## 7. Распространение на модуль снапшотов (`snapshots`) и правил (`rules`) -- [ ] **Консолидация модуля `snapshots`:** - - Интерактивное подтверждение массового удаления снапшотов за дату (`db_delete_snapshots`). - - Объединить операции удаления и управления срезами в единый `db_snapshots_edit(action: ["DELETE"], ...)`[cite: 4]. - - Авто-зачистка тяжелых таблиц снапшотов из контекста при переходе к другим темам. -- [ ] **Модуль правил арбитража (`rules`):** - - Редактирование правил базы знаний через единый паттерн узлов с подсветкой diff. - ---- - -## 8. Изолированная песочница кода (Code Execution Sandbox Engine) -- [ ] **Docker/gVisor контур:** - - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups). - - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only. -- [ ] **Динамические Python/Pandas вычисления:** - - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету. - - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата. \ No newline at end of file diff --git a/modules/ROADMAP.md b/modules/ROADMAP.md new file mode 100644 index 0000000..65cbe6d --- /dev/null +++ b/modules/ROADMAP.md @@ -0,0 +1,56 @@ +# 🗺️ Дорожная карта задач проекта (ROADMAP) + +> **Сформировано:** 2026-08-19 18:40 | **Всего задач:** 16 + +--- + +## Модуль `api` + +- [x] **#35** [HIGH] `[ЗАВЕРШЕНО]` Создание REST API на FastAPI (/api/v1/snapshots, /api/v1/scud/logs, /api/v1/anomalies) + +--- + +## Модуль `core/database` + +- [x] **#22** [HIGH] `[ЗАВЕРШЕНО]` Перевод подключения 1С:ЗУП на продакшн MS SQL сервер ACCOUNT-01 + гибридный фоллбэк +- [x] **#37** [MEDIUM] `[ЗАВЕРШЕНО]` Создание модуля персистентной памяти проекта в SQLite и CLI-инструмента для фиксации архитектурных решений + +--- + +## Модуль `general` + +- [x] **#38** [HIGH] `[ЗАВЕРШЕНО]` По запросу, вывести возможности системы, которые уже настроены. Например на запрос о возможностях по СКУД. И изменить стартовую плашку. +- [ ] **#41** [MEDIUM] Полностью отказаться от tool_injector.py и перевести все вызовы на нативный Function Calling языковой модели. LLM будет основным арбитром *(срок: 2026-08-21)* +- [ ] **#44** [LOW] Тестовая-3 *(срок: 2026-08-20)* + +--- + +## Модуль `scripts` + +- [x] **#26** [MEDIUM] `[ЗАВЕРШЕНО]` Создание и выполнение скрипта сквозной переиндексации снапшотов с префиксом Y +- [x] **#27** [MEDIUM] `[ЗАВЕРШЕНО]` Реализация CLI-инспектора БД и выравнивание номеров срезов для идеальной верстки в терминале +- [ ] **#33** [MEDIUM] Автоматизация сбора срезов по расписанию (Cron / Systemd Timers на 12:00, 17:00, 19:00) + +--- + +## Модуль `services/ai_verifier` + +- [x] **#28** [HIGH] `[ЗАВЕРШЕНО]` Двойная проверка исключений по department_scud и 1С, удержание 100% приоритета кадровых документов + +--- + +## Модуль `services/scud_export` + +- [x] **#23** [HIGH] `[ЗАВЕРШЕНО]` Инцидент Равина В.Э.: Фиксация 100% приоритета сырых логов pLogData СКУД над расчетом УРВ Ориона +- [x] **#25** [HIGH] `[ЗАВЕРШЕНО]` Исправление бага дублирования номеров снапшотов (переход на вычисление MAX() по суффиксу) +- [x] **#29** [LOW] `[ЗАВЕРШЕНО]` Лаконичный вывод неразмеченных сотрудников с короткими аббревиатурами отделов СКУД (РУК, ОИЗ, ПУ и др.) +- [ ] **#30** [HIGH] `[В РАБОТЕ]` Внутридневной контроль: Извлечение полных цепочек проходов pLogData (входы/выходы за смену) +- [ ] **#31** [HIGH] `[В РАБОТЕ]` Внутридневной контроль: Расчет количества и суммарной длительности отлучек/перекуров сверх обеда + +--- + +## Модуль `services/text_reporter` + +- [ ] **#32** [MEDIUM] `[В РАБОТЕ]` Внутридневной контроль: Вывод списка системных нарушителей внутреннего распорядка в Markdown-отчет + +--- diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py index a341b8b..575b4c1 100644 --- a/modules/web_api/llm/agent.py +++ b/modules/web_api/llm/agent.py @@ -3,18 +3,22 @@ FILE: modules/web_api/llm/agent.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm (Core Agent Coordinator) -ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта, - управление сессионными стейтами, Topic Drift Guard и очистка контекста. +ROLE: Нативный оркестратор диалога, диспетчер инструментов (Function Calling) + и управление сессионными стейтами через ContextManager. + +AI-CONTEXT-ANCHORS: + - ANCHOR[AGENT_IMPORTS]: Системные и доменные импорты. + - ANCHOR[AGENT_MAIN_PIPELINE]: Основная точка входа process_chat_message. + - ANCHOR[AGENT_SYSTEM_PROMPT]: Формирование динамического контекста. + - ANCHOR[AGENT_TOOL_DISPATCHER]: Исполнение нативных вызовов инструментов. + - ANCHOR[AGENT_TOPIC_DRIFT]: Защита контекста и обработка свободных тем. =============================================================================== """ -# ============================================================================= -# БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ -# ============================================================================= +# ANCHOR[AGENT_IMPORTS] import sys import json import logging -import re from typing import List, Dict, Any, Tuple, Optional from .db.connection import get_db_connection @@ -26,6 +30,8 @@ from .db_tools import ( db_update_task_status, db_delete_task, db_add_task, + db_tasks_edit, + db_export_tasks_markdown, db_get_rules, db_set_session_state, db_get_session_state, @@ -46,6 +52,12 @@ from .core.calendar_utils import get_dynamic_calendar_context from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed from .core.ollama_client import call_ollama_chat from .core.fast_path import handle_fast_path_intercept +from .core.context_manager import ( + save_tool_interaction, + save_dialog_interaction, + mark_last_user_message_ephemeral, + close_tool_session_and_cleanup +) logger = logging.getLogger("SCUD_AGENT") logger.setLevel(logging.INFO) @@ -58,9 +70,7 @@ if not logger.handlers: logger.addHandler(handler) -# ============================================================================= -# БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА -# ============================================================================= +# ANCHOR[AGENT_MAIN_PIPELINE] def process_chat_message( user_id: int, user_message: str, @@ -70,40 +80,29 @@ def process_chat_message( session_id: str = "web_session_main" ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: """ - Главная функция обработки сообщения: - 1. Очищает осиротевшие эфемерные сообщения. - 2. Перехватывает быстрые кнопки (Fast-Path). - 3. Собирает контекст и передает управление Ollama. - 4. Выполняет Tool Calls и управляет счетчиком Topic Drift. + Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling. """ logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") - # 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения session_state = db_get_session_state(session_id) if not session_state: db_purge_ephemeral_messages(session_id) full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message - # Перехват нажатия кнопок («Подтвердить», «Отменить», «Завершить») без вызова LLM + # 1. Быстрый перехват строго системных кнопок UI (подтверждение превью промпта) fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state) if fast_path_res: return fast_path_res - # Сохранение входящего сообщения оператора - is_user_ephemeral = 1 if session_state else 0 - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral) + # 2. Фиксация сообщения пользователя + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) db_history = db_get_chat_history(session_id, limit=20) - - # ========================================================================= - # БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА - # ========================================================================= - dynamic_prompt_text = db_get_active_system_prompt() + # ANCHOR[AGENT_SYSTEM_PROMPT] calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" - # Извлечение текущего состояния и счетчика отвлечений (idle_turns) current_state_type = session_state.get("state_type") if session_state else None state_data = session_state.get("data_json") or {} if session_state else {} if not isinstance(state_data, dict): @@ -113,47 +112,61 @@ def process_chat_message( active_state_context = "" if current_state_type == "PROMPT_PREVIEW": active_state_context = ( - "\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n" - "- Сейчас открыт предпросмотр изменения системного промпта.\n" - "- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n" - "- Если пользователь переключился на другую тему — ответь на его вопрос кратко и по делу.\n" + "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n" + "- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n" ) elif current_state_type == "PROMPT_FOLLOWUP": active_state_context = ( - "\n[ВНИМАНИЕ: АКТИВНА СЕССИЯ РЕДАКТИРОВАНИЯ СИСТЕМНОГО ПРОМПТА]\n" - "- Оператор только что применил предыдущее изменение или запросил просмотр промпта.\n" - "- Любые команды вида 'добавь X.Y', 'удали X.Y', 'измени X.Y' ОЗНАЧАЮТ ПРОДОЛЖЕНИЕ РАБОТЫ С СИСТЕМНЫМ ПРОМПТОМ -> ВЫЗЫВАЙ db_prompt_node_edit.\n" - "- Если запрос оператора не ясен или относится к другой теме — ответь на него естественно.\n" + "\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n" + "- Оператор просматривает или редактирует системный промпт.\n" + "- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n" + "- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n" + "- Запрещено путать ADD и DELETE.\n" + ) + elif current_state_type == "SNAPSHOTS_VIEW": + active_date = state_data.get("query_date", "выбранную дату") + active_state_context = ( + f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n" + f"- Сейчас на экране отображаются снапшоты за {active_date}.\n" + f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n" + f"- Если оператор запрашивает ДРУГУЮ дату или день недели, отличную от {active_date} (например: 'за вчера', 'а за 13.08', 'покажи за сегодня') — " + f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n" + f"- Запрещено генерировать текст за другую дату по памяти.\n" + ) + elif current_state_type == "SNAPSHOT_DELETE_CONFIRM": + active_state_context = ( + "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n" + ) + elif current_state_type == "TASK_DELETE_CONFIRM": + active_state_context = ( + "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n" ) system_prompt_content = ( - f"Ты — интеллектуальный ассистент SCUD Orion AI. " - f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" - f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n" - f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n" - f"[ОКРУЖЕНИЕ]\n" + f"Ты — интеллектуальный ассистент SCUD Orion AI.\n" + f"Ты управляешь задачами, аналитикой СКУД и системными настройками исключительно через инструменты (tools).\n\n" + f"СТРОГИЕ ПРАВИЛА:\n" + f"1. К оператору всегда обращайся на Вы.\n" + f"2. Для получения данных всегда вызывай соответствующий инструмент:\n" + f" - Снапшоты и срезы логов СКУД за любые даты и дни недели -> db_get_snapshots\n" + f" - Удаление снапшотов -> db_delete_snapshots\n" + f" - Задачи и бэклог (просмотр, создание, смена статуса, удаление, экспорт) -> db_get_tasks, db_tasks_edit\n" + f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n" + f" - Аномалии СКУД/1С -> db_get_anomalies\n" + f" - База знаний -> db_get_rules\n" + f" - Статистика БД -> db_get_stats\n" + f" - Справка -> db_get_reference\n" + f"3. Запрещено сочинять данные от себя без вызова инструментов.\n\n" + f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" f"- Пользователь: {user_info}\n" f"- {calendar_context}\n" - f"{active_state_context}\n" - f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" - f"1. Для ЛЮБЫХ операций с системным промптом ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_prompt_node_edit:\n" - f" - 'удали 2.9' / 'удалить пункт 2.9' -> ВЫЗОВ db_prompt_node_edit(action='DELETE', section_id=2, item_id=9, content='')\n" - f" - 'добавь 3.4 Текст' / 'добавить пункт 3.4' -> ВЫЗОВ db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n" - f" - 'измени 1.2 Текст' -> ВЫЗОВ db_prompt_node_edit(action='UPDATE', section_id=1, item_id=2, content='Текст')\n" - f" КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО отвечать текстом вроде 'Удален пункт... Напишите подтверждаю'. Только Function Call!\n" - f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" - f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n" - f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" + f"{active_state_context}" ) user_msg_object = {"role": "user", "content": full_user_content} - - # ========================================================================= - # БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING - # ========================================================================= + # ANCHOR[AGENT_TOOL_DISPATCHER] try: - # Режим Vision (OCR) if image_b64: user_msg_object["images"] = [image_b64] messages = [ @@ -162,7 +175,6 @@ def process_chat_message( ] msg = call_ollama_chat(messages, is_vision=True) else: - # Текстовый диалог clean_db_history = [dict(m) for m in db_history] for m in clean_db_history: m.pop("images", None) @@ -171,241 +183,329 @@ def process_chat_message( raw_text_reply = msg.get("content", "") tool_calls = msg.get("tool_calls", []) - tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls) - # Исполнение вызванных моделью инструментов + if not tool_calls: + tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls) + if tool_calls: logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") - messages.append(msg) - tool_names_called = [] + + tool = tool_calls[0] + fn_name = tool["function"]["name"] + fn_args = tool["function"].get("arguments", {}) + if isinstance(fn_args, str): + try: + fn_args = json.loads(fn_args) + except Exception: + fn_args = {} - # ----------------------------------------------------------------- - # TOPIC DRIFT GUARD ДЛЯ ИНСТРУМЕНТОВ: - # Если вызваны сторонние инструменты (снапшоты, задачи, статы) - # — мгновенно закрываем сессию и вычищаем эфемерный контекст - # ----------------------------------------------------------------- - is_prompt_tool = any(t["function"]["name"] in ["db_prompt_node_edit", "db_get_system_prompt", "db_get_system_prompts"] for t in tool_calls) - if not is_prompt_tool and session_state: - logger.info(f"Смена темы на инструмент {tool_calls[0]['function']['name']}. Закрываем сессию и очищаем эфемерный контекст.") - db_clear_session_state(session_id) - db_purge_ephemeral_messages(session_id) - session_state = None + logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") - for tool in tool_calls: - fn_name = tool["function"]["name"] - fn_args = tool["function"].get("arguments", {}) - tool_names_called.append(fn_name) - logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") - tool_result_content = "" + # Ротация контекста: закрываем старую сессию инструмента + close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}") + session_state = None + mark_last_user_message_ephemeral(session_id) - # --- 4.1. Модуль задач --- - if fn_name == "db_get_tasks": + # 1. Задачи (Просмотр) + if fn_name == "db_get_tasks": + raw_tasks = db_get_tasks(user_id, status=fn_args.get("status")) + reply_text = "Вот интерактивный список ваших текущих задач:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } + + # 2. Единый диспетчер задач + elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]: + action = fn_args.get("action", "UPDATE").upper() + if fn_name == "db_add_task": action = "ADD" + elif fn_name == "db_delete_task": action = "DELETE" + elif fn_name == "db_update_task_status": action = "UPDATE" + + if action == "DELETE": + task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip() + db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0}) + reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_DELETE_CONFIRM", + "buttons": [ + {"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"}, + {"label": "Отмена", "value": "отмена", "style": "secondary"} + ] + } + + elif action == "EXPORT": + export_res = db_export_tasks_markdown( + user_id=user_id, + filename=fn_args.get("filename"), + status_filter=fn_args.get("status") + ) + reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.") + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + + action_payload = None + if export_res.get("status") == "success": + action_payload = { + "type": "FILE_DOWNLOAD_CARD", + "filename": export_res.get("filename"), + "download_url": export_res.get("download_url"), + "tasks_count": export_res.get("tasks_count") + } + return reply_text, db_get_chat_history(session_id), action_payload + + else: + res = db_tasks_edit( + user_id=user_id, + action=action, + task_id=fn_args.get("task_id"), + title=fn_args.get("title"), + priority=fn_args.get("priority", "MEDIUM"), + status=fn_args.get("status"), + module=fn_args.get("module", "general"), + due_date=fn_args.get("due_date") + ) raw_tasks = db_get_tasks(user_id) - reply_text = "Вот интерактивный список ваших текущих задач:" + reply_text = res.get("message", "Операция над задачами выполнена.") db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) return reply_text, db_get_chat_history(session_id), { "type": "TASK_INTERACTIVE_CARD", "tasks": raw_tasks } - # --- 4.2. Прямой просмотр системного промпта из SQLite --- - elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: - active_prompt = db_get_active_system_prompt() - reply_text = f"Актуальный системный промпт:\n\n{active_prompt}" - - # Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) + # 3. Системный промпт + elif fn_name == "db_get_system_prompt": + active_prompt = db_get_active_system_prompt() + reply_text = f"Актуальный системный промпт:\n\n{active_prompt}" + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), None + + elif fn_name == "db_prompt_node_edit": + action = str(fn_args.get("action", "ADD")).upper() + + try: + sec_id = int(str(fn_args.get("section_id", 3)).strip()) + except Exception: + sec_id = 3 + + try: + itm_id = int(str(fn_args.get("item_id", 1)).strip()) + except Exception: + itm_id = 1 + + content = str(fn_args.get("content", "")).strip() + baseline_prompt = db_get_active_system_prompt() + + with get_db_connection() as temp_conn: + temp_cursor = temp_conn.cursor() + temp_cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = 'main_agent' AND is_active = 1 + ORDER BY section_id, item_id + """) + existing_nodes = temp_cursor.fetchall() + + nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes} + nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} if action == "DELETE" else dict(nodes_dict) + if action != "DELETE": + nodes_dict_for_draft[(sec_id, itm_id)] = content + + draft_lines = [] + curr_sec = None + for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()): + if i_id == 0: + if curr_sec is not None: draft_lines.append("") + draft_lines.append(f"{s_id}. {txt}") + curr_sec = s_id + else: + draft_lines.append(f" {s_id}.{i_id}. {txt}") + merged_prompt = "\n".join(draft_lines) + + diff_lines = [] + curr_sec = None + display_nodes = dict(nodes_dict) + if action != "DELETE": + display_nodes[(sec_id, itm_id)] = content + + for (s_id, i_id), txt in sorted(display_nodes.items()): + if i_id == 0: + if curr_sec is not None: diff_lines.append("") + diff_lines.append(f"{s_id}. {txt}") + curr_sec = s_id + else: + if s_id == sec_id and i_id == itm_id: + line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' if action == "DELETE" else f' {s_id}.{i_id}. {txt}' + else: + line_str = f" {s_id}.{i_id}. {txt}" + diff_lines.append(line_str) + + diff_html = "\n".join(diff_lines) + + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": merged_prompt, + "action": action, + "section_id": sec_id, + "item_id": itm_id, + "content": content, + "idle_turns": 0 + }) + + preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените." + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return preview_reply, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "raw_draft": merged_prompt, + "baseline_prompt": baseline_prompt, + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + ] + } + + # 4. Снапшоты СКУД + elif fn_name == "db_get_snapshots": + snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) + query_date = snapshots_res.get("query_date", "выбранную дату") + reply_text = f"Реестр срезов СКУД за {query_date}:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": snapshots_res + } + + elif fn_name == "db_delete_snapshots": + raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str") + raw_ids = fn_args.get("snapshot_ids") or [] + is_confirmed = fn_args.get("confirmed", False) + + if raw_id and not raw_ids: + if isinstance(raw_id, str) and "," in raw_id: + raw_ids = [s.strip() for s in raw_id.split(",")] + else: + raw_ids = [raw_id] + + safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")] + if not safe_ids: + reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты." db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) return reply_text, db_get_chat_history(session_id), None - # --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) --- - elif fn_name == "db_prompt_node_edit": - action = fn_args.get("action", "ADD").upper() - sec_id = int(fn_args.get("section_id", 2)) - itm_id = int(fn_args.get("item_id", 1)) - content = fn_args.get("content", "").strip() - baseline_prompt = db_get_active_system_prompt() - - with get_db_connection() as temp_conn: - temp_cursor = temp_conn.cursor() - temp_cursor.execute(""" - SELECT section_id, item_id, content - FROM system_prompt_nodes - WHERE prompt_name = 'main_agent' AND is_active = 1 - ORDER BY section_id, item_id - """) - existing_nodes = temp_cursor.fetchall() - - nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes} - - if action == "DELETE": - nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} - else: - nodes_dict_for_draft = dict(nodes_dict) - nodes_dict_for_draft[(sec_id, itm_id)] = content - - # Формирование чистого текста для редактора - draft_lines = [] - curr_sec = None - for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()): - if i_id == 0: - if curr_sec is not None: - draft_lines.append("") - draft_lines.append(f"{s_id}. {txt}") - curr_sec = s_id - else: - draft_lines.append(f" {s_id}.{i_id}. {txt}") - merged_prompt = "\n".join(draft_lines) - - # Формирование HTML Diff с подсветкой - diff_lines = [] - curr_sec = None - display_nodes = dict(nodes_dict) - if action != "DELETE": - display_nodes[(sec_id, itm_id)] = content - - for (s_id, i_id), txt in sorted(display_nodes.items()): - if i_id == 0: - if curr_sec is not None: - diff_lines.append("") - diff_lines.append(f"{s_id}. {txt}") - curr_sec = s_id - else: - if s_id == sec_id and i_id == itm_id: - if action == "DELETE": - line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' - else: - line_str = f' {s_id}.{i_id}. {txt}' - else: - line_str = f" {s_id}.{i_id}. {txt}" - diff_lines.append(line_str) - - diff_html = "\n".join(diff_lines) - - # Фиксация предпросмотра в session_states - db_set_session_state(session_id, "PROMPT_PREVIEW", { - "draft_text": merged_prompt, - "action": action, - "section_id": sec_id, - "item_id": itm_id, - "content": content, + if not is_confirmed: + query_date = state_data.get("query_date", "") + db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", { + "snapshot_ids": safe_ids, + "query_date": query_date, "idle_turns": 0 }) - - with get_db_connection() as conn_fix: - conn_fix.cursor().execute(""" - UPDATE chat_messages - SET is_ephemeral = 1 - WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') - """, (session_id,)) - conn_fix.commit() - - preview_reply = ( - f"Предпросмотр изменений системного промпта:\n\n" - f"{diff_html}\n\n" - f"Для применения подтвердите действие, отредактируйте или отмените." - ) - - db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) - return preview_reply, db_get_chat_history(session_id), { - "type": "PROMPT_PREVIEW", - "raw_draft": merged_prompt, - "baseline_prompt": baseline_prompt, + ids_str = ", ".join(safe_ids) + reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOT_DELETE_CONFIRM", "buttons": [ - {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, - {"label": "Отменить", "value": "отмена", "style": "danger"}, - {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + {"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"}, + {"label": "Отмена", "value": "отмена", "style": "secondary"} ] } + else: + db_delete_snapshots(snapshot_ids=safe_ids) + query_date = state_data.get("query_date", "") + updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date) - # --- 4.4. Сервисные инструменты --- - elif fn_name == "db_get_snapshots": - snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) - tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) + reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": updated_snapshots_res + } + + elif fn_name == "db_delete_snapshots": + raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str") + raw_ids = fn_args.get("snapshot_ids") or ([raw_id] if raw_id else []) + + # Защита: итоговые Y-срезы удалять запрещено + safe_ids = [s for s in raw_ids if not str(s).startswith("Y")] + if not safe_ids: + reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), None - elif fn_name == "db_get_current_server_time": - tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) + # Прямое выполнение удаления в SQLite без лишних текстовых подтверждений + del_res = db_delete_snapshots(snapshot_ids=safe_ids) + + # Получаем свежий реестр за ту же дату + query_date = fn_args.get("day_str", "") + updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date) - elif fn_name == "db_get_stats": - tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) + reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": updated_snapshots_res + } - elif fn_name == "db_get_anomalies": - tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) + # 5. Сервисные запросы + elif fn_name == "db_get_current_server_time": + tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) + elif fn_name == "db_get_stats": + tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) + elif fn_name == "db_get_anomalies": + tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) + elif fn_name == "db_get_rules": + tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) + elif fn_name == "db_get_reference": + tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) + else: + tool_result_content = "{}" - elif fn_name == "db_get_rules": - tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) - - elif fn_name == "db_get_reference": - tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) - - elif fn_name == "db_add_task": - res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_update_task_status": - res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_delete_task": - res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper()) - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_delete_snapshots": - res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")) - tool_result_content = json.dumps(res, ensure_ascii=False) - - messages.append({"role": "tool", "content": tool_result_content}) - - # Интерпретация результатов вызова инструментов + messages.append(msg) + messages.append({"role": "tool", "content": tool_result_content}) sec_msg = call_ollama_chat(messages, is_vision=False) raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "") - final_content = clean_raw_tool_tags(clean_output(raw_content)) + final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен." - is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_prompt_node_edit"] for name in tool_names_called) else 0 + for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]: + if final_content.lower().startswith(artifact): + final_content = final_content[len(artifact):].lstrip(",.!?:; -") - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) + is_ephem = 1 if fn_name in ["db_get_snapshots", "db_get_system_prompt", "db_prompt_node_edit"] else 0 + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_ephem) return final_content, db_get_chat_history(session_id), None - - # ===================================================================== - # БЛОК 5: ОБЫЧНЫЙ ТЕКСТОВЫЙ ОТВЕТ И СЕМАНТИЧЕСКИЙ TOPIC DRIFT GUARD - # ===================================================================== + # ANCHOR[AGENT_TOPIC_DRIFT] raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." + + for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]: + if final_reply.lower().startswith(artifact): + final_reply = final_reply[len(artifact):].lstrip(",.!?:; -") + action_payload = None - # Обработка отвлечений оператора при активной сессии настройки/просмотра if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]: idle_turns += 1 - logger.info(f"Topic Drift: активна сессия {session_state.get('state_type')}, шагов отвлечения: {idle_turns}/3") - - # Порог отвлечений превышен (> 2 шагов после напоминания) -> Полная зачистка if idle_turns > 3: db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Topic Drift Guard TTL: сессия закрыта по таймауту, очищено {deleted_count} сообщений.") + db_purge_ephemeral_messages(session_id) action_payload = None session_state = None elif idle_turns == 3: - # На 3-м сообщении стороннего диалога вежливо спрашиваем оператора tool_action = db_get_tool_action("db_confirm_prompt_preview") guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?" buttons = tool_action.get("buttons", []) if tool_action else [] - final_reply += f"\n\n💡 *Напоминание:* {guard_question}" - action_payload = { - "type": "PROMPT_FOLLOWUP", - "buttons": buttons - } + action_payload = {"type": "PROMPT_FOLLOWUP", "buttons": buttons} db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns}) else: - # Шаги 1 и 2: продолжаем обычный диалог, инкрементируя счетчик db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns}) - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=(1 if session_state else 0)) + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) return final_reply, db_get_chat_history(session_id), action_payload - # Обработка исключений except Exception as ex: logger.exception(f"Непредвиденная ошибка агента: {ex}") error_reply = f"Внутренняя ошибка сервера: {ex}" diff --git a/modules/web_api/llm/core/context_manager.py b/modules/web_api/llm/core/context_manager.py new file mode 100644 index 0000000..fd0dcb6 --- /dev/null +++ b/modules/web_api/llm/core/context_manager.py @@ -0,0 +1,64 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/context_manager.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Интеллектуальный менеджер контекста: разделение служебных транзакций + инструментов и содержательного диалога с сохранением Topic Drift. +=============================================================================== +""" + +import logging +from typing import Dict, Any, Optional, List + +from ..db.connection import get_db_connection +from ..db_tools import ( + db_save_chat_message, + db_get_chat_history, + db_purge_ephemeral_messages, + db_clear_session_state, + db_set_session_state +) + +logger = logging.getLogger("CONTEXT_MANAGER") + + +def save_tool_interaction(session_id: str, user_content: str, assistant_reply: str) -> None: + """ + Сохраняет синхронную служебную пару инструмента (и вопрос, и ответ = 1). + При очистке удалятся оба сообщения, не оставляя сирот. + """ + db_save_chat_message(session_id, "user", user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=1) + + +def save_dialog_interaction(session_id: str, user_content: str, assistant_reply: str) -> None: + """ + Сохраняет содержательный диалог пользователя и ассистента (и вопрос, и ответ = 0). + Эти сообщения остаются в истории навсегда (включая Topic Drift). + """ + db_save_chat_message(session_id, "user", user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=0) + + +def mark_last_user_message_ephemeral(session_id: str) -> None: + """Помечает последнее сообщение пользователя как эфемерное при активации инструмента.""" + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE chat_messages + SET is_ephemeral = 1 + WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') + """, (session_id,)) + conn.commit() + + +def close_tool_session_and_cleanup(session_id: str, close_reason: str = "COMPLETED") -> int: + """ + Закрывает сессию инструмента и удаляет ТОЛЬКО служебные карточки/команды. + Весь содержательный диалог сохраняется. + """ + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"[ContextManager] Сессия инструмента закрыта ({close_reason}). Очищено служебных сообщений: {deleted_count}") + return deleted_count \ No newline at end of file diff --git a/modules/web_api/llm/core/fast_path.py b/modules/web_api/llm/core/fast_path.py index 69b6d7f..f023a3e 100644 --- a/modules/web_api/llm/core/fast_path.py +++ b/modules/web_api/llm/core/fast_path.py @@ -1,28 +1,19 @@ """ -=============================================================================== FILE: modules/web_api/llm/core/fast_path.py -PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: web_api / llm / core -ROLE: Двухфазный жизненный цикл сессии (PROMPT_PREVIEW -> PROMPT_FOLLOWUP), - атомарная фиксация узлов и мягкая зачистка эфемерного контекста. -=============================================================================== +ROLE: Детерминированный технический конвейер (НЕ для ИИ-размышлений). + Выполняет только кнопки подтверждения для удаления задач, снапшотов и промптов. """ -import logging from typing import Dict, Any, Tuple, Optional - from llm.db_tools import ( - db_apply_prompt_node_action, + db_delete_task, + db_get_tasks, + db_delete_snapshots, + db_get_snapshots, db_add_system_prompt, - db_set_session_state, - db_clear_session_state, - db_purge_ephemeral_messages, - db_save_chat_message, - db_get_chat_history + db_apply_prompt_node_action ) - -logger = logging.getLogger("FAST_PATH") - +from .context_manager import close_tool_session_and_cleanup, save_tool_interaction, save_dialog_interaction def handle_fast_path_intercept( session_id: str, @@ -30,77 +21,67 @@ def handle_fast_path_intercept( full_user_content: str, session_state: Optional[Dict[str, Any]] ) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: - """Обрабатывает команды подтверждения, отмены и завершения сессии.""" + if not session_state: return None state_type = session_state.get("state_type") - user_msg_clean = user_message.lower().strip(" .!?:;") + msg = user_message.strip().lower() - # ========================================================================= - # ФАЗА 1: ОБРАБОТКА В СОСТОЯНИИ PROMPT_PREVIEW - # ========================================================================= + # ⭐️ Управление системным промптом (Превью) if state_type == "PROMPT_PREVIEW": state_data = session_state.get("data_json") or {} - if not isinstance(state_data, dict): - state_data = {} - draft_text = state_data.get("draft_text", "") action = state_data.get("action") sec_id = state_data.get("section_id") itm_id = state_data.get("item_id") content = state_data.get("content", "") - # 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ -> ПЕРЕХОД В PROMPT_FOLLOWUP - if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: + if msg in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: if action == "MANUAL_EDIT" or not action or sec_id is None: if draft_text: db_add_system_prompt("main_agent", draft_text) else: db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content) - logger.info("Системный промпт применен в БД. Переход в PROMPT_FOLLOWUP.") + close_tool_session_and_cleanup(session_id, "PROMPT_APPLIED_SUCCESSFULLY") + reply_text = "✅ Изменения системного промпта успешно применены в базе данных." + save_tool_interaction(session_id, full_user_content, reply_text) + return reply_text, [], None + + elif msg in ["отмена", "отменить", "отклонить"]: + close_tool_session_and_cleanup(session_id, "PROMPT_PREVIEW_CANCELLED") + reply_text = "❌ Изменения системного промпта отменены." + save_dialog_interaction(session_id, full_user_content, reply_text) + return reply_text, [], None + + # Удаление снапшотов + elif state_type == "SNAPSHOT_DELETE_CONFIRM": + state_data = session_state.get("data_json", {}) + ids = state_data.get("snapshot_ids", []) + query_date = state_data.get("query_date", "") + + if msg.startswith("подтверждаю удаление снапшотов"): + db_delete_snapshots(snapshot_ids=ids) + close_tool_session_and_cleanup(session_id, "SNAPSHOTS_DELETED") + updated_data = db_get_snapshots(session_id=session_id, date_str=query_date) + return f"✅ Удалено снапшотов: {len(ids)}", [], {"type": "SNAPSHOTS_CARD", "data": updated_data} + + elif msg == "отмена": + close_tool_session_and_cleanup(session_id, "SNAPSHOT_CANCELLED") + updated_data = db_get_snapshots(session_id=session_id, date_str=query_date) + return "Удаление отменено.", [], {"type": "SNAPSHOTS_CARD", "data": updated_data} + + # Удаление задач + elif state_type == "TASK_DELETE_CONFIRM": + task_id = session_state.get("data_json", {}).get("task_id") + if msg.startswith("подтверждаю удаление задачи"): + db_delete_task(1, str(task_id)) + close_tool_session_and_cleanup(session_id, "TASK_DELETED") + return f"Задача #{task_id} удалена.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)} - # Переводим сессию в режим ожидания продолжения/завершения - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {}) - - reply_text = "Системный промпт успешно сохранен и применен в базе данных. Желаете продолжить работу с системным промптом или завершить?" - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) - - action_payload = { - "type": "PROMPT_FOLLOWUP", - "buttons": [ - {"label": "✨ Показать обновленный промпт", "value": "покажи системный промпт", "style": "secondary"}, - {"label": "🏁 Завершить работу", "value": "завершить работу", "style": "primary"} - ] - } - return reply_text, db_get_chat_history(session_id), action_payload - - # 2. ОТМЕНА В МОМЕНТ ПРЕВЬЮ -> ПОЛНАЯ ЗАЧИСТКА - elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: - db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}") - - reply_text = "Изменения системного промпта отменены. Контекст диалога чист." - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None - - # ========================================================================= - # ФАЗА 2: ОБРАБОТКА В СОСТОЯНИИ PROMPT_FOLLOWUP - # ========================================================================= - elif state_type == "PROMPT_FOLLOWUP": - # Явный сигнал завершения работы - if user_msg_clean in [ - "завершить работу", "завершить", "закончить", "готово", "всё", - "все", "нет", "нет, спасибо", "спасибо", "хватит", "выйти" - ]: - db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Сессия работы с промптом закрыта. Очищено эфемерных сообщений: {deleted_count}") - - reply_text = "Работа с системным промптом завершена. Чем могу помочь?" - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None + elif msg == "отмена": + close_tool_session_and_cleanup(session_id, "TASK_CANCELLED") + return "Удаление отменено.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)} return None \ No newline at end of file diff --git a/modules/web_api/llm/core/ollama_client.py b/modules/web_api/llm/core/ollama_client.py index a6fd772..ed4d763 100644 --- a/modules/web_api/llm/core/ollama_client.py +++ b/modules/web_api/llm/core/ollama_client.py @@ -21,9 +21,9 @@ VISION_MODEL = "qwen2.5vl:7b-q8_0" LLM_OPTIONS = { "num_predict": 8192, "num_ctx": 8192, - "temperature": 0.1, - "repeat_penalty": 1.1, - "presence_penalty": 0.5, + "temperature": 0.0, + "repeat_penalty": 1.0, + "presence_penalty": 0.0, "top_p": 0.9 } diff --git a/modules/web_api/llm/core/tool_injector.py b/modules/web_api/llm/core/tool_injector.py index 3dd45c3..f6dd033 100644 --- a/modules/web_api/llm/core/tool_injector.py +++ b/modules/web_api/llm/core/tool_injector.py @@ -3,48 +3,35 @@ FILE: modules/web_api/llm/core/tool_injector.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / core -ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM. +ROLE: Базовая санитарная очистка артефактов без эвристик и регулярных выражений. + +AI-CONTEXT-ANCHORS: + - ANCHOR[CLEAN_RAW_TOOLS]: Очистка строковых тегов. + - ANCHOR[PASS_THROUGH_TOOLS]: Чистый проходной интерфейс инструментов. =============================================================================== """ -import re -import logging -from typing import List, Dict, Any -logger = logging.getLogger("TOOL_INJECTOR") +# ANCHOR[CLEAN_RAW_TOOLS] +from typing import List, Dict, Any def clean_raw_tool_tags(text: str) -> str: - """Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст.""" + """Удаляет только технические теги разметки, если они попали в текст.""" if not text: return "" - text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*()?', '', text) - text = re.sub(r'.*?', '', text, flags=re.DOTALL) - text = re.sub(r'\w*\[\]\(\)', '', text) - text = re.sub(r'', '', text) - return text.strip() + return text.replace("", "").replace("", "").strip() def clean_output(text: str) -> str: - """Удаляет слова-паразиты и склейки в начале ответа.""" + """Возвращает текст ответа без изменения смысла.""" if not text: - return text - - artifacts = [ - "почемучка", "почемучка,", "почемучка!", "почемучка?", - "почемучто", "почто", "почему что", "почему-то", - "здравствуйте!", "привет!" - ] - - cleaned = text.strip() - for art in artifacts: - if cleaned.lower().startswith(art): - cleaned = cleaned[len(art):].lstrip(",.!?:; -") - - return cleaned.strip() + return "" + return text.strip() +# ANCHOR[PASS_THROUGH_TOOLS] def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ - Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM. + Чистый сквозной проход: все решения принимает исключительно языковая модель. """ return tool_calls \ No newline at end of file diff --git a/modules/web_api/llm/db/db_snapshots.py b/modules/web_api/llm/db/db_snapshots.py index 6918033..2c6e94f 100644 --- a/modules/web_api/llm/db/db_snapshots.py +++ b/modules/web_api/llm/db/db_snapshots.py @@ -1,49 +1,112 @@ """ +=============================================================================== FILE: modules/web_api/llm/db/db_snapshots.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / db +ROLE: Выборка, фильтрация и пакетное удаление срезов логов СКУД в SQLite. + +AI-CONTEXT-ANCHORS: + - ANCHOR[DB_GET_SNAPSHOTS]: Выборка снапшотов с нормализацией дат. + - ANCHOR[DB_DEL_SNAPSHOTS]: Удаление снапшотов по ID, списку ID или за дату. +=============================================================================== """ + import json -from typing import Dict, Any, Optional +import re +from typing import Dict, Any, Optional, List from .connection import get_db_connection from .db_prompts import db_set_session_state +from ..core.calendar_utils import parse_relative_date_ru +# ANCHOR[DB_GET_SNAPSHOTS] def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() - query = "SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count FROM scud_logs " + + query = """ + SELECT + snapshot_id, + log_date, + snapshot_time, + COUNT(*) as record_count + FROM scud_logs + """ params = [] - if date_str: - iso_date = date_str - if "." in date_str: - parts = date_str.split(".") + + clean_date = date_str.strip() if date_str else "" + if not clean_date or not re.search(r'\d{2}\.\d{2}\.\d{4}', clean_date): + if original_user_message: + clean_date = parse_relative_date_ru(original_user_message) + + if clean_date and re.search(r'\d{2}\.\d{2}\.\d{4}', clean_date): + iso_date = clean_date + compact_date = clean_date.replace(".", "") + + if "." in clean_date: + parts = clean_date.split(".") if len(parts) == 3: iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}" - query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? " - params.extend([date_str, iso_date, f"{iso_date}%"]) + compact_date = f"{parts[2]}{parts[1]}{parts[0]}" + + query += """ + WHERE log_date = ? + OR snapshot_time LIKE ? + OR snapshot_id LIKE ? + OR snapshot_id LIKE ? + """ + params.extend([clean_date, f"{iso_date}%", f"{compact_date}-%", f"Y{compact_date}-%"]) - query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50" + query += """ + GROUP BY snapshot_id, log_date, snapshot_time + ORDER BY snapshot_time DESC, snapshot_id DESC + LIMIT 50 + """ + cursor.execute(query, params) rows = cursor.fetchall() snapshots = [dict(r) for r in rows] result_data = { - "query_date": date_str or "все", + "query_date": clean_date or "все", "snapshots_count": len(snapshots), "snapshots": snapshots } - db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=json.dumps(result_data, ensure_ascii=False)) + db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=result_data) conn.close() return result_data -def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]: - if not snapshot_id and not day_str: - return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."} +# ANCHOR[DB_DEL_SNAPSHOTS] +def db_delete_snapshots( + snapshot_id: Optional[str] = None, + snapshot_ids: Optional[List[str]] = None, + day_str: Optional[str] = None +) -> Dict[str, Any]: + """Удаляет один или группу дневных снапшотов с защитой итоговых Y-снапшотов.""" conn = get_db_connection() cursor = conn.cursor() - if snapshot_id: - cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) + deleted = 0 + + if snapshot_ids and isinstance(snapshot_ids, list): + # Исключаем любые итоговые снапшоты, начинающиеся с Y + safe_ids = [s.strip() for s in snapshot_ids if s and not str(s).strip().startswith("Y")] + if safe_ids: + placeholders = ",".join(["?"] * len(safe_ids)) + cursor.execute(f"DELETE FROM scud_logs WHERE snapshot_id IN ({placeholders})", safe_ids) + deleted = cursor.rowcount + elif snapshot_id: + clean_id = str(snapshot_id).strip() + if clean_id.startswith("Y"): + conn.close() + return {"status": "error", "message": f"Итоговый срез [{clean_id}] защищен от удаления."} + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (clean_id,)) + deleted = cursor.rowcount + elif day_str: + cursor.execute("DELETE FROM scud_logs WHERE (log_date = ? OR snapshot_id LIKE ?) AND snapshot_id NOT LIKE 'Y%'", (day_str, f"%{day_str.replace('.', '')}%")) + deleted = cursor.rowcount else: - cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%")) - deleted = cursor.rowcount + conn.close() + return {"status": "error", "message": "Не указаны идентификаторы для удаления."} + conn.commit() conn.close() - return {"status": "success", "message": f"Успешно удалено записей: {deleted}"} \ No newline at end of file + return {"status": "success", "deleted_records": deleted, "message": f"Успешно удалено записей: {deleted}"} \ No newline at end of file diff --git a/modules/web_api/llm/db/db_tasks.py b/modules/web_api/llm/db/db_tasks.py index 4bb97bd..855b0e3 100644 --- a/modules/web_api/llm/db/db_tasks.py +++ b/modules/web_api/llm/db/db_tasks.py @@ -1,82 +1,297 @@ """ +=============================================================================== FILE: modules/web_api/llm/db/db_tasks.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / db +ROLE: Комплексное управление задачами, единый диспетчер db_tasks_edit + и генерация структурированных отчетов в Markdown. +=============================================================================== """ + +import uuid +import logging +import os import re +from datetime import datetime from typing import List, Dict, Any, Optional from .connection import get_db_connection +logger = logging.getLogger("DB_TASKS") + def normalize_task_id(task_id_input: str) -> str: + """Нормализует идентификатор задачи к формату TASK-XX.""" if not task_id_input: return "" - clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "") + clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "") if clean_id.isdigit(): num = int(clean_id) return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}" return f"TASK-{clean_id}" -def db_get_tasks(user_id: int) -> List[Dict[str, Any]]: +def db_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]: + """Получает список всех задач пользователя.""" conn = get_db_connection() cursor = conn.cursor() - cursor.execute(""" - SELECT id, task_id, module, title, priority, status, due_date, created_at - FROM tasks - WHERE user_id = ? - ORDER BY id DESC - """, (user_id,)) + + if status and status.upper() != "ALL": + target_status = status.upper() + if target_status in ["PROGRESS", "В РАБОТЕ"]: target_status = "IN_PROGRESS" + elif target_status in ["DONE", "ГОТОВО"]: target_status = "COMPLETED" + elif target_status in ["PLANNED", "ПЛАНЫ"]: target_status = "BACKLOG" + + cursor.execute(""" + SELECT id, task_id, module, title, priority, status, due_date, created_at + FROM tasks + WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED')) + ORDER BY id DESC + """, (user_id, target_status, target_status)) + else: + cursor.execute(""" + SELECT id, task_id, module, title, priority, status, due_date, created_at + FROM tasks + WHERE user_id = ? + ORDER BY id DESC + """, (user_id,)) + rows = cursor.fetchall() conn.close() return [dict(r) for r in rows] -def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]: +def db_add_task( + user_id: int, + module: str, + title: str, + priority: str = "MEDIUM", + due_date: Optional[str] = None, + status: str = "BACKLOG" +) -> Dict[str, Any]: + """Добавление новой задачи со статусом по умолчанию BACKLOG (В планах).""" conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT MAX(id) FROM tasks") max_id = cursor.fetchone()[0] or 0 new_task_id = f"TASK-{(max_id + 1):02d}" + target_status = status.upper() if status else "BACKLOG" + if target_status in ["PROGRESS", "В РАБОТЕ"]: target_status = "IN_PROGRESS" + elif target_status in ["DONE", "ГОТОВО"]: target_status = "COMPLETED" + elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]: target_status = "BACKLOG" + cursor.execute(""" INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id) - VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?) - """, (new_task_id, module, title, priority.upper(), due_date, user_id)) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id)) conn.commit() conn.close() - return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"} + return {"status": "success", "task_id": new_task_id, "message": f"Задача #{max_id + 1} создана и добавлена в планы"} def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]: + """Быстрое обновление статуса задачи.""" + return db_update_task_details(user_id=user_id, task_id=task_id, status=status, due_date=due_date) + +def db_update_task_details( + user_id: int, + task_id: str, + title: Optional[str] = None, + priority: Optional[str] = None, + status: Optional[str] = None, + due_date: Optional[str] = None +) -> Dict[str, Any]: + """Комплексное обновление любых параметров задачи.""" conn = get_db_connection() cursor = conn.cursor() + + clean_num = re.sub(r'\D', '', str(task_id)) formatted_id = normalize_task_id(task_id) - if due_date: - cursor.execute(""" - UPDATE tasks - SET status = ?, due_date = ? - WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? - """, (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id)) - else: - cursor.execute(""" - UPDATE tasks - SET status = ? - WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? - """, (status.upper(), formatted_id, f"%{task_id.strip()}", user_id)) + + updates = [] + params = [] + + if title is not None and title.strip(): + updates.append("title = ?") + params.append(title.strip()) + + if priority is not None and priority.strip(): + updates.append("priority = ?") + params.append(priority.strip().upper()) + + if status is not None and status.strip(): + target_status = status.strip().upper() + if target_status in ["PROGRESS", "В РАБОТЕ"]: target_status = "IN_PROGRESS" + elif target_status in ["DONE", "ГОТОВО"]: target_status = "COMPLETED" + elif target_status in ["PLANNED", "ПЛАНЫ"]: target_status = "BACKLOG" + updates.append("status = ?") + params.append(target_status) + + if due_date is not None: + updates.append("due_date = ?") + params.append(due_date.strip() if due_date.strip() else None) + + if not updates: + conn.close() + return {"status": "success", "message": "Нет данных для обновления"} + + params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id]) + sql = f""" + UPDATE tasks + SET {', '.join(updates)} + WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """ + cursor.execute(sql, params) if cursor.rowcount == 0: conn.close() return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"} + conn.commit() conn.close() - return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"} + return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"} def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: + """Удаление задачи.""" conn = get_db_connection() cursor = conn.cursor() + clean_num = re.sub(r'\D', '', str(task_id)) formatted_id = normalize_task_id(task_id) + cursor.execute(""" DELETE FROM tasks - WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? - """, (formatted_id, f"%{task_id.strip()}", user_id)) + WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """, (clean_num, formatted_id, f"%{task_id.strip()}", user_id)) + if cursor.rowcount == 0: conn.close() return {"error": f"Задача {task_id} не найдена"} conn.commit() conn.close() - return {"status": "success", "message": f"Задача {formatted_id} удалена"} \ No newline at end of file + return {"status": "success", "message": f"Задача #{task_id} удалена"} + +def db_export_tasks_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]: + """Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/.""" + tasks = db_get_tasks(user_id) + if not tasks: + return {"status": "error", "message": "Список задач пуст, экспорт отменен"} + + # Фильтрация по статусу + if status_filter and status_filter.upper() != "ALL": + tgt = status_filter.upper() + if tgt in ["COMPLETED", "DONE", "ВЫПОЛНЕННЫЕ"]: + tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["COMPLETED", "DONE"]] + elif tgt in ["IN_PROGRESS", "PROGRESS", "В РАБОТЕ"]: + tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["IN_PROGRESS", "PROGRESS"]] + elif tgt in ["BACKLOG", "PLANNED", "В ПЛАНАХ"]: + tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["BACKLOG", "PLANNED"]] + + if not tasks: + return {"status": "error", "message": f"Нет задач с фильтром '{status_filter}' для экспорта"} + + # Имя файла + target_filename = filename.strip() if (filename and filename.strip()) else "ROADMAP.md" + if not target_filename.endswith(".md"): + target_filename = f"{target_filename}.md" + + now_dt = datetime.now() + now_str = now_dt.strftime("%Y-%m-%d %H:%M") + + modules: Dict[str, List[Dict[str, Any]]] = {} + for t in tasks: + mod = t.get("module") or "general" + modules.setdefault(mod, []).append(t) + + md_lines = [ + "# 🗺️ Дорожная карта задач проекта (ROADMAP)\n", + f"> **Сформировано:** {now_str} | **Всего задач:** {len(tasks)}\n", + "---\n" + ] + + for mod_name, mod_tasks in sorted(modules.items()): + md_lines.append(f"## Модуль `{mod_name}`\n") + for t in sorted(mod_tasks, key=lambda x: x.get("id", 0)): + status = str(t.get("status", "BACKLOG")).upper() + is_done = status in ["COMPLETED", "DONE"] + is_progress = status in ["IN_PROGRESS", "PROGRESS"] + + check_box = "[x]" if is_done else "[ ]" + t_id = t.get("id") + title = t.get("title", "Без названия") + prio = t.get("priority", "MEDIUM") + due = f" *(срок: {t['due_date']})*" if t.get("due_date") else "" + status_tag = " `[В РАБОТЕ]`" if is_progress else (" `[ЗАВЕРШЕНО]`" if is_done else "") + + md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}") + + md_lines.append("\n---\n") + + content = "\n".join(md_lines) + + # Точный путь к корню scud_ai/output/web/tasks_export/ + current_file_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.abspath(os.path.join(current_file_dir, "../../../../")) + tool_dir = os.path.join(root_dir, "output", "web", "tasks_export") + os.makedirs(tool_dir, exist_ok=True) + + try: + from routers.files import purge_old_tool_sessions + purge_old_tool_sessions(tool_dir) + except Exception: + pass + + session_token = uuid.uuid4().hex[:8] + session_dir = os.path.join(tool_dir, session_token) + os.makedirs(session_dir, exist_ok=True) + + filepath = os.path.join(session_dir, target_filename) + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + logger.info(f"Файл успешно создан: {filepath}") + + return { + "status": "success", + "filename": target_filename, + "filepath": filepath, + "download_url": f"/api/v1/files/download/tasks_export/{session_token}/{target_filename}", + "tasks_count": len(tasks), + "message": f"Отчет успешно сформирован в файл `{target_filename}` (всего задач: {len(tasks)})." + } + +def db_tasks_edit( + user_id: int, + action: str, + task_id: Optional[str] = None, + title: Optional[str] = None, + priority: Optional[str] = "MEDIUM", + status: Optional[str] = None, + module: Optional[str] = "general", + due_date: Optional[str] = None, + filename: Optional[str] = "ROADMAP.md" +) -> Dict[str, Any]: + """Единый консолидированный диспетчер операций над задачами.""" + act = action.strip().upper() + + if act == "ADD": + if not title: + return {"status": "error", "message": "Для создания задачи требуется указать title"} + return db_add_task( + user_id=user_id, + module=module or "general", + title=title, + priority=priority or "MEDIUM", + due_date=due_date, + status=status or "BACKLOG" + ) + + elif act == "UPDATE": + if not task_id: + return {"status": "error", "message": "Для обновления требуется указать task_id"} + return db_update_task_details(user_id=user_id, task_id=str(task_id), title=title, priority=priority, status=status, due_date=due_date) + + elif act == "DELETE": + if not task_id: + return {"status": "error", "message": "Для удаления требуется указать task_id"} + return db_delete_task(user_id=user_id, task_id=str(task_id)) + + elif act == "EXPORT": + return db_export_tasks_markdown(user_id=user_id, filename=filename or "ROADMAP.md") + + return {"status": "error", "message": f"Неизвестное действие action='{action}'"} \ No newline at end of file diff --git a/modules/web_api/llm/db_tools.py b/modules/web_api/llm/db_tools.py index 0eec0d2..f0264b1 100644 --- a/modules/web_api/llm/db_tools.py +++ b/modules/web_api/llm/db_tools.py @@ -1,31 +1,44 @@ """ +=============================================================================== FILE: modules/web_api/llm/db_tools.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm +ROLE: Фасадная точка доступа ко всем функциям базы данных SQLite. +=============================================================================== """ -from datetime import datetime + from .db.connection import DB_PATH, get_db_connection -from .db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages -from .db.db_tasks import normalize_task_id, db_get_tasks, db_add_task, db_update_task_status, db_delete_task -from .db.db_snapshots import db_get_snapshots, db_delete_snapshots from .db.db_prompts import ( db_get_active_system_prompt, db_add_system_prompt, db_apply_prompt_node_action, db_get_tool_action, - db_get_rules, db_set_session_state, db_get_session_state, db_clear_session_state, + db_get_rules, db_get_stats, db_get_anomalies, db_get_reference ) - -def db_get_current_server_time(): - now = datetime.now() - days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] - return { - "current_date": now.strftime("%d.%m.%Y"), - "current_time": now.strftime("%H:%M:%S"), - "day_of_week": days_ru[now.weekday()], - "iso_date": now.strftime("%Y-%m-%d") - } \ No newline at end of file +from .db.db_chat import ( + db_save_chat_message, + db_get_chat_history, + db_purge_ephemeral_messages, + db_clear_chat_history +) +from .db.db_tasks import ( + normalize_task_id, + db_get_tasks, + db_add_task, + db_update_task_status, + db_update_task_details, + db_delete_task, + db_export_tasks_markdown, + db_tasks_edit +) +from .db.db_snapshots import ( + db_get_snapshots, + db_delete_snapshots +) +from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time \ No newline at end of file diff --git a/modules/web_api/llm/schemas.py b/modules/web_api/llm/schemas.py index 386139e..9c23433 100644 --- a/modules/web_api/llm/schemas.py +++ b/modules/web_api/llm/schemas.py @@ -1,50 +1,77 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/schemas.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm +ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama. + +AI-CONTEXT-ANCHORS: + - ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта. + - ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами. + - ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД. +=============================================================================== +""" + +# ANCHOR[SCHEMA_PROMPT_EDIT] TOOLS_SCHEMA = [ { "type": "function", "function": { "name": "db_prompt_node_edit", "description": ( - "ЕДИНСТВЕННЫЙ инструмент для добавления, изменения или УДАЛЕНИЯ пунктов системного промпта. " - "Вызывай ВСЕГДА при фразах 'удали X.Y', 'удалить пункт X.Y', 'добавь X.Y', 'измени X.Y'. " - "При удалении: action='DELETE', section_id=X, item_id=Y, content=''. " - "Запрещено отвечать текстовым подтверждением без вызова этой функции!" + "Управление элементами системного промпта (добавление, изменение, удаление). " + "Если пользователь пишет 'удали 2.9' или 'удали пункт 2.9', вызывай action='DELETE', section_id=2, item_id=9. " + "Действие action может быть ADD, EDIT или DELETE." ), "parameters": { "type": "object", "properties": { "action": { "type": "string", - "enum": ["ADD", "UPDATE", "DELETE"], - "description": "ADD (добавить), UPDATE (изменить), DELETE (удалить)" + "enum": ["ADD", "EDIT", "DELETE"], + "description": "Действие: ADD (добавить), EDIT (изменить), DELETE (удалить)" }, "section_id": { "type": "integer", - "description": "Номер раздела (например: 1, 2, 3)" + "description": "Номер раздела из точки (например, 2 из 2.9)" }, "item_id": { "type": "integer", - "description": "Номер пункта (например: 4, 8, 9)" + "description": "Номер пункта из точки (например, 9 из 2.9)" }, "content": { "type": "string", - "description": "Текст пункта (для DELETE передается пустая строка)" + "description": "Текст пункта (для DELETE пустая строка)" } }, "required": ["action", "section_id", "item_id"] } } }, + { + "type": "function", + "function": { + "name": "db_get_system_prompt", + "description": "Просмотр текущего активного системного промпта ассистента из базы данных.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + # ANCHOR[SCHEMA_TASKS_EDIT] { "type": "function", "function": { "name": "db_get_tasks", - "description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!", + "description": "Просмотр реестра задач и бэклога текущего пользователя.", "parameters": { "type": "object", "properties": { "status": { "type": "string", - "description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED." + "enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"], + "description": "Опциональный фильтр статуса задач" } } } @@ -53,64 +80,63 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_get_rules", - "description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.", - "parameters": {"type": "object", "properties": {}} - } - }, - { - "type": "function", - "function": { - "name": "db_get_system_prompt", - "description": "ВЫЗЫВАЙ ВСЕГДА при наличии в сообщении фраз: 'покажи системный промпт', 'покажи промпт', 'выведи промпт', 'системный промпт'. Запрещено отвечать текстом без вызова этого инструмента.", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } - }, - { - "type": "function", - "function": { - "name": "db_update_task_status", - "description": "Изменить статус и/или срок выполнения задачи в реестре.", + "name": "db_tasks_edit", + "description": "Единый инструмент управления задачами: создание (ADD), изменение статуса/дедлайна (UPDATE), удаление (DELETE) и экспорт задач в Markdown-файл (EXPORT).", "parameters": { "type": "object", "properties": { - "task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"}, - "status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"}, - "due_date": {"type": "string", "description": "Срок выполнения задачи"} + "action": { + "type": "string", + "enum": ["ADD", "UPDATE", "DELETE", "EXPORT"], + "description": "Тип действия: ADD, UPDATE, DELETE или EXPORT" + }, + "task_id": { + "type": "string", + "description": "Номер задачи (для UPDATE и DELETE, например: '35')" + }, + "title": { + "type": "string", + "description": "Описание или текст задачи" + }, + "status": { + "type": "string", + "enum": ["IN_PROGRESS", "COMPLETED", "PLANNED"], + "description": "Статус задачи" + }, + "priority": { + "type": "string", + "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"], + "description": "Приоритет задачи" + }, + "module": { + "type": "string", + "description": "Модуль проекта" + }, + "due_date": { + "type": "string", + "description": "Срок в формате ГГГГ-ММ-ДД" + }, + "filename": { + "type": "string", + "description": "Имя файла для экспорта" + } }, - "required": ["task_id"] - } - } - }, - { - "type": "function", - "function": { - "name": "db_delete_task", - "description": "Удалить задачу из реестра по её task_id (например, TASK-18).", - "parameters": { - "type": "object", - "properties": { - "task_id": {"type": "string", "description": "Идентификатор задачи для удаления, например TASK-18"} - }, - "required": ["task_id"] + "required": ["action"] } } }, + # ANCHOR[SCHEMA_SNAPSHOTS] { "type": "function", "function": { "name": "db_get_snapshots", - "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!", + "description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.", "parameters": { "type": "object", "properties": { "date_str": { "type": "string", - "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]." + "description": "Дата в формате ДД.ММ.ГГГГ или относительное слово" } } } @@ -120,12 +146,18 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_delete_snapshots", - "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", + "description": "Удаление снапшотов СКУД по идентификатору или дате.", "parameters": { "type": "object", "properties": { - "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, - "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} + "snapshot_id": { + "type": "string", + "description": "Идентификатор конкретного снапшота" + }, + "day_str": { + "type": "string", + "description": "Дата всех снапшотов за день" + } } } } @@ -133,29 +165,31 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_get_current_server_time", - "description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.", + "name": "db_get_anomalies", + "description": "Просмотр истории аномалий и расхождений между СКУД и 1С.", "parameters": { "type": "object", - "properties": {}, - "required": [] + "properties": { + "date_str": { + "type": "string", + "description": "Опциональная дата в формате ДД.ММ.ГГГГ" + }, + "limit": { + "type": "integer", + "description": "Лимит записей" + } + } } } }, { "type": "function", "function": { - "name": "db_add_task", - "description": "Добавить новую задачу в бэклог проекта.", + "name": "db_get_rules", + "description": "Просмотр базы знаний и правил кадрового арбитража компании.", "parameters": { "type": "object", - "properties": { - "title": {"type": "string", "description": "Краткое описание задачи"}, - "priority": {"type": "string", "description": "Приоритет: HIGH, MEDIUM, LOW"}, - "module": {"type": "string", "description": "Модуль проекта, например general или services/scud_export"}, - "due_date": {"type": "string", "description": "Срок выполнения задачи, например '2026-08-07 12:00'"} - }, - "required": ["title"] + "properties": {} } } }, @@ -163,43 +197,35 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_get_stats", - "description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.", - "parameters": {"type": "object", "properties": {}} - } - }, - { - "type": "function", - "function": { - "name": "db_get_anomalies", - "description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.", + "description": "Получение статистики количества записей в таблицах базы данных.", "parameters": { "type": "object", - "properties": { - "limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"}, - "date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"} - } + "properties": {} } } }, { "type": "function", "function": { - "name": "db_get_session_states", - "description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.", - "parameters": {"type": "object", "properties": {}} + "name": "db_get_current_server_time", + "description": "Получение текущего точного времени сервера.", + "parameters": { + "type": "object", + "properties": {} + } } }, { "type": "function", "function": { "name": "db_get_reference", - "description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.", + "description": "Справка о возможностях ассистента и примеры доступных команд.", "parameters": { "type": "object", "properties": { "category": { "type": "string", - "description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр." + "description": "Категория справки" } } } diff --git a/modules/web_api/main.py b/modules/web_api/main.py index 1a64668..a5317cd 100644 --- a/modules/web_api/main.py +++ b/modules/web_api/main.py @@ -25,6 +25,7 @@ from routers.auth import router as auth_router from routers.admin import router as admin_router from routers.tasks import router as tasks_router from routers.chat import router as chat_router +from routers.files import router as files_router # ANCHOR[APP_CONFIG] logging.basicConfig( @@ -53,6 +54,7 @@ app.include_router(auth_router) app.include_router(admin_router) app.include_router(tasks_router) app.include_router(chat_router) +app.include_router(files_router) # ANCHOR[ROOT_STATIC_ROUTES] @app.get("/") @@ -81,7 +83,6 @@ def serve_static_fallback(file_path: str): return FileResponse(target, media_type="text/css") return FileResponse(target) - # Резервный рекурсивный поиск файла в static filename = os.path.basename(clean_path) for root, _, files in os.walk(STATIC_DIR): if filename in files: diff --git a/modules/web_api/routers/files.py b/modules/web_api/routers/files.py new file mode 100644 index 0000000..a411fea --- /dev/null +++ b/modules/web_api/routers/files.py @@ -0,0 +1,73 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/files.py +ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен + через изолированные UUID-директории инструментов. +=============================================================================== +""" + +import os +import time +import shutil +import urllib.parse +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +router = APIRouter(prefix="/api/v1/files", tags=["Files"]) + +BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web") +os.makedirs(WEB_OUTPUT_DIR, exist_ok=True) + +SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок + + +def purge_old_tool_sessions(tool_dir_path: str): + """Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента.""" + if not os.path.exists(tool_dir_path): + return + now = time.time() + cutoff = now - (SESSION_TTL_HOURS * 3600) + try: + for entry in os.listdir(tool_dir_path): + subpath = os.path.join(tool_dir_path, entry) + if os.path.isdir(subpath): + if os.path.getmtime(subpath) < cutoff: + shutil.rmtree(subpath, ignore_errors=True) + except Exception: + pass + + +@router.get("/download/{tool_name}/{session_uuid}/{filename}") +async def download_file(tool_name: str, session_uuid: str, filename: str): + """ + Безопасная отдача файла с каноническим именем из изолированной директории. + """ + safe_tool = os.path.basename(tool_name) + safe_uuid = os.path.basename(session_uuid) + safe_filename = os.path.basename(filename) + + file_path = os.path.join(WEB_OUTPUT_DIR, safe_tool, safe_uuid, safe_filename) + + if not os.path.exists(file_path) or not os.path.isfile(file_path): + raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек") + + # Определение MIME-типа + media_type = "application/octet-stream" + if safe_filename.endswith(".md") or safe_filename.endswith(".txt"): + media_type = "text/markdown; charset=utf-8" + elif safe_filename.endswith(".xlsx"): + media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + elif safe_filename.endswith(".pdf"): + media_type = "application/pdf" + + # Корректная кодировка для кириллических имен файлов + encoded_filename = urllib.parse.quote(safe_filename) + + return FileResponse( + path=file_path, + media_type=media_type, + headers={ + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" + } + ) \ No newline at end of file diff --git a/modules/web_api/routers/tasks.py b/modules/web_api/routers/tasks.py index 5260cfb..80054a6 100644 --- a/modules/web_api/routers/tasks.py +++ b/modules/web_api/routers/tasks.py @@ -1,71 +1,75 @@ """ =============================================================================== FILE: modules/web_api/routers/tasks.py -ROLE: REST API управления задачами (GET / POST / PATCH / DELETE). +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / routers +ROLE: REST API эндпоинты реестра задач (получение, создание и обновление). =============================================================================== """ -# ANCHOR[TASKS_ROUTER_IMPORTS] -from typing import Dict, Any, Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel +from typing import Optional, Dict, Any -from .auth import get_current_user -from llm.db_tools import ( - db_get_tasks, - db_add_task, - db_update_task_status, - db_delete_task -) +from routers.auth import get_current_user +from llm.db_tools import db_get_tasks, db_add_task, db_update_task_details -router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) +router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"]) -# ANCHOR[TASKS_SCHEMAS] -class CreateTaskRequest(BaseModel): +class TaskCreateRequest(BaseModel): title: str priority: Optional[str] = "MEDIUM" module: Optional[str] = "general" due_date: Optional[str] = None + status: Optional[str] = "BACKLOG" -class UpdateTaskRequest(BaseModel): - status: Optional[str] = "COMPLETED" +class TaskUpdateRequest(BaseModel): + title: Optional[str] = None + priority: Optional[str] = None due_date: Optional[str] = None + status: Optional[str] = None + + +def resolve_user_id(current_user: Dict[str, Any]) -> int: + """Извлекает корректный ID пользователя из JWT payload или ставит дефолтный 1.""" + if not current_user: + return 1 + return current_user.get("id") or current_user.get("user_id") or 1 + -# ANCHOR[TASKS_ENDPOINTS] @router.get("") -def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): - """Получить список всех задач текущего авторизованного пользователя.""" - return db_get_tasks(user_id=user["id"]) +async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)): + user_id = resolve_user_id(current_user) + return {"tasks": db_get_tasks(user_id=user_id, status=status)} + @router.post("") -def create_task_endpoint(req: CreateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): - """Прямое создание задачи.""" +async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)): + user_id = resolve_user_id(current_user) res = db_add_task( - user_id=user["id"], - module=req.module or "general", - title=req.title.strip(), - priority=req.priority or "MEDIUM", - due_date=req.due_date - ) - return res - -@router.patch("/{task_id}") -def update_task_endpoint(task_id: str, req: UpdateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): - """Прямое обновление статуса и срока задачи.""" - res = db_update_task_status( - user_id=user["id"], - task_id=task_id, - status=req.status or "COMPLETED", - due_date=req.due_date + user_id=user_id, + module=req.module, + title=req.title, + priority=req.priority, + due_date=req.due_date, + status=req.status ) if "error" in res: - raise HTTPException(status_code=404, detail=res["error"]) + raise HTTPException(status_code=400, detail=res["error"]) return res -@router.delete("/{task_id}") -def delete_task_endpoint(task_id: str, user: Dict[str, Any] = Depends(get_current_user)): - """Прямое удаление задачи.""" - res = db_delete_task(user_id=user["id"], task_id=task_id) + +@router.patch("/{task_id}") +async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)): + user_id = resolve_user_id(current_user) + res = db_update_task_details( + user_id=user_id, + task_id=task_id, + title=req.title, + priority=req.priority, + status=req.status, + due_date=req.due_date + ) if "error" in res: raise HTTPException(status_code=404, detail=res["error"]) return res \ No newline at end of file diff --git a/modules/web_api/static/js/chat/core.js b/modules/web_api/static/js/chat/core.js index de806f9..c79594d 100644 --- a/modules/web_api/static/js/chat/core.js +++ b/modules/web_api/static/js/chat/core.js @@ -4,7 +4,7 @@ FILE: modules/web_api/static/js/chat/core.js PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / static / js / chat ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода - при активных кнопках, динамический вызов инлайн-редактора и двусторонний Diff. + при активных кнопках, вызов инлайн-редактора, Diff и карточки скачивания файлов. =============================================================================== */ @@ -89,8 +89,8 @@ function handleActionButtonClick(text) { } function escapeHtml(text) { - if (!text) return ""; - return text + if (text === null || text === undefined) return ""; + return String(text) .replace(/&/g, "&") .replace(//g, ">") @@ -170,13 +170,11 @@ function buildHighlightedPromptHtml(newRawText, originalBaselineText) { const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`; if (!origMap.has(key) || origMap.get(key).content !== cleanContent) { - // Добавленный или изменённый пункт formattedLines.push(` ${escapeHtml(fullItemStr)}`); } else { formattedLines.push(` ${escapeHtml(fullItemStr)}`); } } else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) { - // Заголовок раздела currentSection = parseInt(secMatch[1]); formattedLines.push(escapeHtml(trimmed)); } else { @@ -184,7 +182,6 @@ function buildHighlightedPromptHtml(newRawText, originalBaselineText) { } } - // Добавляем удаленные пункты (были в оригинале, но отсутствуют в новом тексте) for (let [origKey, origObj] of origMap.entries()) { if (!handledNewKeys.has(origKey)) { const strikeMarkup = ` ${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]`; @@ -382,14 +379,45 @@ async function sendMessage(e) { interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks); } + let snapshotsWidgetHtml = ""; + if (actionData && actionData.type === "SNAPSHOTS_CARD" && typeof renderSnapshotsCard === "function") { + snapshotsWidgetHtml = renderSnapshotsCard(actionData.data); + } + + // ⭐️ БЛОК КАРТОЧКИ СКАЧИВАНИЯ ФАЙЛА + let fileDownloadHtml = ""; + if (actionData && actionData.type === "FILE_DOWNLOAD_CARD") { + fileDownloadHtml = ` +
+
+ +
+
${escapeHtml(actionData.filename)}
+
Готов к скачиванию (задач: ${actionData.tasks_count || '—'})
+
+
+ + + Скачать + +
+ `; + } + + const isWideWidget = actionData && (actionData.type === "TASK_INTERACTIVE_CARD" || actionData.type === "PROMPT_PREVIEW"); + const maxWidthClass = isWideWidget ? "max-w-4xl w-full" : "max-w-2xl"; + const botMsgHtml = ` -
+

${assistantTitle}

${replyText}
${previewEditorHtml} ${interactiveWidgetHtml} + ${snapshotsWidgetHtml} + ${fileDownloadHtml} ${actionButtonsHtml}
`; diff --git a/modules/web_api/static/js/chat/task_widget.js b/modules/web_api/static/js/chat/task_widget.js index d6328e0..d3b9bd8 100644 --- a/modules/web_api/static/js/chat/task_widget.js +++ b/modules/web_api/static/js/chat/task_widget.js @@ -1,208 +1,445 @@ -/** +/* =============================================================================== FILE: modules/web_api/static/js/chat/task_widget.js -ROLE: Генеративный UI интерактивных карточек задач внутри диалога чата - (фильтры, inline-чекбоксы статусов, быстрое добавление и удаление). +ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами. =============================================================================== */ -let activeWidgetTasksMap = new Map(); +window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS'; +window.currentTasksCache = window.currentTasksCache || []; -function renderTaskItemsHtml(tasks, filterStatus) { - const filtered = tasks.filter(t => { - if (filterStatus === "ALL") return true; - return t.status === filterStatus; - }); +function getAuthHeaders() { + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const headers = { 'Content-Type': 'application/json' }; + if (token) { + headers['Authorization'] = 'Bearer ' + token; + } + return headers; +} - if (filtered.length === 0) { - const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все'; - return `
Нет задач со статусом «${label}»
`; +window.sendChatAction = function(actionText) { + const input = document.getElementById("user-input"); + if (input && typeof sendMessage === "function") { + input.value = actionText; + if (typeof setInputLocked === "function") setInputLocked(false); + sendMessage(); + } +}; + +// ============================================================================ +// РЕНДЕРИНГ КАРТОЧКИ СРЕЗОВ СКУД (SNAPSHOTS_CARD) С ЧЕКБОКСАМИ +// ============================================================================ +function renderSnapshotsCard(data) { + if (!data || !data.snapshots || !Array.isArray(data.snapshots)) return ''; + const queryDate = data.query_date || 'выбранную дату'; + const snapshots = data.snapshots; + + if (snapshots.length === 0) { + return ` +
+ 📸 За дату ${queryDate} сохраненных снапшотов не найдено. +
+ `; } - return filtered.map(t => { - const isDone = t.status === "COMPLETED"; - const isProgress = t.status === "IN_PROGRESS"; - const checkedAttr = isDone ? "checked" : ""; - const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium"; + const rowsHtml = snapshots.map((s, idx) => { + const snapId = s.snapshot_id || `ID-${idx}`; + const snapTime = s.snapshot_time ? s.snapshot_time.split(' ')[1] || s.snapshot_time : '—'; + const count = s.record_count || 0; + const isFinal = snapId.startsWith('Y'); - let statusPill = `В планах`; - if (isDone) { - statusPill = `Завершено`; - } else if (isProgress) { - statusPill = `В работе`; - } - - let prioPill = ""; - if (t.priority === "HIGH") { - prioPill = `HIGH`; + if (isFinal) { + return ` +
+
+ + + + + #${snapId} + +
+ + ${snapTime} + + + ${count} записей + +
+
+ + Итоговый Y-срез + +
+ `; } return ` -
-
- -
-
- ${t.task_id} - ${prioPill} - ${statusPill} -
- ${escapeHtml(t.title)} +
+
+ + + #${snapId} + +
+ + ${snapTime} + + + ${count} записей +
-
`; - }).join(""); -} - -function renderInteractiveTaskCard(tasks) { - if (!tasks || !Array.isArray(tasks)) return ""; - const widgetId = "task_widget_" + Date.now(); - activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" }); - - const itemsHtml = renderTaskItemsHtml(tasks, "ALL"); + }).join(''); return ` -
-
- - - - +
+
+
+
+ +
+
+ Реестр срезов СКУД + за ${queryDate} +
+
+
+ + + Срезов: ${snapshots.length} + +
-
- ${itemsHtml} +
+ ${rowsHtml}
-
- -
`; } -function filterTaskWidget(widgetId, status, btnEl) { - const state = activeWidgetTasksMap.get(widgetId); - if (!state) return; - state.filter = status; +// ⭐️ Обработчики чекбоксов +window.toggleSelectAllSnapshots = function(masterCb) { + const root = masterCb.closest('.snapshots-widget-root'); + if (!root) return; + const checkboxes = root.querySelectorAll('.snapshot-item-checkbox'); + checkboxes.forEach(cb => cb.checked = masterCb.checked); + window.syncBulkDeleteFooter(root); +}; - const widgetEl = document.getElementById(widgetId); - if (!widgetEl) return; +window.updateSelectedSnapshots = function(itemCb) { + const root = itemCb.closest('.snapshots-widget-root'); + if (!root) return; + window.syncBulkDeleteFooter(root); +}; - widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => { - b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700"; +window.syncBulkDeleteFooter = function(root) { + const checkboxes = root.querySelectorAll('.snapshot-item-checkbox:checked'); + const footer = root.querySelector('.snapshot-bulk-actions-footer'); + const counter = root.querySelector('.bulk-selected-counter'); + const masterCb = root.querySelector('.select-all-snapshots-cb'); + const allCheckboxes = root.querySelectorAll('.snapshot-item-checkbox'); + + if (masterCb) { + masterCb.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length; + } + + if (checkboxes.length > 0) { + if (footer) footer.classList.remove('hidden'); + if (counter) counter.innerText = `Выбрано дневных срезов: ${checkboxes.length}`; + } else { + if (footer) footer.classList.add('hidden'); + } +}; + +// Одиночная корзина в строке снапшота: +// onclick="window.sendChatAction('удали снапшот ${snapId}')" + +// Кнопка пакетного удаления в подвале карточки: +window.submitBulkDeleteSnapshots = function(btnEl) { + const root = btnEl.closest('.snapshots-widget-root'); + if (!root) return; + const selected = Array.from(root.querySelectorAll('.snapshot-item-checkbox:checked')).map(cb => cb.value); + if (selected.length === 0) return; + window.sendChatAction(`удали снапшоты ${selected.join(', ')}`); +}; + +// ============================================================================ +// РЕНДЕРИНГ КАРТОЧКИ ЗАДАЧ (TASK_INTERACTIVE_CARD) +// ============================================================================ +function renderInteractiveTaskCard(tasks) { + if (!tasks || !Array.isArray(tasks)) return ''; + window.currentTasksCache = tasks; + + const counts = { + ALL: tasks.length, + IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS' || t.status === 'PROGRESS').length, + PLANNED: tasks.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED').length, + COMPLETED: tasks.filter(t => t.status === 'COMPLETED' || t.status === 'DONE').length + }; + + const currentFilter = window.activeTaskFilter || 'IN_PROGRESS'; + + const filteredTasks = tasks.filter(t => { + const s = (t.status || 'BACKLOG').toUpperCase(); + if (currentFilter === 'ALL') return true; + if (currentFilter === 'IN_PROGRESS') return s === 'IN_PROGRESS' || s === 'PROGRESS'; + if (currentFilter === 'PLANNED') return s === 'BACKLOG' || s === 'PLANNED'; + if (currentFilter === 'COMPLETED') return s === 'COMPLETED' || s === 'DONE'; + return true; }); - btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200"; - const container = widgetEl.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, status); - } + const getPrioBadge = (prio) => { + const p = (prio || 'MEDIUM').toUpperCase(); + if (p === 'HIGH' || p === 'CRITICAL') return '🔥 HIGH'; + if (p === 'LOW') return '☕ LOW'; + return '⚡ MEDIUM'; + }; + + const getStatusBadge = (status) => { + const s = (status || 'BACKLOG').toUpperCase(); + if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '⚙️ В работе'; + if (s === 'COMPLETED' || s === 'DONE') return '✓ Готово'; + return '📋 В планах'; + }; + + const taskRows = filteredTasks.map(t => { + const id = t.id; + const title = t.title || 'Без названия'; + const isDone = t.status === 'COMPLETED' || t.status === 'DONE'; + const isInProgress = t.status === 'IN_PROGRESS' || t.status === 'PROGRESS'; + + const actionBtn = isInProgress + ? `` + : (!isDone + ? `` + : ''); + + return ` +
+
+
+ #${id} + ${title} +
+
+ ${actionBtn} + + +
+
+ +
+ ${getPrioBadge(t.priority)} + ${getStatusBadge(t.status)} + ${t.module || 'general'} + ${t.due_date ? `📅 срок: ${t.due_date}` : ''} + ${t.created_at ? `создана: ${t.created_at.split(' ')[0]}` : ''} +
+ + + +
+ `; + }).join(''); + + const emptyState = ` +
+
📭
+
Нет задач в категории «${currentFilter}»
+
+ `; + + return ` +
+
+
+ + + + +
+ + +
+ + + +
+ ${filteredTasks.length > 0 ? taskRows : emptyState} +
+
+ `; } -async function toggleTaskStatusInline(taskId, isChecked) { - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - const newStatus = isChecked ? "COMPLETED" : "BACKLOG"; +window.switchTaskFilter = function(filterName, btnEl) { + window.activeTaskFilter = filterName; + const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root'); + if (root && window.currentTasksCache && window.currentTasksCache.length > 0) { + root.outerHTML = renderInteractiveTaskCard(window.currentTasksCache); + } +}; + +window.toggleCreateTaskForm = function(btnEl) { + const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root'); + if (!root) return; + const form = root.querySelector('.new-task-creation-form'); + if (form) { + form.classList.toggle('hidden'); + if (!form.classList.contains('hidden')) { + const input = form.querySelector('.new-task-title'); + if (input) input.focus(); + } + } +}; + +window.submitCreateTask = async function(btnEl) { + const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root'); + if (!root) return; + + const titleInput = root.querySelector('.new-task-title'); + const dateInput = root.querySelector('.new-task-date'); + const prioInput = root.querySelector('.new-task-prio'); + const statusInput = root.querySelector('.new-task-status'); + + if (!titleInput || !titleInput.value.trim()) { + alert('Введите описание задачи'); + return; + } try { - const res = await fetch(`/api/v1/tasks/${taskId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + token - }, - body: JSON.stringify({ status: newStatus }) + const res = await fetch('/api/v1/tasks', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + title: titleInput.value.trim(), + due_date: dateInput ? (dateInput.value || null) : null, + priority: prioInput ? prioInput.value : 'MEDIUM', + status: statusInput ? statusInput.value : 'BACKLOG' + }) }); if (res.ok) { - for (let [wId, state] of activeWidgetTasksMap.entries()) { - const targetTask = state.tasks.find(t => t.task_id === taskId); - if (targetTask) { - targetTask.status = newStatus; - const widgetEl = document.getElementById(wId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); - } - } - } - if (typeof loadTasks === "function") loadTasks(); + window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED'; + window.sendChatAction('покажи задачи'); + } else { + const err = await res.json(); + alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка')); } - } catch (err) { - console.error("[Task Patch Error]", err); + } catch (e) { + console.error('Ошибка создания задачи:', e); + alert('Сетевая ошибка при создании задачи'); } -} +}; -async function deleteTaskInline(taskId) { - if (!confirm(`Удалить задачу ${taskId}?`)) return; - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); +window.openTaskInlineEditor = function(id) { + const card = document.getElementById(`task-card-${id}`); + if (!card) return; + card.querySelectorAll('.task-view-mode').forEach(el => el.classList.add('hidden')); + const editor = document.getElementById(`task-editor-${id}`); + if (editor) editor.classList.remove('hidden'); +}; + +window.closeTaskInlineEditor = function(id) { + const card = document.getElementById(`task-card-${id}`); + if (!card) return; + card.querySelectorAll('.task-view-mode').forEach(el => el.classList.remove('hidden')); + const editor = document.getElementById(`task-editor-${id}`); + if (editor) editor.classList.add('hidden'); +}; + +window.saveTaskInlineEdit = async function(id) { + const titleInput = document.getElementById(`task-edit-title-${id}`); + const dateInput = document.getElementById(`task-edit-date-${id}`); + const prioInput = document.getElementById(`task-edit-prio-${id}`); + + if (!titleInput || !titleInput.value.trim()) { + alert('Описание задачи не может быть пустым'); + return; + } try { - const res = await fetch(`/api/v1/tasks/${taskId}`, { - method: "DELETE", - headers: { "Authorization": "Bearer " + token } + const res = await fetch(`/api/v1/tasks/${id}`, { + method: 'PATCH', + headers: getAuthHeaders(), + body: JSON.stringify({ + title: titleInput.value.trim(), + due_date: dateInput ? (dateInput.value || null) : null, + priority: prioInput ? prioInput.value : 'MEDIUM' + }) }); if (res.ok) { - for (let [wId, state] of activeWidgetTasksMap.entries()) { - state.tasks = state.tasks.filter(t => t.task_id !== taskId); - const widgetEl = document.getElementById(wId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); - } - } - if (typeof loadTasks === "function") loadTasks(); + window.sendChatAction('покажи задачи'); + } else { + const err = await res.json(); + alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка')); } - } catch (err) { - console.error("[Task Delete Error]", err); + } catch (e) { + console.error('Ошибка сохранения задачи:', e); + alert('Сетевая ошибка при обновлении задачи'); } -} - -async function addTaskInline(widgetId, inputEl) { - if (!inputEl) return; - const title = inputEl.value.trim(); - if (!title) return; - - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - - try { - const res = await fetch("/api/v1/tasks", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + token - }, - body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" }) - }); - - if (res.ok) { - inputEl.value = ""; - const tasksRes = await fetch("/api/v1/tasks", { - headers: { "Authorization": "Bearer " + token } - }); - const updatedTasks = await tasksRes.json(); - - const state = activeWidgetTasksMap.get(widgetId); - if (state) { - state.tasks = updatedTasks; - const widgetEl = document.getElementById(widgetId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter); - } - } - if (typeof loadTasks === "function") loadTasks(); - } - } catch (err) { - console.error("[Task Add Error]", err); - } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/project_code_snapshot.md b/project_code_snapshot.md index ffa9679..f36017d 100644 --- a/project_code_snapshot.md +++ b/project_code_snapshot.md @@ -774,12 +774,12 @@ def print_stats(): print("=" * 60) with get_connection() as conn: cursor = conn.cursor() - tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states'] + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks'] for t in tables: try: cursor.execute(f"SELECT COUNT(*) FROM {t}") cnt = cursor.fetchone()[0] - print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + print(f" • Таблица [{t:<20}]: {cnt:>6} записей") except Exception: pass print("=" * 60 + "\n") @@ -874,7 +874,7 @@ def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None): df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id) if df.empty: - print(f"Записи СКУД не найдены.") + print("Записи СКУД не найдены.") print("=" * 90 + "\n") return @@ -1041,7 +1041,6 @@ def print_chat_messages(session_id=None, limit=50): if not rows: print("Таблица chat_messages пуста.") else: - # Если выводили все без конкретной сессии в порядке DESC, развернем для читаемости if not session_id: rows = list(reversed(rows)) @@ -1050,13 +1049,53 @@ def print_chat_messages(session_id=None, limit=50): eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else "" print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}") print("-" * 105) - # Выводим контент, ограничивая длинные сообщения при необходимости content_preview = content if content else "" print(f"{content_preview}") print("=" * 105) print("\n") +def purge_chat_context(session_id=None, purge_all=False): + """ + Очистка контекста сообщений: + - По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий. + - purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии. + """ + with get_connection() as conn: + cursor = conn.cursor() + + if purge_all: + if session_id: + cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,)) + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + else: + cursor.execute("DELETE FROM chat_messages") + cursor.execute("DELETE FROM session_states") + deleted_msgs = cursor.rowcount + conn.commit() + print(f"\n[✓] Полная очистка истории выполнена! Удалено сообщений: {deleted_msgs}\n") + return + + query = """ + DELETE FROM chat_messages + WHERE is_ephemeral = 1 + OR content LIKE '%Предпросмотр изменений%' + OR content LIKE '%Удален пункт:%' + OR content LIKE '%добавлен пункт:%' + """ + if session_id: + cursor.execute(query + " AND session_id = ?", (session_id,)) + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + else: + cursor.execute(query) + cursor.execute("DELETE FROM session_states") + + deleted_msgs = cursor.rowcount + conn.commit() + + print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n") + + HELP_TEXT = """ CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) @@ -1071,6 +1110,7 @@ CLI-утилита инспекции и управления SQLite базой tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry) sessions -- Посмотреть активные сессии и превью (session_states) context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages) + context purge [session_id] [--all] -- Очистить контекст (умная зачистка или полная с флагом --all) dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день @@ -1083,6 +1123,9 @@ CLI-утилита инспекции и управления SQLite базой python scripts/db_cli.py tools python scripts/db_cli.py sessions python scripts/db_cli.py context web_session_main --limit 20 + python scripts/db_cli.py context purge + python scripts/db_cli.py context purge --all + python scripts/db_cli.py context purge web_session_main --all python scripts/db_cli.py snapshot del Y20260805-007 python scripts/db_cli.py dump my_dump.xlsx """ @@ -1099,12 +1142,13 @@ def main(): add_help=False ) parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools', 'context'], help="Основная команда") - parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del') или session_id для контекста") - parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)") + parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del', 'purge') или session_id для контекста") + parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, '--all' или имя файла)") parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции") parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл") parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") parser.add_argument('--limit', type=int, default=50, help="Лимит выводимых сообщений чата (для команды context)") + parser.add_argument('--all', action='store_true', help="Полная очистка всех сообщений (для команды context purge)") if len(sys.argv) == 1: print_stats() @@ -1133,8 +1177,13 @@ def main(): elif args.command == 'tools': print_tool_actions() elif args.command == 'context': - sess_id = args.action if args.action else None - print_chat_messages(session_id=sess_id, limit=args.limit) + if args.action in ['purge', 'clear']: + is_all = args.all or (args.param == '--all') + sess_id = None if (args.param == '--all' or not args.param) else args.param + purge_chat_context(session_id=sess_id, purge_all=is_all) + else: + sess_id = args.action if args.action else None + print_chat_messages(session_id=sess_id, limit=args.limit) elif args.command == 'dump': filename = args.param if args.param else "db_dump_full.xlsx" dump_all_to_excel(filename) @@ -1151,7 +1200,7 @@ def main(): print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.") print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n") else: - print(f"\n[❌] Ошибка: Неизвестная команда.") + print("\n[❌] Ошибка: Неизвестная команда.") print(HELP_TEXT) if __name__ == "__main__": @@ -3839,6 +3888,7 @@ from routers.auth import router as auth_router from routers.admin import router as admin_router from routers.tasks import router as tasks_router from routers.chat import router as chat_router +from routers.files import router as files_router # ANCHOR[APP_CONFIG] logging.basicConfig( @@ -3867,6 +3917,7 @@ app.include_router(auth_router) app.include_router(admin_router) app.include_router(tasks_router) app.include_router(chat_router) +app.include_router(files_router) # ANCHOR[ROOT_STATIC_ROUTES] @app.get("/") @@ -3895,7 +3946,6 @@ def serve_static_fallback(file_path: str): return FileResponse(target, media_type="text/css") return FileResponse(target) - # Резервный рекурсивный поиск файла в static filename = os.path.basename(clean_path) for root, _, files in os.walk(STATIC_DIR): if filename in files: @@ -4595,578 +4645,6 @@ async function deleteUser(userId, username) { } ``` -## File: `./modules/web_api/static/js/chat.js` -```js -/** -=============================================================================== -FILE: modules/web_api/static/js/chat.js -ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, генерация динамических - кнопок подтверждений и блокировка ввода при активном действии. -=============================================================================== -*/ - -function updateInputHeight(el) { - if (!el) return; - el.style.height = "24px"; - const newHeight = Math.min(el.scrollHeight, 120); - el.style.height = newHeight + "px"; -} - -let selectedFile = null; - -function handleFileSelect(e) { - const file = e.target.files[0]; - if (!file) return; - - if (file.size > 15 * 1024 * 1024) { - alert("Файл слишком большой. Максимальный размер: 15 МБ"); - e.target.value = ""; - return; - } - - selectedFile = file; - const fileNameEl = document.getElementById("file-name-display"); - const fileSizeEl = document.getElementById("file-size-display"); - const previewContainer = document.getElementById("file-preview-container"); - - if (fileNameEl) fileNameEl.innerText = file.name; - if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`; - if (previewContainer) previewContainer.classList.remove("hidden"); -} - -function clearAttachedFile() { - selectedFile = null; - const fileInput = document.getElementById("file-input"); - const previewContainer = document.getElementById("file-preview-container"); - if (fileInput) fileInput.value = ""; - if (previewContainer) previewContainer.classList.add("hidden"); -} - -function setInputLocked(isLocked) { - const input = document.getElementById("user-input"); - const sendBtn = document.getElementById("send-btn"); - if (input) { - input.disabled = isLocked; - if (isLocked) { - input.placeholder = "Выберите действие с помощью кнопок выше..."; - input.classList.add("cursor-not-allowed", "opacity-60"); - } else { - input.placeholder = "Команда, вопрос или перетащите файл сюда..."; - input.classList.remove("cursor-not-allowed", "opacity-60"); - } - } - if (sendBtn) { - sendBtn.disabled = isLocked; - if (isLocked) { - sendBtn.classList.add("opacity-40", "cursor-not-allowed"); - } else { - sendBtn.classList.remove("opacity-40", "cursor-not-allowed"); - } - } -} - -function disableAllActionButtons() { - const allBtnContainers = document.querySelectorAll(".action-buttons-container"); - allBtnContainers.forEach(container => { - container.querySelectorAll("button").forEach(btn => { - btn.disabled = true; - btn.classList.add("opacity-40", "cursor-not-allowed"); - }); - }); -} - -function handleActionButtonClick(text) { - disableAllActionButtons(); - setInputLocked(false); - const input = document.getElementById("user-input"); - if (input) { - input.value = text; - sendMessage(); - } -} - -// --- [INTERACTIVE TASK WIDGET] --- -let activeWidgetTasksMap = new Map(); - -function renderTaskItemsHtml(tasks, filterStatus) { - const filtered = tasks.filter(t => { - if (filterStatus === "ALL") return true; - return t.status === filterStatus; - }); - - if (filtered.length === 0) { - const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все'; - return `
Нет задач со статусом «${label}»
`; - } - - return filtered.map(t => { - const isDone = t.status === "COMPLETED"; - const isProgress = t.status === "IN_PROGRESS"; - const checkedAttr = isDone ? "checked" : ""; - const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium"; - - let statusPill = `В планах`; - if (isDone) { - statusPill = `Завершено`; - } else if (isProgress) { - statusPill = `В работе`; - } - - let prioPill = ""; - if (t.priority === "HIGH") { - prioPill = `HIGH`; - } - - return ` -
-
- -
-
- ${t.task_id} - ${prioPill} - ${statusPill} -
- ${escapeHtml(t.title)} -
-
- -
- `; - }).join(""); -} - -function renderInteractiveTaskCard(tasks) { - if (!tasks || !Array.isArray(tasks)) return ""; - const widgetId = "task_widget_" + Date.now(); - activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" }); - - const itemsHtml = renderTaskItemsHtml(tasks, "ALL"); - - return ` -
-
- - - - -
- -
- ${itemsHtml} -
- -
- - -
-
- `; -} - -function filterTaskWidget(widgetId, status, btnEl) { - const state = activeWidgetTasksMap.get(widgetId); - if (!state) return; - state.filter = status; - - const widgetEl = document.getElementById(widgetId); - if (!widgetEl) return; - - widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => { - b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700"; - }); - btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200"; - - const container = widgetEl.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, status); - } -} - -async function toggleTaskStatusInline(taskId, isChecked) { - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - const newStatus = isChecked ? "COMPLETED" : "BACKLOG"; - - try { - const res = await fetch(`/api/v1/tasks/${taskId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + token - }, - body: JSON.stringify({ status: newStatus }) - }); - - if (res.ok) { - for (let [wId, state] of activeWidgetTasksMap.entries()) { - const targetTask = state.tasks.find(t => t.task_id === taskId); - if (targetTask) { - targetTask.status = newStatus; - const widgetEl = document.getElementById(wId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); - } - } - } - if (typeof loadTasks === "function") loadTasks(); - } - } catch (err) { - console.error("[Task Patch Error]", err); - } -} - -async function deleteTaskInline(taskId) { - if (!confirm(`Удалить задачу ${taskId}?`)) return; - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - - try { - const res = await fetch(`/api/v1/tasks/${taskId}`, { - method: "DELETE", - headers: { "Authorization": "Bearer " + token } - }); - - if (res.ok) { - for (let [wId, state] of activeWidgetTasksMap.entries()) { - state.tasks = state.tasks.filter(t => t.task_id !== taskId); - const widgetEl = document.getElementById(wId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter); - } - } - if (typeof loadTasks === "function") loadTasks(); - } - } catch (err) { - console.error("[Task Delete Error]", err); - } -} - -async function addTaskInline(widgetId, inputEl) { - if (!inputEl) return; - const title = inputEl.value.trim(); - if (!title) return; - - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - - try { - const res = await fetch("/api/v1/tasks", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": "Bearer " + token - }, - body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" }) - }); - - if (res.ok) { - inputEl.value = ""; - const tasksRes = await fetch("/api/v1/tasks", { - headers: { "Authorization": "Bearer " + token } - }); - const updatedTasks = await tasksRes.json(); - - const state = activeWidgetTasksMap.get(widgetId); - if (state) { - state.tasks = updatedTasks; - const widgetEl = document.getElementById(widgetId); - const container = widgetEl?.querySelector(".widget-items-container"); - if (container) { - container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter); - } - } - if (typeof loadTasks === "function") loadTasks(); - } - } catch (err) { - console.error("[Task Add Error]", err); - } -} - -// --- [CHAT SEND PIPELINE] --- -async function sendMessage(e) { - if (e && e.preventDefault) e.preventDefault(); - - const input = document.getElementById("user-input"); - const chatWindow = document.getElementById("chat-window"); - const sendBtn = document.getElementById("send-btn"); - - if (!input || !chatWindow) return; - const text = input.value.trim(); - - if (!text && !selectedFile) return; - - disableAllActionButtons(); - - let userDisplayHtml = escapeHtml(text); - if (selectedFile) { - userDisplayHtml = `
- ${escapeHtml(selectedFile.name)} -
` + userDisplayHtml; - } - - const userMsgHtml = ` -
-
- ${userDisplayHtml} -
-
- `; - chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); - - input.value = ""; - updateInputHeight(input); - chatWindow.scrollTop = chatWindow.scrollHeight; - - if (sendBtn) { - sendBtn.disabled = true; - sendBtn.classList.add("opacity-50"); - } - - const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); - const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); - - const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat"; - - const formData = new FormData(); - formData.append("session_id", "web_session_main"); - formData.append("message", text || "Проанализируй прикрепленный файл"); - - if (selectedFile instanceof File) { - formData.append("file", selectedFile, selectedFile.name); - } - - const headers = {}; - if (!isGuest && token) { - headers["Authorization"] = "Bearer " + token; - } - - try { - const res = await fetch(endpoint, { - method: "POST", - headers: headers, - body: formData - }); - - if (res.status === 401 && !isGuest) { - if (typeof logout === 'function') logout(); - return; - } - - const data = await res.json(); - const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; - const replyText = data.reply || "Пустой ответ от нейросети"; - - let actionButtonsHtml = ""; - const actionData = data.action_type || data.action; - - if (actionData && actionData.buttons && actionData.buttons.length > 0) { - const buttonsMarkup = actionData.buttons.map(btn => { - let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300"; - let iconMarkup = ''; - - const labelLower = (btn.label || "").toLowerCase(); - const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да"); - const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш"); - - if (isPrimary) { - btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm"; - iconMarkup = ''; - } else if (isDanger) { - btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300"; - iconMarkup = ''; - } - - return ` - - `; - }).join(""); - - actionButtonsHtml = ` -
- ${buttonsMarkup} -
- `; - - // Если выведены кнопки подтверждения превью — блокируем ввод с клавиатуры - if (actionData.type === "PROMPT_PREVIEW") { - setInputLocked(true); - } else { - setInputLocked(false); - } - } else { - setInputLocked(false); - } - - let interactiveWidgetHtml = ""; - if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && Array.isArray(actionData.tasks)) { - interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks); - } - - const botMsgHtml = ` -
-

- ${assistantTitle} -

-

${escapeHtml(replyText)}

- ${interactiveWidgetHtml} - ${actionButtonsHtml} -
- `; - chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); - chatWindow.scrollTop = chatWindow.scrollHeight; - - clearAttachedFile(); - - if (!isGuest && typeof loadTasks === 'function') { - loadTasks(); - } - - } catch (err) { - console.error("[Chat Error]", err); - const errorHtml = ` -
- Ошибка связи с сервером. -
- `; - chatWindow.insertAdjacentHTML("beforeend", errorHtml); - chatWindow.scrollTop = chatWindow.scrollHeight; - setInputLocked(false); - } finally { - if (sendBtn && !document.getElementById("user-input")?.disabled) { - sendBtn.disabled = false; - sendBtn.classList.remove("opacity-50"); - } - } -} - -function escapeHtml(text) { - if (!text) return ""; - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -document.addEventListener("DOMContentLoaded", () => { - const input = document.getElementById("user-input"); - const dropZone = document.getElementById("chat-window")?.parentElement; - const dropOverlay = document.getElementById("drop-overlay"); - - if (input) { - let historyIndex = -1; - let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]"); - - input.addEventListener("keydown", (e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - if (input.disabled) return; - const text = input.value.trim(); - if (text) { - if (localHistory.length === 0 || localHistory[0] !== text) { - localHistory.unshift(text); - if (localHistory.length > 50) localHistory.pop(); - localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory)); - } - historyIndex = -1; - } - sendMessage(e); - updateInputHeight(input); - return; - } - - if (e.key === "ArrowUp") { - const textBeforeCursor = input.value.substring(0, input.selectionStart); - const isFirstLine = !textBeforeCursor.includes("\n"); - - if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) { - if (historyIndex < localHistory.length - 1) { - e.preventDefault(); - if (historyIndex === -1) { - input.dataset.draft = input.value; - } - historyIndex++; - input.value = localHistory[historyIndex]; - updateInputHeight(input); - input.setSelectionRange(input.value.length, input.value.length); - } - } - } - - if (e.key === "ArrowDown") { - const textAfterCursor = input.value.substring(input.selectionEnd); - const isLastLine = !textAfterCursor.includes("\n"); - - if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) { - e.preventDefault(); - if (historyIndex > 0) { - historyIndex--; - input.value = localHistory[historyIndex]; - } else { - historyIndex = -1; - input.value = input.dataset.draft || ""; - } - updateInputHeight(input); - input.setSelectionRange(input.value.length, input.value.length); - } - } - }); - } - - if (dropZone && dropOverlay) { - ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => { - dropZone.addEventListener(eventName, (e) => { - e.preventDefault(); - e.stopPropagation(); - }, false); - }); - - ["dragenter", "dragover"].forEach(eventName => { - dropZone.addEventListener(eventName, () => { - dropOverlay.classList.remove("hidden"); - dropOverlay.classList.add("flex"); - }, false); - }); - - ["dragleave", "drop"].forEach(eventName => { - dropZone.addEventListener(eventName, (e) => { - if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) { - dropOverlay.classList.add("hidden"); - dropOverlay.classList.remove("flex"); - } - }, false); - }); - - dropZone.addEventListener("drop", (e) => { - const dt = e.dataTransfer; - const files = dt.files; - - if (files && files.length > 0) { - const file = files[0]; - handleFileSelect({ target: { files: [file] } }); - - const fileInput = document.getElementById("file-input"); - if (fileInput) { - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(file); - fileInput.files = dataTransfer.files; - } - } - }, false); - } -}); -``` - ## File: `./modules/web_api/static/js/tasks.js` ```js /** @@ -5343,7 +4821,7 @@ FILE: modules/web_api/static/js/chat/core.js PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / static / js / chat ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода - при активных кнопках, вызов онлайн-редактора и отправка правок. + при активных кнопках, вызов инлайн-редактора, Diff и карточки скачивания файлов. =============================================================================== */ @@ -5418,11 +4896,6 @@ function disableAllActionButtons() { } function handleActionButtonClick(text) { - if (text === "action:open_editor") { - openInlinePromptEditor(); - return; - } - disableAllActionButtons(); setInputLocked(false); const input = document.getElementById("user-input"); @@ -5432,22 +4905,120 @@ function handleActionButtonClick(text) { } } -function openInlinePromptEditor() { - const editorContainer = document.getElementById("inline-prompt-editor-container"); - if (editorContainer) { +function escapeHtml(text) { + if (text === null || text === undefined) return ""; + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// --- [INLINE PROMPT EDITOR & FULL DIFF BUILDER] --- +function openInlinePromptEditor(btnEl) { + const cardContainer = btnEl.closest(".chat-message-card"); + if (!cardContainer) return; + + const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container"); + const diffView = cardContainer.querySelector(".prompt-preview-diff-view"); + const textarea = cardContainer.querySelector(".inline-prompt-textarea"); + + if (editorContainer && textarea) { editorContainer.classList.remove("hidden"); - const textarea = document.getElementById("inline-prompt-textarea"); - if (textarea) textarea.focus(); + + if (diffView) { + const targetHeight = Math.max(diffView.offsetHeight, 320); + textarea.style.height = `${targetHeight}px`; + } + + textarea.focus(); } } -function closeInlinePromptEditor() { - const editorContainer = document.getElementById("inline-prompt-editor-container"); +function closeInlinePromptEditor(btnEl) { + const cardContainer = btnEl.closest(".chat-message-card"); + if (!cardContainer) return; + const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container"); if (editorContainer) editorContainer.classList.add("hidden"); } -async function saveManualPromptDraft() { - const textarea = document.getElementById("inline-prompt-textarea"); +function parsePromptIntoMap(text) { + const map = new Map(); + if (!text) return map; + const lines = text.split("\n"); + for (let line of lines) { + let trimmed = line.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/); + if (match) { + const key = `${match[1]}.${match[2]}`; + map.set(key, { sec: parseInt(match[1]), itm: parseInt(match[2]), content: match[3].trim(), full: trimmed }); + } + } + return map; +} + +function buildHighlightedPromptHtml(newRawText, originalBaselineText) { + const origMap = parsePromptIntoMap(originalBaselineText); + const newMap = parsePromptIntoMap(newRawText); + + const formattedLines = []; + const rawLines = newRawText.split("\n"); + const handledNewKeys = new Set(); + + let currentSection = null; + + for (let line of rawLines) { + let trimmed = line.trim(); + if (!trimmed) { + formattedLines.push(""); + continue; + } + + const secMatch = trimmed.match(/^(\d+)[\.\s\:\-]+([^\d].*)$/); + const subMatch = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/); + + if (subMatch) { + const key = `${subMatch[1]}.${subMatch[2]}`; + handledNewKeys.add(key); + currentSection = parseInt(subMatch[1]); + const cleanContent = subMatch[3].trim(); + const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`; + + if (!origMap.has(key) || origMap.get(key).content !== cleanContent) { + formattedLines.push(` ${escapeHtml(fullItemStr)}`); + } else { + formattedLines.push(` ${escapeHtml(fullItemStr)}`); + } + } else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) { + currentSection = parseInt(secMatch[1]); + formattedLines.push(escapeHtml(trimmed)); + } else { + formattedLines.push(escapeHtml(trimmed)); + } + } + + for (let [origKey, origObj] of origMap.entries()) { + if (!handledNewKeys.has(origKey)) { + const strikeMarkup = ` ${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]`; + formattedLines.push(strikeMarkup); + } + } + + return formattedLines.join("\n"); +} + +function anyLower(str) { + return /[а-яa-z]/.test(str); +} + +async function saveManualPromptDraft(btnEl) { + const cardContainer = btnEl.closest(".chat-message-card"); + if (!cardContainer) return; + + const textarea = cardContainer.querySelector(".inline-prompt-textarea"); + const previewTextEl = cardContainer.querySelector(".prompt-preview-diff-view"); if (!textarea) return; const newText = textarea.value.trim(); @@ -5469,28 +5040,18 @@ async function saveManualPromptDraft() { }); if (res.ok) { - closeInlinePromptEditor(); - const previewTextEl = document.getElementById("prompt-preview-diff-view"); + closeInlinePromptEditor(btnEl); if (previewTextEl) { - previewTextEl.innerText = newText; + const baseline = cardContainer.dataset.originalBaseline || ""; + const highlightedHtml = buildHighlightedPromptHtml(newText, baseline); + previewTextEl.innerHTML = highlightedHtml; } - alert("✓ Правки сохранены в черновике! Нажмите «Подтвердить» для применения в БД."); } } catch (err) { alert("Ошибка сохранения черновика: " + err); } } -function escapeHtml(text) { - if (!text) return ""; - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - // --- [CHAT SEND PIPELINE] --- async function sendMessage(e) { if (e && e.preventDefault) e.preventDefault(); @@ -5589,8 +5150,10 @@ async function sendMessage(e) { iconMarkup = ''; } + const clickHandler = isEdit ? "openInlinePromptEditor(this)" : `handleActionButtonClick('${escapeHtml(btn.value)}')`; + return ` -
`; - // Блокируем ввод при наличии кнопок setInputLocked(true); } else { setInputLocked(false); } let previewEditorHtml = ""; - if (actionData && actionData.type === "PROMPT_PREVIEW" && actionData.raw_draft) { + let rawDraftText = ""; + let baselineText = ""; + + if (actionData && actionData.type === "PROMPT_PREVIEW") { + rawDraftText = actionData.raw_draft || ""; + baselineText = actionData.baseline_prompt || rawDraftText; previewEditorHtml = ` -