24 changed files with 3471 additions and 2068 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1
View File
@@ -11,4 +11,3 @@ fio,reason,note
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка
Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка
Ароян Станислав Месропович,Удаленная работа,Временная удаленка
1 fio reason note
11 Ковалев Владимир Владимирович Удаленная работа Постоянная удаленка
12 Кожокарь Татьяна Юрьевна Удаленная работа Постоянная удаленка
13 Субетто Юлия Викторовна Удаленная работа Постоянная удаленка
Ароян Станислав Месропович Удаленная работа Временная удаленка
+28
View File
@@ -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 ## [2026-08-18] — Двухфазный жизненный цикл сессий, двусторонний Diff и Topic Drift Guard
### ✨ Добавлено ### ✨ Добавлено
-61
View File
@@ -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 контуре.
+136
View File
@@ -0,0 +1,136 @@
# План реализации (ROADMAP)
## 1. Очистка от регулярок и костылей (`tool_injector.py`)
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[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].
@@ -1,72 +0,0 @@
# План реализации
## 1. Очистка от регулярок и костылей (`tool_injector.py`)
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4].
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[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-чата.
+56
View File
@@ -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-отчет
---
+332 -232
View File
@@ -3,18 +3,22 @@
FILE: modules/web_api/llm/agent.py FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent Coordinator) MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта, ROLE: Нативный оркестратор диалога, диспетчер инструментов (Function Calling)
управление сессионными стейтами, Topic Drift Guard и очистка контекста. и управление сессионными стейтами через 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]: Защита контекста и обработка свободных тем.
=============================================================================== ===============================================================================
""" """
# ============================================================================= # ANCHOR[AGENT_IMPORTS]
# БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ
# =============================================================================
import sys import sys
import json import json
import logging import logging
import re
from typing import List, Dict, Any, Tuple, Optional from typing import List, Dict, Any, Tuple, Optional
from .db.connection import get_db_connection from .db.connection import get_db_connection
@@ -26,6 +30,8 @@ from .db_tools import (
db_update_task_status, db_update_task_status,
db_delete_task, db_delete_task,
db_add_task, db_add_task,
db_tasks_edit,
db_export_tasks_markdown,
db_get_rules, db_get_rules,
db_set_session_state, db_set_session_state,
db_get_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.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
from .core.ollama_client import call_ollama_chat from .core.ollama_client import call_ollama_chat
from .core.fast_path import handle_fast_path_intercept 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 = logging.getLogger("SCUD_AGENT")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@@ -58,9 +70,7 @@ if not logger.handlers:
logger.addHandler(handler) logger.addHandler(handler)
# ============================================================================= # ANCHOR[AGENT_MAIN_PIPELINE]
# БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА
# =============================================================================
def process_chat_message( def process_chat_message(
user_id: int, user_id: int,
user_message: str, user_message: str,
@@ -70,40 +80,29 @@ def process_chat_message(
session_id: str = "web_session_main" session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
""" """
Главная функция обработки сообщения: Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling.
1. Очищает осиротевшие эфемерные сообщения.
2. Перехватывает быстрые кнопки (Fast-Path).
3. Собирает контекст и передает управление Ollama.
4. Выполняет Tool Calls и управляет счетчиком Topic Drift.
""" """
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
# 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения
session_state = db_get_session_state(session_id) session_state = db_get_session_state(session_id)
if not session_state: if not session_state:
db_purge_ephemeral_messages(session_id) db_purge_ephemeral_messages(session_id)
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message 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) fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
if fast_path_res: if fast_path_res:
return fast_path_res return fast_path_res
# Сохранение входящего сообщения оператора # 2. Фиксация сообщения пользователя
is_user_ephemeral = 1 if session_state else 0 db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral)
db_history = db_get_chat_history(session_id, limit=20) db_history = db_get_chat_history(session_id, limit=20)
# ANCHOR[AGENT_SYSTEM_PROMPT]
# =========================================================================
# БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА
# =========================================================================
dynamic_prompt_text = db_get_active_system_prompt()
calendar_context = get_dynamic_calendar_context() calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" 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 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 {} state_data = session_state.get("data_json") or {} if session_state else {}
if not isinstance(state_data, dict): if not isinstance(state_data, dict):
@@ -113,47 +112,61 @@ def process_chat_message(
active_state_context = "" active_state_context = ""
if current_state_type == "PROMPT_PREVIEW": if current_state_type == "PROMPT_PREVIEW":
active_state_context = ( active_state_context = (
"\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n" "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n"
"- Сейчас открыт предпросмотр изменения системного промпта.\n" "- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n"
"- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n"
"- Если пользователь переключился на другую тему — ответь на его вопрос кратко и по делу.\n"
) )
elif current_state_type == "PROMPT_FOLLOWUP": elif current_state_type == "PROMPT_FOLLOWUP":
active_state_context = ( active_state_context = (
"\n[ВНИМАНИЕ: АКТИВНА СЕССИЯ РЕДАКТИРОВАНИЯ СИСТЕМНОГО ПРОМПТА]\n" "\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n"
"- Оператор только что применил предыдущее изменение или запросил просмотр промпта.\n" "- Оператор просматривает или редактирует системный промпт.\n"
"- Любые команды вида 'добавь X.Y', 'удали X.Y', 'измени X.Y' ОЗНАЧАЮТ ПРОДОЛЖЕНИЕ РАБОТЫ С СИСТЕМНЫМ ПРОМПТОМ -> ВЫЗЫВАЙ db_prompt_node_edit.\n" "- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n"
"- Если запрос оператора не ясен или относится к другой теме — ответь на него естественно.\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 = ( system_prompt_content = (
f"Ты — интеллектуальный ассистент SCUD Orion AI. " f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" f"Ты управляешь задачами, аналитикой СКУД и системными настройками исключительно через инструменты (tools).\n\n"
f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n" f"СТРОГИЕ ПРАВИЛА:\n"
f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n" f"1. К оператору всегда обращайся на Вы.\n"
f"[ОКРУЖЕНИЕ]\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"- Пользователь: {user_info}\n"
f"- {calendar_context}\n" f"- {calendar_context}\n"
f"{active_state_context}\n" f"{active_state_context}"
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}"
) )
user_msg_object = {"role": "user", "content": full_user_content} user_msg_object = {"role": "user", "content": full_user_content}
# ANCHOR[AGENT_TOOL_DISPATCHER]
# =========================================================================
# БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING
# =========================================================================
try: try:
# Режим Vision (OCR)
if image_b64: if image_b64:
user_msg_object["images"] = [image_b64] user_msg_object["images"] = [image_b64]
messages = [ messages = [
@@ -162,7 +175,6 @@ def process_chat_message(
] ]
msg = call_ollama_chat(messages, is_vision=True) msg = call_ollama_chat(messages, is_vision=True)
else: else:
# Текстовый диалог
clean_db_history = [dict(m) for m in db_history] clean_db_history = [dict(m) for m in db_history]
for m in clean_db_history: for m in clean_db_history:
m.pop("images", None) m.pop("images", None)
@@ -171,241 +183,329 @@ def process_chat_message(
raw_text_reply = msg.get("content", "") raw_text_reply = msg.get("content", "")
tool_calls = msg.get("tool_calls", []) 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: if tool_calls:
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
messages.append(msg)
tool_names_called = []
# ----------------------------------------------------------------- tool = tool_calls[0]
# TOPIC DRIFT GUARD ДЛЯ ИНСТРУМЕНТОВ: fn_name = tool["function"]["name"]
# Если вызваны сторонние инструменты (снапшоты, задачи, статы) fn_args = tool["function"].get("arguments", {})
# — мгновенно закрываем сессию и вычищаем эфемерный контекст if isinstance(fn_args, str):
# ----------------------------------------------------------------- try:
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) fn_args = json.loads(fn_args)
if not is_prompt_tool and session_state: except Exception:
logger.info(f"Смена темы на инструмент {tool_calls[0]['function']['name']}. Закрываем сессию и очищаем эфемерный контекст.") fn_args = {}
db_clear_session_state(session_id)
db_purge_ephemeral_messages(session_id)
session_state = None
for tool in tool_calls: logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
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 = ""
# --- 4.1. Модуль задач --- # Ротация контекста: закрываем старую сессию инструмента
if fn_name == "db_get_tasks": close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
session_state = None
mark_last_user_message_ephemeral(session_id)
# 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) 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) db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), { return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD", "type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks "tasks": raw_tasks
} }
# --- 4.2. Прямой просмотр системного промпта из SQLite --- # 3. Системный промпт
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: elif fn_name == "db_get_system_prompt":
active_prompt = db_get_active_system_prompt() active_prompt = db_get_active_system_prompt()
reply_text = f"Актуальный системный промпт:\n\n{active_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
# Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift elif fn_name == "db_prompt_node_edit":
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) 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' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>' if action == "DELETE" else f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
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) db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), None return reply_text, db_get_chat_history(session_id), None
# --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) --- if not is_confirmed:
elif fn_name == "db_prompt_node_edit": query_date = state_data.get("query_date", "")
action = fn_args.get("action", "ADD").upper() db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
sec_id = int(fn_args.get("section_id", 2)) "snapshot_ids": safe_ids,
itm_id = int(fn_args.get("item_id", 1)) "query_date": query_date,
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' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>'
else:
line_str = f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
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,
"idle_turns": 0 "idle_turns": 0
}) })
ids_str = ", ".join(safe_ids)
with get_db_connection() as conn_fix: reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
conn_fix.cursor().execute(""" db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
UPDATE chat_messages return reply_text, db_get_chat_history(session_id), {
SET is_ephemeral = 1 "type": "SNAPSHOT_DELETE_CONFIRM",
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,
"buttons": [ "buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, {"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
{"label": "Отменить", "value": "отмена", "style": "danger"}, {"label": "Отмена", "value": "отмена", "style": "secondary"}
{"label": "✏️ Редактировать", "value": "action:open_editor", "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. Сервисные инструменты --- reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
elif fn_name == "db_get_snapshots": db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) return reply_text, db_get_chat_history(session_id), {
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) "type": "SNAPSHOTS_CARD",
"data": updated_snapshots_res
}
elif fn_name == "db_get_current_server_time": elif fn_name == "db_delete_snapshots":
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) 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 [])
elif fn_name == "db_get_stats": # Защита: итоговые Y-срезы удалять запрещено
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) 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_anomalies": # Прямое выполнение удаления в SQLite без лишних текстовых подтверждений
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) del_res = db_delete_snapshots(snapshot_ids=safe_ids)
elif fn_name == "db_get_rules": # Получаем свежий реестр за ту же дату
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) 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_reference": reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) 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_add_task": # 5. Сервисные запросы
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")) elif fn_name == "db_get_current_server_time":
tool_result_content = json.dumps(res, ensure_ascii=False) 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_update_task_status": messages.append(msg)
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")) messages.append({"role": "tool", "content": tool_result_content})
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})
# Интерпретация результатов вызова инструментов
sec_msg = call_ollama_chat(messages, is_vision=False) sec_msg = call_ollama_chat(messages, is_vision=False)
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "") 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 return final_content, db_get_chat_history(session_id), None
# ANCHOR[AGENT_TOPIC_DRIFT]
# =====================================================================
# БЛОК 5: ОБЫЧНЫЙ ТЕКСТОВЫЙ ОТВЕТ И СЕМАНТИЧЕСКИЙ TOPIC DRIFT GUARD
# =====================================================================
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." 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 action_payload = None
# Обработка отвлечений оператора при активной сессии настройки/просмотра
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]: if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]:
idle_turns += 1 idle_turns += 1
logger.info(f"Topic Drift: активна сессия {session_state.get('state_type')}, шагов отвлечения: {idle_turns}/3")
# Порог отвлечений превышен (> 2 шагов после напоминания) -> Полная зачистка
if idle_turns > 3: if idle_turns > 3:
db_clear_session_state(session_id) db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id) db_purge_ephemeral_messages(session_id)
logger.info(f"Topic Drift Guard TTL: сессия закрыта по таймауту, очищено {deleted_count} сообщений.")
action_payload = None action_payload = None
session_state = None session_state = None
elif idle_turns == 3: elif idle_turns == 3:
# На 3-м сообщении стороннего диалога вежливо спрашиваем оператора
tool_action = db_get_tool_action("db_confirm_prompt_preview") tool_action = db_get_tool_action("db_confirm_prompt_preview")
guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?" guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?"
buttons = tool_action.get("buttons", []) if tool_action else [] buttons = tool_action.get("buttons", []) if tool_action else []
final_reply += f"\n\n💡 *Напоминание:* {guard_question}" final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
action_payload = { action_payload = {"type": "PROMPT_FOLLOWUP", "buttons": buttons}
"type": "PROMPT_FOLLOWUP",
"buttons": buttons
}
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns}) db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns})
else: else:
# Шаги 1 и 2: продолжаем обычный диалог, инкрементируя счетчик
db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns}) 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 return final_reply, db_get_chat_history(session_id), action_payload
# Обработка исключений
except Exception as ex: except Exception as ex:
logger.exception(f"Непредвиденная ошибка агента: {ex}") logger.exception(f"Непредвиденная ошибка агента: {ex}")
error_reply = f"Внутренняя ошибка сервера: {ex}" error_reply = f"Внутренняя ошибка сервера: {ex}"
@@ -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
+45 -64
View File
@@ -1,28 +1,19 @@
""" """
===============================================================================
FILE: modules/web_api/llm/core/fast_path.py FILE: modules/web_api/llm/core/fast_path.py
PROJECT: SCUD Orion AI (Unified Architecture) ROLE: Детерминированный технический конвейер (НЕ для ИИ-размышлений).
MODULE: web_api / llm / core Выполняет только кнопки подтверждения для удаления задач, снапшотов и промптов.
ROLE: Двухфазный жизненный цикл сессии (PROMPT_PREVIEW -> PROMPT_FOLLOWUP),
атомарная фиксация узлов и мягкая зачистка эфемерного контекста.
===============================================================================
""" """
import logging
from typing import Dict, Any, Tuple, Optional from typing import Dict, Any, Tuple, Optional
from llm.db_tools import ( 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_add_system_prompt,
db_set_session_state, db_apply_prompt_node_action
db_clear_session_state,
db_purge_ephemeral_messages,
db_save_chat_message,
db_get_chat_history
) )
from .context_manager import close_tool_session_and_cleanup, save_tool_interaction, save_dialog_interaction
logger = logging.getLogger("FAST_PATH")
def handle_fast_path_intercept( def handle_fast_path_intercept(
session_id: str, session_id: str,
@@ -30,77 +21,67 @@ def handle_fast_path_intercept(
full_user_content: str, full_user_content: str,
session_state: Optional[Dict[str, Any]] session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: ) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
"""Обрабатывает команды подтверждения, отмены и завершения сессии."""
if not session_state: if not session_state:
return None return None
state_type = session_state.get("state_type") 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": if state_type == "PROMPT_PREVIEW":
state_data = session_state.get("data_json") or {} state_data = session_state.get("data_json") or {}
if not isinstance(state_data, dict):
state_data = {}
draft_text = state_data.get("draft_text", "") draft_text = state_data.get("draft_text", "")
action = state_data.get("action") action = state_data.get("action")
sec_id = state_data.get("section_id") sec_id = state_data.get("section_id")
itm_id = state_data.get("item_id") itm_id = state_data.get("item_id")
content = state_data.get("content", "") content = state_data.get("content", "")
# 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ -> ПЕРЕХОД В PROMPT_FOLLOWUP if msg in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
if action == "MANUAL_EDIT" or not action or sec_id is None: if action == "MANUAL_EDIT" or not action or sec_id is None:
if draft_text: if draft_text:
db_add_system_prompt("main_agent", draft_text) db_add_system_prompt("main_agent", draft_text)
else: else:
db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content) 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 ["отмена", "отменить", "отклонить"]:
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {}) 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
reply_text = "Системный промпт успешно сохранен и применен в базе данных. Желаете продолжить работу с системным промптом или завершить?" # Удаление снапшотов
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) 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", "")
action_payload = { if msg.startswith("подтверждаю удаление снапшотов"):
"type": "PROMPT_FOLLOWUP", db_delete_snapshots(snapshot_ids=ids)
"buttons": [ close_tool_session_and_cleanup(session_id, "SNAPSHOTS_DELETED")
{"label": "✨ Показать обновленный промпт", "value": "покажи системный промпт", "style": "secondary"}, updated_data = db_get_snapshots(session_id=session_id, date_str=query_date)
{"label": "🏁 Завершить работу", "value": "завершить работу", "style": "primary"} return f"✅ Удалено снапшотов: {len(ids)}", [], {"type": "SNAPSHOTS_CARD", "data": updated_data}
]
}
return reply_text, db_get_chat_history(session_id), action_payload
# 2. ОТМЕНА В МОМЕНТ ПРЕВЬЮ -> ПОЛНАЯ ЗАЧИСТКА elif msg == "отмена":
elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: close_tool_session_and_cleanup(session_id, "SNAPSHOT_CANCELLED")
db_clear_session_state(session_id) updated_data = db_get_snapshots(session_id=session_id, date_str=query_date)
deleted_count = db_purge_ephemeral_messages(session_id) return "Удаление отменено.", [], {"type": "SNAPSHOTS_CARD", "data": updated_data}
logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}")
reply_text = "Изменения системного промпта отменены. Контекст диалога чист." # Удаление задач
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) elif state_type == "TASK_DELETE_CONFIRM":
return reply_text, db_get_chat_history(session_id), None 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)}
# ========================================================================= elif msg == "отмена":
# ФАЗА 2: ОБРАБОТКА В СОСТОЯНИИ PROMPT_FOLLOWUP close_tool_session_and_cleanup(session_id, "TASK_CANCELLED")
# ========================================================================= return "Удаление отменено.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)}
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
return None return None
+3 -3
View File
@@ -21,9 +21,9 @@ VISION_MODEL = "qwen2.5vl:7b-q8_0"
LLM_OPTIONS = { LLM_OPTIONS = {
"num_predict": 8192, "num_predict": 8192,
"num_ctx": 8192, "num_ctx": 8192,
"temperature": 0.1, "temperature": 0.0,
"repeat_penalty": 1.1, "repeat_penalty": 1.0,
"presence_penalty": 0.5, "presence_penalty": 0.0,
"top_p": 0.9 "top_p": 0.9
} }
+14 -27
View File
@@ -3,48 +3,35 @@
FILE: modules/web_api/llm/core/tool_injector.py FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core 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: def clean_raw_tool_tags(text: str) -> str:
"""Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст.""" """Удаляет только технические теги разметки, если они попали в текст."""
if not text: if not text:
return "" return ""
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text) return text.replace("<tool_call>", "").replace("</tool_call>", "").strip()
text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
text = re.sub(r'</tool_call>\w*\[\]\(\)', '', text)
text = re.sub(r'</tool_call>', '', text)
return text.strip()
def clean_output(text: str) -> str: def clean_output(text: str) -> str:
"""Удаляет слова-паразиты и склейки в начале ответа.""" """Возвращает текст ответа без изменения смысла."""
if not text: if not text:
return text return ""
return text.strip()
artifacts = [
"почемучка", "почемучка,", "почемучка!", "почемучка?",
"почемучто", "почто", "почему что", "почему-то",
"здравствуйте!", "привет!"
]
cleaned = text.strip()
for art in artifacts:
if cleaned.lower().startswith(art):
cleaned = cleaned[len(art):].lstrip(",.!?:; -")
return cleaned.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]]: 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 return tool_calls
+82 -19
View File
@@ -1,49 +1,112 @@
""" """
===============================================================================
FILE: modules/web_api/llm/db/db_snapshots.py 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 import json
from typing import Dict, Any, Optional import re
from typing import Dict, Any, Optional, List
from .connection import get_db_connection from .connection import get_db_connection
from .db_prompts import db_set_session_state 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]: 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() conn = get_db_connection()
cursor = conn.cursor() 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 = [] params = []
if date_str:
iso_date = date_str clean_date = date_str.strip() if date_str else ""
if "." in date_str: if not clean_date or not re.search(r'\d{2}\.\d{2}\.\d{4}', clean_date):
parts = date_str.split(".") 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: if len(parts) == 3:
iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}" iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? " compact_date = f"{parts[2]}{parts[1]}{parts[0]}"
params.extend([date_str, iso_date, f"{iso_date}%"])
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, log_date, snapshot_time
ORDER BY snapshot_time DESC, snapshot_id DESC
LIMIT 50
"""
query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
cursor.execute(query, params) cursor.execute(query, params)
rows = cursor.fetchall() rows = cursor.fetchall()
snapshots = [dict(r) for r in rows] snapshots = [dict(r) for r in rows]
result_data = { result_data = {
"query_date": date_str or "все", "query_date": clean_date or "все",
"snapshots_count": len(snapshots), "snapshots_count": len(snapshots),
"snapshots": 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() conn.close()
return result_data return result_data
def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]: # ANCHOR[DB_DEL_SNAPSHOTS]
if not snapshot_id and not day_str: def db_delete_snapshots(
return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."} snapshot_id: Optional[str] = None,
snapshot_ids: Optional[List[str]] = None,
day_str: Optional[str] = None
) -> Dict[str, Any]:
"""Удаляет один или группу дневных снапшотов с защитой итоговых Y-снапшотов."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
if snapshot_id: deleted = 0
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
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: else:
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%")) conn.close()
deleted = cursor.rowcount return {"status": "error", "message": "Не указаны идентификаторы для удаления."}
conn.commit() conn.commit()
conn.close() conn.close()
return {"status": "success", "message": f"Успешно удалено записей: {deleted}"} return {"status": "success", "deleted_records": deleted, "message": f"Успешно удалено записей: {deleted}"}
+243 -28
View File
@@ -1,82 +1,297 @@
""" """
===============================================================================
FILE: modules/web_api/llm/db/db_tasks.py 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 import re
from datetime import datetime
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from .connection import get_db_connection from .connection import get_db_connection
logger = logging.getLogger("DB_TASKS")
def normalize_task_id(task_id_input: str) -> str: def normalize_task_id(task_id_input: str) -> str:
"""Нормализует идентификатор задачи к формату TASK-XX."""
if not task_id_input: if not task_id_input:
return "" 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(): if clean_id.isdigit():
num = int(clean_id) num = int(clean_id)
return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}" return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}"
return f"TASK-{clean_id}" 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() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at if status and status.upper() != "ALL":
FROM tasks target_status = status.upper()
WHERE user_id = ? if target_status in ["PROGRESS", "В РАБОТЕ"]: target_status = "IN_PROGRESS"
ORDER BY id DESC elif target_status in ["DONE", "ГОТОВО"]: target_status = "COMPLETED"
""", (user_id,)) 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() rows = cursor.fetchall()
conn.close() conn.close()
return [dict(r) for r in rows] 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() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT MAX(id) FROM tasks") cursor.execute("SELECT MAX(id) FROM tasks")
max_id = cursor.fetchone()[0] or 0 max_id = cursor.fetchone()[0] or 0
new_task_id = f"TASK-{(max_id + 1):02d}" 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(""" cursor.execute("""
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id) INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", (new_task_id, module, title, priority.upper(), due_date, user_id)) """, (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id))
conn.commit() conn.commit()
conn.close() 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]: 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() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id) formatted_id = normalize_task_id(task_id)
if due_date:
cursor.execute(""" updates = []
UPDATE tasks params = []
SET status = ?, due_date = ?
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? if title is not None and title.strip():
""", (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id)) updates.append("title = ?")
else: params.append(title.strip())
cursor.execute("""
UPDATE tasks if priority is not None and priority.strip():
SET status = ? updates.append("priority = ?")
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? params.append(priority.strip().upper())
""", (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))
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: if cursor.rowcount == 0:
conn.close() conn.close()
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"} return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
conn.commit() conn.commit()
conn.close() 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]: def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
"""Удаление задачи."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
clean_num = re.sub(r'\D', '', str(task_id))
formatted_id = normalize_task_id(task_id) formatted_id = normalize_task_id(task_id)
cursor.execute(""" cursor.execute("""
DELETE FROM tasks DELETE FROM tasks
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (formatted_id, f"%{task_id.strip()}", user_id)) """, (clean_num, formatted_id, f"%{task_id.strip()}", user_id))
if cursor.rowcount == 0: if cursor.rowcount == 0:
conn.close() conn.close()
return {"error": f"Задача {task_id} не найдена"} return {"error": f"Задача {task_id} не найдена"}
conn.commit() conn.commit()
conn.close() conn.close()
return {"status": "success", "message": f"Задача {formatted_id} удалена"} 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}'"}
+28 -15
View File
@@ -1,31 +1,44 @@
""" """
===============================================================================
FILE: modules/web_api/llm/db_tools.py 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.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 ( from .db.db_prompts import (
db_get_active_system_prompt, db_get_active_system_prompt,
db_add_system_prompt, db_add_system_prompt,
db_apply_prompt_node_action, db_apply_prompt_node_action,
db_get_tool_action, db_get_tool_action,
db_get_rules,
db_set_session_state, db_set_session_state,
db_get_session_state, db_get_session_state,
db_clear_session_state, db_clear_session_state,
db_get_rules,
db_get_stats, db_get_stats,
db_get_anomalies, db_get_anomalies,
db_get_reference db_get_reference
) )
from .db.db_chat import (
def db_get_current_server_time(): db_save_chat_message,
now = datetime.now() db_get_chat_history,
days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] db_purge_ephemeral_messages,
return { db_clear_chat_history
"current_date": now.strftime("%d.%m.%Y"), )
"current_time": now.strftime("%H:%M:%S"), from .db.db_tasks import (
"day_of_week": days_ru[now.weekday()], normalize_task_id,
"iso_date": now.strftime("%Y-%m-%d") 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
+113 -87
View File
@@ -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 = [ TOOLS_SCHEMA = [
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "db_prompt_node_edit", "name": "db_prompt_node_edit",
"description": ( "description": (
"ЕДИНСТВЕННЫЙ инструмент для добавления, изменения или УДАЛЕНИЯ пунктов системного промпта. " "Управление элементами системного промпта (добавление, изменение, удаление). "
"Вызывай ВСЕГДА при фразах 'удали X.Y', 'удалить пункт X.Y', 'добавь X.Y', 'измени X.Y'. " "Если пользователь пишет 'удали 2.9' или 'удали пункт 2.9', вызывай action='DELETE', section_id=2, item_id=9. "
"При удалении: action='DELETE', section_id=X, item_id=Y, content=''. " "Действие action может быть ADD, EDIT или DELETE."
"Запрещено отвечать текстовым подтверждением без вызова этой функции!"
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"action": { "action": {
"type": "string", "type": "string",
"enum": ["ADD", "UPDATE", "DELETE"], "enum": ["ADD", "EDIT", "DELETE"],
"description": "ADD (добавить), UPDATE (изменить), DELETE (удалить)" "description": "Действие: ADD (добавить), EDIT (изменить), DELETE (удалить)"
}, },
"section_id": { "section_id": {
"type": "integer", "type": "integer",
"description": "Номер раздела (например: 1, 2, 3)" "description": "Номер раздела из точки (например, 2 из 2.9)"
}, },
"item_id": { "item_id": {
"type": "integer", "type": "integer",
"description": "Номер пункта (например: 4, 8, 9)" "description": "Номер пункта из точки (например, 9 из 2.9)"
}, },
"content": { "content": {
"type": "string", "type": "string",
"description": "Текст пункта (для DELETE передается пустая строка)" "description": "Текст пункта (для DELETE пустая строка)"
} }
}, },
"required": ["action", "section_id", "item_id"] "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", "type": "function",
"function": { "function": {
"name": "db_get_tasks", "name": "db_get_tasks",
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!", "description": "Просмотр реестра задач и бэклога текущего пользователя.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"status": { "status": {
"type": "string", "type": "string",
"description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED." "enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
"description": "Опциональный фильтр статуса задач"
} }
} }
} }
@@ -53,64 +80,63 @@ TOOLS_SCHEMA = [
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_rules", "name": "db_tasks_edit",
"description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.", "description": "Единый инструмент управления задачами: создание (ADD), изменение статуса/дедлайна (UPDATE), удаление (DELETE) и экспорт задач в Markdown-файл (EXPORT).",
"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": "Изменить статус и/или срок выполнения задачи в реестре.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"}, "action": {
"status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"}, "type": "string",
"due_date": {"type": "string", "description": "Срок выполнения задачи"} "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"] "required": ["action"]
}
}
},
{
"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"]
} }
} }
}, },
# ANCHOR[SCHEMA_SNAPSHOTS]
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_snapshots", "name": "db_get_snapshots",
"description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!", "description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"date_str": { "date_str": {
"type": "string", "type": "string",
"description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]." "description": "Дата в формате ДД.ММ.ГГГГ или относительное слово"
} }
} }
} }
@@ -120,12 +146,18 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_delete_snapshots", "name": "db_delete_snapshots",
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", "description": "Удаление снапшотов СКУД по идентификатору или дате.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, "snapshot_id": {
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} "type": "string",
"description": "Идентификатор конкретного снапшота"
},
"day_str": {
"type": "string",
"description": "Дата всех снапшотов за день"
}
} }
} }
} }
@@ -133,29 +165,31 @@ TOOLS_SCHEMA = [
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_current_server_time", "name": "db_get_anomalies",
"description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.", "description": "Просмотр истории аномалий и расхождений между СКУД и 1С.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {
"required": [] "date_str": {
"type": "string",
"description": "Опциональная дата в формате ДД.ММ.ГГГГ"
},
"limit": {
"type": "integer",
"description": "Лимит записей"
}
}
} }
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "db_add_task", "name": "db_get_rules",
"description": "Добавить новую задачу в бэклог проекта.", "description": "Просмотр базы знаний и правил кадрового арбитража компании.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "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"]
} }
} }
}, },
@@ -163,43 +197,35 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_stats", "name": "db_get_stats",
"description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.", "description": "Получение статистики количества записей в таблицах базы данных.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_get_anomalies",
"description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {}
"limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"},
"date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"}
}
} }
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_session_states", "name": "db_get_current_server_time",
"description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.", "description": "Получение текущего точного времени сервера.",
"parameters": {"type": "object", "properties": {}} "parameters": {
"type": "object",
"properties": {}
}
} }
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_reference", "name": "db_get_reference",
"description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.", "description": "Справка о возможностях ассистента и примеры доступных команд.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"category": { "category": {
"type": "string", "type": "string",
"description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр." "description": "Категория справки"
} }
} }
} }
+2 -1
View File
@@ -25,6 +25,7 @@ from routers.auth import router as auth_router
from routers.admin import router as admin_router from routers.admin import router as admin_router
from routers.tasks import router as tasks_router from routers.tasks import router as tasks_router
from routers.chat import router as chat_router from routers.chat import router as chat_router
from routers.files import router as files_router
# ANCHOR[APP_CONFIG] # ANCHOR[APP_CONFIG]
logging.basicConfig( logging.basicConfig(
@@ -53,6 +54,7 @@ app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(tasks_router) app.include_router(tasks_router)
app.include_router(chat_router) app.include_router(chat_router)
app.include_router(files_router)
# ANCHOR[ROOT_STATIC_ROUTES] # ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/") @app.get("/")
@@ -81,7 +83,6 @@ def serve_static_fallback(file_path: str):
return FileResponse(target, media_type="text/css") return FileResponse(target, media_type="text/css")
return FileResponse(target) return FileResponse(target)
# Резервный рекурсивный поиск файла в static
filename = os.path.basename(clean_path) filename = os.path.basename(clean_path)
for root, _, files in os.walk(STATIC_DIR): for root, _, files in os.walk(STATIC_DIR):
if filename in files: if filename in files:
+73
View File
@@ -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}"
}
)
+44 -40
View File
@@ -1,71 +1,75 @@
""" """
=============================================================================== ===============================================================================
FILE: modules/web_api/routers/tasks.py 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 fastapi import APIRouter, Depends, HTTPException, Request
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Dict, Any
from .auth import get_current_user from routers.auth import get_current_user
from llm.db_tools import ( from llm.db_tools import db_get_tasks, db_add_task, db_update_task_details
db_get_tasks,
db_add_task,
db_update_task_status,
db_delete_task
)
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])
# ANCHOR[TASKS_SCHEMAS] class TaskCreateRequest(BaseModel):
class CreateTaskRequest(BaseModel):
title: str title: str
priority: Optional[str] = "MEDIUM" priority: Optional[str] = "MEDIUM"
module: Optional[str] = "general" module: Optional[str] = "general"
due_date: Optional[str] = None due_date: Optional[str] = None
status: Optional[str] = "BACKLOG"
class UpdateTaskRequest(BaseModel): class TaskUpdateRequest(BaseModel):
status: Optional[str] = "COMPLETED" title: Optional[str] = None
priority: Optional[str] = None
due_date: 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("") @router.get("")
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)):
"""Получить список всех задач текущего авторизованного пользователя.""" user_id = resolve_user_id(current_user)
return db_get_tasks(user_id=user["id"]) return {"tasks": db_get_tasks(user_id=user_id, status=status)}
@router.post("") @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( res = db_add_task(
user_id=user["id"], user_id=user_id,
module=req.module or "general", module=req.module,
title=req.title.strip(), title=req.title,
priority=req.priority or "MEDIUM", priority=req.priority,
due_date=req.due_date due_date=req.due_date,
status=req.status
) )
if "error" in res:
raise HTTPException(status_code=400, detail=res["error"])
return res return res
@router.patch("/{task_id}") @router.patch("/{task_id}")
def update_task_endpoint(task_id: str, req: UpdateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): 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_status( res = db_update_task_details(
user_id=user["id"], user_id=user_id,
task_id=task_id, task_id=task_id,
status=req.status or "COMPLETED", title=req.title,
priority=req.priority,
status=req.status,
due_date=req.due_date due_date=req.due_date
) )
if "error" in res: if "error" in res:
raise HTTPException(status_code=404, detail=res["error"]) raise HTTPException(status_code=404, detail=res["error"])
return res 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)
if "error" in res:
raise HTTPException(status_code=404, detail=res["error"])
return res
+35 -7
View File
@@ -4,7 +4,7 @@ FILE: modules/web_api/static/js/chat/core.js
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / static / js / chat MODULE: web_api / static / js / chat
ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода
при активных кнопках, динамический вызов инлайн-редактора и двусторонний Diff. при активных кнопках, вызов инлайн-редактора, Diff и карточки скачивания файлов.
=============================================================================== ===============================================================================
*/ */
@@ -89,8 +89,8 @@ function handleActionButtonClick(text) {
} }
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return ""; if (text === null || text === undefined) return "";
return text return String(text)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
@@ -170,13 +170,11 @@ function buildHighlightedPromptHtml(newRawText, originalBaselineText) {
const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`; const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`;
if (!origMap.has(key) || origMap.get(key).content !== cleanContent) { if (!origMap.has(key) || origMap.get(key).content !== cleanContent) {
// Добавленный или изменённый пункт
formattedLines.push(` <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">${escapeHtml(fullItemStr)}</span>`); formattedLines.push(` <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">${escapeHtml(fullItemStr)}</span>`);
} else { } else {
formattedLines.push(` ${escapeHtml(fullItemStr)}`); formattedLines.push(` ${escapeHtml(fullItemStr)}`);
} }
} else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) { } else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) {
// Заголовок раздела
currentSection = parseInt(secMatch[1]); currentSection = parseInt(secMatch[1]);
formattedLines.push(escapeHtml(trimmed)); formattedLines.push(escapeHtml(trimmed));
} else { } else {
@@ -184,7 +182,6 @@ function buildHighlightedPromptHtml(newRawText, originalBaselineText) {
} }
} }
// Добавляем удаленные пункты (были в оригинале, но отсутствуют в новом тексте)
for (let [origKey, origObj] of origMap.entries()) { for (let [origKey, origObj] of origMap.entries()) {
if (!handledNewKeys.has(origKey)) { if (!handledNewKeys.has(origKey)) {
const strikeMarkup = ` <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]</span>`; const strikeMarkup = ` <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]</span>`;
@@ -382,14 +379,45 @@ async function sendMessage(e) {
interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks); 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 = `
<div class="mt-3 p-3 bg-emerald-50 border border-emerald-200 rounded-xl flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<i class="fa-solid fa-file-lines text-emerald-600 text-lg shrink-0"></i>
<div class="truncate">
<div class="text-xs font-bold text-slate-900 truncate">${escapeHtml(actionData.filename)}</div>
<div class="text-[11px] text-emerald-700">Готов к скачиванию (задач: ${actionData.tasks_count || '—'})</div>
</div>
</div>
<a href="${actionData.download_url}" download="${escapeHtml(actionData.filename)}"
class="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-semibold rounded-lg text-xs flex items-center gap-1.5 transition shrink-0 shadow-sm">
<i class="fa-solid fa-download"></i>
<span>Скачать</span>
</a>
</div>
`;
}
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 = ` const botMsgHtml = `
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3" data-original-baseline="${escapeHtml(baselineText)}"> <div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm ${maxWidthClass} mb-3" data-original-baseline="${escapeHtml(baselineText)}">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1"> <p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle} <i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
</p> </p>
<div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div> <div class="prompt-preview-diff-view text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${replyText}</div>
${previewEditorHtml} ${previewEditorHtml}
${interactiveWidgetHtml} ${interactiveWidgetHtml}
${snapshotsWidgetHtml}
${fileDownloadHtml}
${actionButtonsHtml} ${actionButtonsHtml}
</div> </div>
`; `;
+393 -156
View File
@@ -1,208 +1,445 @@
/** /*
=============================================================================== ===============================================================================
FILE: modules/web_api/static/js/chat/task_widget.js FILE: modules/web_api/static/js/chat/task_widget.js
ROLE: Генеративный UI интерактивных карточек задач внутри диалога чата ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами.
(фильтры, inline-чекбоксы статусов, быстрое добавление и удаление).
=============================================================================== ===============================================================================
*/ */
let activeWidgetTasksMap = new Map(); window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS';
window.currentTasksCache = window.currentTasksCache || [];
function renderTaskItemsHtml(tasks, filterStatus) { function getAuthHeaders() {
const filtered = tasks.filter(t => { const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
if (filterStatus === "ALL") return true; const headers = { 'Content-Type': 'application/json' };
return t.status === filterStatus; if (token) {
}); headers['Authorization'] = 'Bearer ' + token;
}
return headers;
}
if (filtered.length === 0) { window.sendChatAction = function(actionText) {
const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все'; const input = document.getElementById("user-input");
return `<div class="text-slate-400 text-xs py-4 text-center">Нет задач со статусом «${label}»</div>`; 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 `
<div class="p-6 bg-slate-50 border border-slate-200 rounded-xl text-center text-xs text-slate-500 my-2">
📸 За дату <b>${queryDate}</b> сохраненных снапшотов не найдено.
</div>
`;
} }
return filtered.map(t => { const rowsHtml = snapshots.map((s, idx) => {
const isDone = t.status === "COMPLETED"; const snapId = s.snapshot_id || `ID-${idx}`;
const isProgress = t.status === "IN_PROGRESS"; const snapTime = s.snapshot_time ? s.snapshot_time.split(' ')[1] || s.snapshot_time : '—';
const checkedAttr = isDone ? "checked" : ""; const count = s.record_count || 0;
const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium"; const isFinal = snapId.startsWith('Y');
let statusPill = `<span class="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded border border-slate-200">В планах</span>`; if (isFinal) {
if (isDone) { return `
statusPill = `<span class="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded border border-emerald-300 font-semibold">Завершено</span>`; <div class="p-2.5 bg-purple-50/60 rounded-lg border border-purple-200 shadow-sm flex items-center justify-between gap-3">
} else if (isProgress) { <div class="flex items-center gap-2.5 min-w-0">
statusPill = `<span class="text-[10px] bg-amber-50 text-amber-700 px-2 py-0.5 rounded border border-amber-300 font-semibold">В работе</span>`; <span class="w-4 flex justify-center text-purple-400" title="Итоговый срез защищен">
} <i class="fa-solid fa-lock text-[11px]"></i>
</span>
let prioPill = ""; <span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-purple-100 text-purple-800 border border-purple-300">
if (t.priority === "HIGH") { #${snapId}
prioPill = `<span class="text-[9px] bg-red-50 text-red-700 font-bold px-1.5 py-0.5 rounded border border-red-200 uppercase">HIGH</span>`; </span>
<div class="flex items-center gap-3 text-xs text-slate-600">
<span class="flex items-center gap-1 font-semibold text-purple-900">
<i class="fa-regular fa-clock text-purple-600 text-[11px]"></i> ${snapTime}
</span>
<span class="flex items-center gap-1 text-slate-500">
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
</span>
</div>
</div>
<span class="text-[10px] font-bold text-purple-700 bg-purple-100 border border-purple-200 px-2 py-0.5 rounded">
Итоговый Y-срез
</span>
</div>
`;
} }
return ` return `
<div class="flex items-center justify-between p-2.5 bg-slate-50 hover:bg-slate-100/80 rounded-xl border border-slate-200 transition gap-2" id="widget-task-${t.task_id}"> <div class="p-2.5 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition flex items-center justify-between gap-3">
<div class="flex items-center gap-2.5 flex-1 min-w-0"> <div class="flex items-center gap-2.5 min-w-0">
<input type="checkbox" ${checkedAttr} onchange="toggleTaskStatusInline('${t.task_id}', this.checked)" <input type="checkbox" value="${snapId}" onchange="window.updateSelectedSnapshots(this)"
class="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500 cursor-pointer"> class="snapshot-item-checkbox rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-4 h-4 cursor-pointer">
<div class="flex flex-col min-w-0 flex-1"> <span class="font-mono text-xs font-bold px-2 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">
<div class="flex items-center gap-1.5 mb-0.5"> #${snapId}
<span class="font-mono text-[11px] font-bold text-slate-700">${t.task_id}</span> </span>
${prioPill} <div class="flex items-center gap-3 text-xs text-slate-600">
${statusPill} <span class="flex items-center gap-1 font-semibold text-slate-800">
</div> <i class="fa-regular fa-clock text-indigo-500 text-[11px]"></i> ${snapTime}
<span class="text-xs ${textClass} truncate leading-tight">${escapeHtml(t.title)}</span> </span>
<span class="flex items-center gap-1 text-slate-500">
<i class="fa-solid fa-users text-slate-400 text-[11px]"></i> ${count} записей
</span>
</div> </div>
</div> </div>
<button type="button" onclick="deleteTaskInline('${t.task_id}')" class="text-slate-400 hover:text-red-500 p-1.5 transition rounded-lg hover:bg-red-50" title="Удалить задачу"> <button type="button" onclick="window.sendChatAction('удали снапшот ${snapId}')"
class="p-1 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-transparent hover:border-rose-200 transition"
title="Удалить срез">
<i class="fa-solid fa-trash-can text-xs"></i> <i class="fa-solid fa-trash-can text-xs"></i>
</button> </button>
</div> </div>
`; `;
}).join(""); }).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 ` return `
<div class="task-widget-card mt-3 bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm space-y-3" id="${widgetId}"> <div class="snapshots-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
<div class="flex items-center gap-1 pb-2 border-b border-slate-100 overflow-x-auto no-scrollbar text-xs font-semibold"> <div class="px-4 py-2.5 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
<button type="button" onclick="filterTaskWidget('${widgetId}', 'ALL', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200">Все</button> <div class="flex items-center gap-2">
<button type="button" onclick="filterTaskWidget('${widgetId}', 'IN_PROGRESS', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В работе</button> <div class="bg-indigo-600 text-white p-1.5 rounded-lg flex items-center justify-center">
<button type="button" onclick="filterTaskWidget('${widgetId}', 'BACKLOG', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В планах</button> <i class="fa-solid fa-camera text-xs"></i>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'COMPLETED', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">Завершенные</button> </div>
<div>
<span class="text-xs font-bold text-slate-800">Реестр срезов СКУД</span>
<span class="text-xs text-slate-500 ml-1">за ${queryDate}</span>
</div>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer select-none">
<input type="checkbox" onchange="window.toggleSelectAllSnapshots(this)" class="select-all-snapshots-cb rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 w-3.5 h-3.5">
<span>Выбрать все</span>
</label>
<span class="text-[11px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200 px-2 py-0.5 rounded-full">
Срезов: ${snapshots.length}
</span>
</div>
</div> </div>
<div class="widget-items-container space-y-2 max-h-72 overflow-y-auto pr-1"> <div class="p-3 flex flex-col gap-2">
${itemsHtml} ${rowsHtml}
</div> </div>
<div class="pt-2 border-t border-slate-100 flex items-center gap-2"> <!-- ПОДВАЛ С КНОПКОЙ УДАЛЕНИЯ ВЫБРАННЫХ -->
<input type="text" placeholder="Новая задача..." class="flex-1 bg-slate-50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs text-slate-800 focus:outline-none focus:border-indigo-600 focus:bg-white transition" onkeydown="if(event.key==='Enter') addTaskInline('${widgetId}', this)"> <div class="snapshot-bulk-actions-footer hidden px-4 py-2 bg-rose-50/70 border-t border-rose-200 flex items-center justify-between">
<button type="button" onclick="addTaskInline('${widgetId}', this.previousElementSibling)" class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white px-3 py-1.5 rounded-xl text-xs font-semibold shadow-sm transition flex items-center gap-1"> <span class="text-xs text-rose-800 font-medium bulk-selected-counter">Выбрано: 0</span>
<i class="fa-solid fa-plus text-[10px]"></i> Добавить <button type="button" onclick="window.submitBulkDeleteSnapshots(this)"
class="px-3 py-1.5 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition shadow-sm">
<i class="fa-solid fa-trash-can"></i>
<span>Удалить выбранные</span>
</button> </button>
</div> </div>
</div> </div>
`; `;
} }
function filterTaskWidget(widgetId, status, btnEl) { // ⭐️ Обработчики чекбоксов
const state = activeWidgetTasksMap.get(widgetId); window.toggleSelectAllSnapshots = function(masterCb) {
if (!state) return; const root = masterCb.closest('.snapshots-widget-root');
state.filter = status; 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); window.updateSelectedSnapshots = function(itemCb) {
if (!widgetEl) return; const root = itemCb.closest('.snapshots-widget-root');
if (!root) return;
window.syncBulkDeleteFooter(root);
};
widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => { window.syncBulkDeleteFooter = function(root) {
b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700"; 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"); const getPrioBadge = (prio) => {
if (container) { const p = (prio || 'MEDIUM').toUpperCase();
container.innerHTML = renderTaskItemsHtml(state.tasks, status); if (p === 'HIGH' || p === 'CRITICAL') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-rose-100 text-rose-700 border border-rose-200">🔥 HIGH</span>';
} if (p === 'LOW') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-100 text-slate-600 border border-slate-200">☕ LOW</span>';
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-amber-100 text-amber-700 border border-amber-200">⚡ MEDIUM</span>';
};
const getStatusBadge = (status) => {
const s = (status || 'BACKLOG').toUpperCase();
if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '<span class="text-[10px] px-2 py-0.5 rounded font-bold bg-blue-50 text-blue-700 border border-blue-200">⚙️ В работе</span>';
if (s === 'COMPLETED' || s === 'DONE') return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">✓ Готово</span>';
return '<span class="text-[10px] px-2 py-0.5 rounded font-semibold bg-slate-50 text-slate-600 border border-slate-200">📋 В планах</span>';
};
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
? `<button type="button" onclick="window.sendChatAction('заверши задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-emerald-50 text-emerald-700 border border-emerald-300 hover:bg-emerald-100 transition-colors shadow-sm" title="Завершить задачу">✓ Готово</button>`
: (!isDone
? `<button type="button" onclick="window.sendChatAction('возьми в работу задачу ${id}')" class="px-2.5 py-1 text-xs font-semibold rounded bg-blue-50 text-blue-700 border border-blue-300 hover:bg-blue-100 transition-colors shadow-sm" title="Взять в работу">⚙️ В работу</button>`
: '');
return `
<div id="task-card-${id}" class="p-3 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-slate-300 transition-all flex flex-col gap-2">
<div class="task-view-mode flex items-center justify-between gap-3">
<div class="flex items-center gap-2 flex-wrap flex-1 min-w-0">
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-slate-100 text-slate-700 border border-slate-200">#${id}</span>
<span class="text-sm font-medium text-slate-900 truncate" title="${title}">${title}</span>
</div>
<div class="flex items-center gap-1.5 shrink-0">
${actionBtn}
<button type="button" onclick="window.openTaskInlineEditor(${id})" class="p-1 text-xs text-slate-500 hover:text-indigo-600 hover:bg-slate-50 rounded border border-slate-200 transition-colors" title="Редактировать">✏️</button>
<button type="button" onclick="window.sendChatAction('удали задачу ${id}')" class="p-1 text-xs text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded border border-slate-200 transition-colors" title="Удалить">🗑️</button>
</div>
</div>
<div class="task-view-mode flex items-center gap-2 text-xs text-slate-500 flex-wrap">
${getPrioBadge(t.priority)}
${getStatusBadge(t.status)}
<span class="px-1.5 py-0.5 rounded bg-slate-50 border border-slate-200 text-slate-600 font-mono text-[11px]">${t.module || 'general'}</span>
${t.due_date ? `<span class="text-slate-500">📅 срок: <b>${t.due_date}</b></span>` : ''}
${t.created_at ? `<span class="text-slate-400">создана: ${t.created_at.split(' ')[0]}</span>` : ''}
</div>
<!-- ФОРМА ИНЛАЙН РЕДАКТИРОВАНИЯ -->
<div id="task-editor-${id}" class="hidden flex flex-col gap-2 pt-2 border-t border-slate-100">
<input type="text" id="task-edit-title-${id}" value="${title.replace(/"/g, '&quot;')}" class="w-full text-xs px-2.5 py-1.5 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" placeholder="Описание задачи..." />
<div class="flex items-center gap-2 flex-wrap">
<input type="date" id="task-edit-date-${id}" value="${t.due_date || ''}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50" />
<select id="task-edit-prio-${id}" class="text-xs px-2 py-1 border border-slate-300 rounded focus:border-indigo-500 focus:outline-none bg-slate-50">
<option value="LOW" ${t.priority === 'LOW' ? 'selected' : ''}>☕ LOW</option>
<option value="MEDIUM" ${t.priority === 'MEDIUM' || !t.priority ? 'selected' : ''}>⚡ MEDIUM</option>
<option value="HIGH" ${t.priority === 'HIGH' ? 'selected' : ''}>🔥 HIGH</option>
<option value="CRITICAL" ${t.priority === 'CRITICAL' ? 'selected' : ''}>🚨 CRITICAL</option>
</select>
<button type="button" onclick="window.saveTaskInlineEdit(${id})" class="px-2.5 py-1 text-xs font-semibold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Сохранить</button>
<button type="button" onclick="window.closeTaskInlineEditor(${id})" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-100 transition-colors">Отмена</button>
</div>
</div>
</div>
`;
}).join('');
const emptyState = `
<div class="p-8 text-center text-slate-400">
<div class="text-3xl mb-2">📭</div>
<div class="text-sm font-medium">Нет задач в категории «${currentFilter}»</div>
</div>
`;
return `
<div class="task-widget-root w-full max-w-4xl mx-auto my-2 bg-slate-50 border border-slate-300 rounded-xl shadow-md overflow-hidden flex flex-col">
<div class="px-4 py-3 bg-white border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-1.5 flex-wrap">
<button type="button" onclick="window.switchTaskFilter('IN_PROGRESS', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'IN_PROGRESS' ? 'bg-blue-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
⚙️ В работе (${counts.IN_PROGRESS})
</button>
<button type="button" onclick="window.switchTaskFilter('PLANNED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'PLANNED' ? 'bg-indigo-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
📋 В планах (${counts.PLANNED})
</button>
<button type="button" onclick="window.switchTaskFilter('COMPLETED', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'COMPLETED' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
✓ Готово (${counts.COMPLETED})
</button>
<button type="button" onclick="window.switchTaskFilter('ALL', this)" class="px-2.5 py-1 text-xs font-bold rounded-md transition-colors ${currentFilter === 'ALL' ? 'bg-slate-800 text-white shadow-sm' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}">
Все (${counts.ALL})
</button>
</div>
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-3 py-1.5 text-xs font-bold rounded-md bg-indigo-600 hover:bg-indigo-700 text-white transition-all shadow-sm flex items-center gap-1">
➕ Добавить задачу
</button>
</div>
<div class="new-task-creation-form hidden p-3 bg-indigo-50/70 border-b border-indigo-100 flex flex-col gap-2">
<div class="text-xs font-bold text-indigo-900">Новая задача:</div>
<input type="text" class="new-task-title w-full text-xs px-2.5 py-1.5 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" placeholder="Что необходимо сделать?..." />
<div class="flex items-center gap-2 flex-wrap">
<input type="date" class="new-task-date text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white" />
<select class="new-task-prio text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
<option value="LOW">☕ LOW (Низкий)</option>
<option value="MEDIUM" selected>⚡ MEDIUM (Средний)</option>
<option value="HIGH">🔥 HIGH (Высокий)</option>
<option value="CRITICAL">🚨 CRITICAL (Критический)</option>
</select>
<select class="new-task-status text-xs px-2 py-1 border border-indigo-200 rounded focus:border-indigo-500 focus:outline-none bg-white">
<option value="BACKLOG" selected>📋 В планы (Бэклог)</option>
<option value="IN_PROGRESS">⚙️ Сразу в работу</option>
</select>
<button type="button" onclick="window.submitCreateTask(this)" class="px-3 py-1 text-xs font-bold rounded bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm">Создать</button>
<button type="button" onclick="window.toggleCreateTaskForm(this)" class="px-2 py-1 text-xs font-medium rounded text-slate-500 hover:bg-slate-200 transition-colors">Отмена</button>
</div>
</div>
<div class="p-3 overflow-y-auto max-h-[70vh] flex flex-col gap-2 task-rows-container">
${filteredTasks.length > 0 ? taskRows : emptyState}
</div>
</div>
`;
} }
async function toggleTaskStatusInline(taskId, isChecked) { window.switchTaskFilter = function(filterName, btnEl) {
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); window.activeTaskFilter = filterName;
const newStatus = isChecked ? "COMPLETED" : "BACKLOG"; 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 { try {
const res = await fetch(`/api/v1/tasks/${taskId}`, { const res = await fetch('/api/v1/tasks', {
method: "PATCH", method: 'POST',
headers: { headers: getAuthHeaders(),
"Content-Type": "application/json", body: JSON.stringify({
"Authorization": "Bearer " + token title: titleInput.value.trim(),
}, due_date: dateInput ? (dateInput.value || null) : null,
body: JSON.stringify({ status: newStatus }) priority: prioInput ? prioInput.value : 'MEDIUM',
status: statusInput ? statusInput.value : 'BACKLOG'
})
}); });
if (res.ok) { if (res.ok) {
for (let [wId, state] of activeWidgetTasksMap.entries()) { window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED';
const targetTask = state.tasks.find(t => t.task_id === taskId); window.sendChatAction('покажи задачи');
if (targetTask) { } else {
targetTask.status = newStatus; const err = await res.json();
const widgetEl = document.getElementById(wId); alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка'));
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
}
}
}
if (typeof loadTasks === "function") loadTasks();
} }
} catch (err) { } catch (e) {
console.error("[Task Patch Error]", err); console.error('Ошибка создания задачи:', e);
alert('Сетевая ошибка при создании задачи');
} }
} };
async function deleteTaskInline(taskId) { window.openTaskInlineEditor = function(id) {
if (!confirm(`Удалить задачу ${taskId}?`)) return; const card = document.getElementById(`task-card-${id}`);
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); 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 { try {
const res = await fetch(`/api/v1/tasks/${taskId}`, { const res = await fetch(`/api/v1/tasks/${id}`, {
method: "DELETE", method: 'PATCH',
headers: { "Authorization": "Bearer " + token } headers: getAuthHeaders(),
body: JSON.stringify({
title: titleInput.value.trim(),
due_date: dateInput ? (dateInput.value || null) : null,
priority: prioInput ? prioInput.value : 'MEDIUM'
})
}); });
if (res.ok) { if (res.ok) {
for (let [wId, state] of activeWidgetTasksMap.entries()) { window.sendChatAction('покажи задачи');
state.tasks = state.tasks.filter(t => t.task_id !== taskId); } else {
const widgetEl = document.getElementById(wId); const err = await res.json();
const container = widgetEl?.querySelector(".widget-items-container"); alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка'));
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
}
}
if (typeof loadTasks === "function") loadTasks();
} }
} catch (err) { } catch (e) {
console.error("[Task Delete Error]", err); 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);
}
}
+1762 -1237
View File
File diff suppressed because it is too large Load Diff