feat(web): стабилизация UI, Gemini-скроллинг, роутеры контекста/снапшотов и актуализация роадмапа

This commit is contained in:
2026-09-07 17:29:21 +03:00
parent 1bb95cd8e1
commit d391a08224
33 changed files with 7768 additions and 1832 deletions
+5
View File
@@ -27,3 +27,8 @@ data/1c/*
!data/scud/.gitkeep !data/scud/.gitkeep
!data/1c/.gitkeep !data/1c/.gitkeep
!data/static_reason_workers.csv !data/static_reason_workers.csv
*.db-shm
*.db-wal
data/scud_orion_ai.db
data/uploads/
+1 -1
View File
@@ -24,7 +24,7 @@ else:
DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y") DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y")
OLLAMA_URL = "http://10.121.17.227:11434/api/generate" OLLAMA_URL = "http://10.121.17.227:11434/api/generate"
OLLAMA_MODEL = "qwen2.5:14b-instruct-q8_0" OLLAMA_MODEL = "qwen2.5:14b"
MODEL_NAME = OLLAMA_MODEL MODEL_NAME = OLLAMA_MODEL
KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json") KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json")
+21 -8
View File
@@ -47,6 +47,8 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# 1. Проверяем, существует ли уже срез с точно таким же временем и датой
if date_str: if date_str:
cursor.execute( cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1", "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
@@ -62,19 +64,30 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste
if row and row[0]: if row and row[0]:
return row[0] return row[0]
# 2. Извлекаем ВСЕ существующие ID за текущие календарные сутки
cursor.execute(""" cursor.execute("""
SELECT snapshot_id FROM scud_logs SELECT DISTINCT snapshot_id
FROM scud_logs
WHERE snapshot_id LIKE ? OR snapshot_id LIKE ? WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
ORDER BY snapshot_id DESC LIMIT 1
""", (f"{date_prefix}-%", f"Y{date_prefix}-%")) """, (f"{date_prefix}-%", f"Y{date_prefix}-%"))
last_row = cursor.fetchone() rows = cursor.fetchall()
next_seq = 1 max_seq = 0
if last_row and last_row[0]:
parts = last_row[0].replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit():
next_seq = int(parts[1]) + 1
for (s_id,) in rows:
if not s_id:
continue
try:
# Извлекаем число после последнего дефиса
parts = str(s_id).split('-')
if len(parts) >= 2 and parts[-1].isdigit():
num = int(parts[-1])
if num > max_seq:
max_seq = num
except Exception:
continue
next_seq = max_seq + 1
return f"{prefix}{date_prefix}-{next_seq:03d}" return f"{prefix}{date_prefix}-{next_seq:03d}"
+14 -16
View File
@@ -1,16 +1,14 @@
fio,reason,note fio,department,reason,date_from,date_to
Королёва Наталья Александровна,Удаленная работа,Постоянная удаленка Королёва Наталья Александровна,Все,Удаленная работа,,
Николаева Ирина Леонидовна,Удаленная работа,Постоянная удаленка Николаева Ирина Леонидовна,Все,Удаленная работа,,
Познякова Татьяна Сергеевна,Удаленная работа,Постоянная удаленка Познякова Татьяна Сергеевна,Все,Удаленная работа,,
Софьин Никита Сергеевич,Удаленная работа,Постоянная удаленка Софьин Никита Сергеевич,Все,Удаленная работа,,
Чуб Александр Васильевич,Удаленная работа,Постоянная удаленка Чуб Александр Васильевич,Все,Удаленная работа,,
Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка Шуличенко Иван Иванович,Все,Удаленная работа,,
Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка Пухаренко Юрий Владимирович,Все,Удаленная работа,,
Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка Пшеничный Виктор Петрович,Все,Удаленная работа,,
Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка Незнанова Валерия Игоревна,Все,Удаленная работа,,
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка Ковалев Владимир Владимирович,Все,Удаленная работа,,
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка Кожокарь Татьяна Юрьевна,Все,Удаленная работа,,
Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка Субетто Юлия Викторовна,Все,Удаленная работа,,
Чуркина Елена Геннадьевна,Удаленная работа,Постоянная удаленка Чуркина Елена Геннадьевна,Все,Удаленная работа,,
Пушков Александр Александрович,Удаленная работа,Временная удаленка с 26.08.2026 по 04.09.2026
Сысоев Алексей Валерьевич,Удаленная работа,Временная удаленка с 25.08.2026 по 27.08.2026
1 fio department note reason date_from date_to
2 Королёва Наталья Александровна Все Постоянная удаленка Удаленная работа
3 Николаева Ирина Леонидовна Все Постоянная удаленка Удаленная работа
4 Познякова Татьяна Сергеевна Все Постоянная удаленка Удаленная работа
5 Софьин Никита Сергеевич Все Постоянная удаленка Удаленная работа
6 Чуб Александр Васильевич Все Постоянная удаленка Удаленная работа
7 Шуличенко Иван Иванович Все Постоянная удаленка Удаленная работа
8 Пухаренко Юрий Владимирович Все Постоянная удаленка Удаленная работа
9 Пшеничный Виктор Петрович Все Постоянная удаленка Удаленная работа
10 Незнанова Валерия Игоревна Все Постоянная удаленка Удаленная работа
11 Ковалев Владимир Владимирович Все Постоянная удаленка Удаленная работа
12 Кожокарь Татьяна Юрьевна Все Постоянная удаленка Удаленная работа
13 Субетто Юлия Викторовна Все Постоянная удаленка Удаленная работа
14 Чуркина Елена Геннадьевна Все Постоянная удаленка Удаленная работа
Пушков Александр Александрович Временная удаленка с 26.08.2026 по 04.09.2026 Удаленная работа
Сысоев Алексей Валерьевич Временная удаленка с 25.08.2026 по 27.08.2026 Удаленная работа
+60 -2
View File
@@ -1,6 +1,64 @@
# 📋 История изменений (CHANGELOG) # Changelog
Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле. Все важные изменения проекта документируются в этом файле.
## [Unreleased] - 2026-09-07
### Добавлено
- Механизм Gemini-скроллинга диалогового окна: функция `scrollToUserMessageTop` в `core.js` автоматически поднимает отправленный вопрос к верхнему срезу экрана, скрывая громоздкие предыдущие ответы.
- CSS-отступ `padding-bottom: 80vh` у `#chat-messages-container`, гарантирующий свободное вертикальное пространство для прокрутки без сдвига строки ввода.
- Пункт 2.9 системного промпта, жестко обязывающий модель вызывать нативный инструмент `db_get_rules` при запросах Базы Знаний и регламентов вместо текстовой имитации.
### Запланировано
- Рефакторинг интерпретации аппаратных кодов турникетов PERCO в `services/scud_export.py`: разделение логики разрешения прохода (`Event 28`) и факта проворота створки (`Event 32`), приоритет направления по `log.Mode` (1 — вход, 2 — выход) для устранения фантомных выходов.
- Создание DDL-схемы и таблицы `scud_events_raw` в SQLite для гранулярного хранения всех внутридневных перемещений сотрудников (база для учета перекуров и инструмента «Кто в здании»).
## [0.9.5] - 2026-08-28
### Добавлено
- Механизм отказоустойчивого сохранения отчетов `safe_close_workbook` в `services/excel_exporter.py`: при блокировке файла открытым процессом Excel создается резервный файл с таймстемпом вида `..._timestamp.xlsx`.
### Изменено
- Экспорт всех Excel-документов (`services/scud_export.py`, `services/excel_exporter.py`) полностью переведен с `openpyxl` на `xlsxwriter`.
- Скорректирована верстка детального отчета: столбец «Подразделение» выровнен строго по центру, колонка «ФИО» расширена на 10% (ширина 33), а избыточная ширина столбцов «Первая активность» и «Отклонение от нормы» уменьшена до 11 пунктов для корректного размещения на экране без горизонтальной прокрутки.
- В модуле сетевой синхронизации `services/share_copier.py` вызов `shutil.copy2` заменен на `shutil.copyfile`, что устранило ошибки метаданных `[Errno 1] Operation not permitted` при монтировании SMB-шары.
### Исправлено
- Устранена системная ошибка структуры OpenXML (`errorXXXXXX_01.xml`, «Обнаружена ошибка в части содержимого книги...»), возникавшая при открытии сгенерированных отчетов в MS Excel.
- Восстановлены сворачиваемые интерактивные категории с кнопками `[+]` / `[-]` в файле ежедневной сводки (`outline_settings(symbols_below=False)`).
## [Unreleased] - 2026-08-28
### Fixed
- **Генерация Excel-книг (`services/excel_exporter.py`, `services/scud_export.py`):** полный перевод экспорта с `openpyxl` на `xlsxwriter`, что устранило хроническую ошибку структуры OpenXML («В книге обнаружено нечитаемое содержимое...»).
- **Группировки в Сводке:** восстановлено корректное отображение интерактивных кнопок `[+]`/`[-]` и сворачивание категорий по умолчанию (`outline_settings`, `level=1`, `collapsed=True`).
- **Верстка и пропорции Детального отчета:** центрирование столбца «Подразделение», калибровка ширины колонок «ФИО» (+10%), «Первая активность» и «Отклонение от нормы» для плотного размещения таблицы на одном экране без горизонтальной прокрутки.
- **Безопасное сохранение при блокировках:** внедрена функция `safe_close_workbook` для фонового сохранения резервных копий с timestamp, если файл открыт пользователем в Excel.
- **Сетевое копирование 1С (`services/share_copier.py`):** замена `shutil.copy2` на `shutil.copyfile` для устранения сбоя `[Errno 1] Operation not permitted` при работе с SMB/CIFS-шарой.
## [Unreleased] - 2026-08-28
### Fixed
- **Инкремент Snapshot ID (`core/repositories/scud_repo.py`):** заменена строковая сортировка `ORDER BY snapshot_id DESC` на целочисленный парсинг суффиксов, что устранило залипание номеров снапшотов (`-002`).
### Added
- **Режим тихих почасовых срезов (`--export-only`):** быстрый экспорт данных СКУД напрямую в SQLite без построения отчетов для интеграции в cron.
- **Интерактивный справочник CLI (`main_etl.py`):** полный баннер справки по всем аргументам (`-h`, `--help`, `help`).
- **Автоматическая ротация промежуточных срезов (`services/snapshots/retention.py`):** ночная очистка устаревших почасовых срезов старше 2 дней с сохранением полуденного (`13:00`) и итогового `Y` (`23:59:59`).
## [Unreleased] - 2026-08-28
### Added
- **Smart Snap-to-Grid Finder (`services/snapshots/finder.py`):** интеллектуальный поиск ближайшего среза СКУД в SQLite с допуском ±20 минут и поддержкой On-Demand экспорта из MS SQL при отсутствии готового среза.
- **Параметр `--time` в `main_etl.py`:** возможность формирования оперативной сводки на произвольное время суток.
- **Раздельные генераторы отчетов:**
- `services/scud_etl/svodka_generator.py` — оперативная сводка за сегодня с группировками.
- `services/scud_etl/otchet_generator.py` — суточный детальный отчет за вчера по финишному срезу `Y` (23:59:59) с учетом рабочего норматива 8.5 ч.
### Changed
- **Фиксация финишного среза `Y` на `23:59:59`:** обновлено время снапшота в `services/scud_export.py`, `core/repositories/scud_repo.py` и `services/scud_etl/pipeline.py` с сохранением обратной совместимости со срезами на `22:00:00`.
- **Путь виртуального окружения в `run_cron_etl.sh`:** скорректирован на актуальный `/home/puh/projects/scud_ai/venv/bin/python`.
- **Игнорирование временных файлов SQLite:** добавлены `*.db-shm` и `*.db-wal` в `.gitignore`.
## [3.2.0] — 2026-08-27 ## [3.2.0] — 2026-08-27
### 🧠 Интеллектуальный арбитраж личностей, агрегация мульти-пропусков и автозакрытие смен ### 🧠 Интеллектуальный арбитраж личностей, агрегация мульти-пропусков и автозакрытие смен
+95 -66
View File
@@ -1,76 +1,77 @@
# План реализации (ROADMAP) # План реализации (ROADMAP)
## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]` ## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]`
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 3]. - [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов.
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 3]. - [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`).
--- ---
## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]` ## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]`
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 3]. - [x] Передать управление диалогом языковой модели через системный блок `role: "system"`.
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: - [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию:
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 3]. - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты.
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 3]. - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение.
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 3]. - [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью.
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 3]. - [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`.
- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 3]. - [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов.
--- ---
## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]` ## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]`
- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 3]. - [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**.
- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 3]. - [x] Сбрасывать состояние сессии в базе данных после фиксации решения.
- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 3]. - [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`.
- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 3]. - [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста.
- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 3]. - [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории.
--- ---
## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]` ## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]`
- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 3]. - [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**.
- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 3]. - [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением.
- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 3]. - [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`).
- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 3]. - [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика.
--- ---
## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]` ## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]`
- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 3]. - [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`).
- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 3]. - [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail).
- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 3]. - [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`).
- [x] **Строгий вызов Базы Знаний:** внедрено правило 2.9 в системный промпт для пресечения текстовой имитации вызова `db_get_rules`.
--- ---
## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]` ## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]`
- [x] **Масштабирование UI задач:** - [x] **Масштабирование UI задач:**
- Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 3]. - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`.
- Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 3]. - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все».
- Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 3]. - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих).
- [x] **Инлайн-редактирование карточки задачи:** - [x] **Инлайн-редактирование карточки задачи:**
- Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 3]. - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности).
- Отображение даты создания задачи[cite: 3]. - Отображение даты создания задачи.
- Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 3]. - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`.
- [x] **Детерминированный Fast-Path и двухфазное удаление:** - [x] **Детерминированный Fast-Path и двухфазное удаление:**
- Мгновенная смена статусов без задержек LLM[cite: 3]. - Мгновенная смена статусов без задержек LLM.
- Карточка подтверждения удаления с автоочисткой контекста[cite: 3]. - Карточка подтверждения удаления с автоочисткой контекста.
- [x] **Генерация отчетов задач в Markdown:** - [x] **Генерация отчетов задач в Markdown:**
- Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3]. - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`.
- Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3]. - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`).
- [x] **Доменная консолидация задач:** - [x] **Доменная консолидация задач:**
- Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 3]. - Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`.
--- ---
## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]` ## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]`
- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 3]. - [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00.
- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 3]. - [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат.
- [x] **Интерактивный UI с чекбоксами и защитой срезов:** - [x] **Интерактивный UI с чекбоксами и защитой срезов:**
- Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3]. - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке.
- Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3]. - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора).
- Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 3]. - Динамическая кнопка «Удалить выбранные (N)» в подвале карточки.
- [x] **Детерминированный Fast-Path удаления срезов:** - [x] **Детерминированный Fast-Path удаления срезов:**
- Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 3]. - Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов.
- Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления[cite: 3]. - Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления.
--- ---
@@ -109,39 +110,67 @@
--- ---
## 10. Разграничение Сводки и Отчета + Почасовые срезы и Y-23:59:59 `[В РАБОТЕ]` ## 10. Разделение генераторов и почасовые срезы `[ЗАВЕРШЕНО]`
- [ ] **Корректировка финишного среза Y на 23:59:59:** - [x] Разделение логики генерации на `svodka_generator.py` и `otchet_generator.py`.
- [ ] Обновить время вечернего среза с `22:00:00` на `23:59:59` в `services/scud_export.py`, `scud_repo.py` и `pipeline.py`. - [x] Перевод времени суточного среза `Y` на `23:59:59`.
- [ ] **Разделение генераторов отчетов:** - [x] Интеллектуальный поиск срезов `services/snapshots/finder.py` (Snap-to-Grid ±20 мин).
- [ ] Создать `services/scud_etl/svodka_generator.py` (оперативная сводка, последний доступный срез или выбранный дневной). - [x] Флаг `--time` и интерактивный help в `main_etl.py`.
- [ ] Создать `services/scud_etl/otchet_generator.py` (детальный отчет за прошлую смену, строго по единственному итоговому `Y`-снапшоту за 23:59:59). - [x] Флаг `--export-only` для почасового крона.
- [ ] Исключить устаревший `report_generator.py`. - [x] Политика ночной ротации промежуточных срезов `services/snapshots/retention.py`.
- [ ] **Параметризация `main_etl.py`:** - [x] Исправление целочисленного инкремента `snapshot_id`.
- [ ] Добавить поддержку аргументов `--date ДД.ММ.ГГГГ` и `--snapshot ID` для свободного запуска расчета за любой прошлый день.
- [ ] **Политика почасовых срезов (Hourly Snapshots) и retention-очистка:**
- [ ] Реализовать фоновое снятие часовых срезов в течение рабочего дня.
- [ ] Настроить ночную очистку промежуточных дневных срезов с сохранением полуденного (13:00) и финишного (`Y` 23:59:59).
- [ ] **Толерантный поиск срезов (Smart Snap-to-Grid, ±15–30 мин):**
- [ ] Поиск готового снапшота в SQLite при запросе оператора с возможностью мгновенного построения сводки без лишних запросов к MS SQL Орион.
- [ ] Формирование On-Demand запроса в MS SQL Орион при отсутствии подходящего среза.
--- ---
## 11. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]` ## 11. Стабилизация генератора отчетов и верстки `[ЗАВЕРШЕНО]`
- [ ] **Двухконтурный запуск ИИ:** - [x] **Миграция на `XlsxWriter`:** полный перевод экспорта книг (`scud_export.py`, `excel_exporter.py`) на чистый генератор, устранивший ошибки OpenXML и окна восстановления при открытии.
- [ ] Перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`. - [x] **Интерактивные группировки в Сводке:** восстановление сворачивания категорий по умолчанию (`level=1`, `collapsed=True`) и кнопок управления уровнями `[+]`/`[-]` (`outline_settings(symbols_below=False)`).
- [x] **Компактная экранная верстка Детального отчета:** центрирование столбца «Подразделение», расширение столбца «ФИО» (+10%, ширина 33), оптимизация ширины колонок «Первая активность» (11) и «Отклонение от нормы» (11) для отображения без горизонтальной прокрутки.
- [x] **Защита от блокировок занятых файлов (Fallback Timestamp):** механизм перехвата `FileCreateError`/`OSError` в `safe_close_workbook` с сохранением копии при открытом в Excel файле.
- [x] **Безопасная синхронизация 1С с сетевой шары:** переход на `shutil.copyfile` в `share_copier.py` для устранения сбоев прав доступа (`Operation not permitted`) на SMB/CIFS-ресурсах.
---
## 12. UI/UX веб-интерфейса и скроллинг ленты сообщений `[ЗАВЕРШЕНО]`
- [x] **Паттерн Gemini-скролла:** реализация функции `scrollToUserMessageTop` в `core.js`, позиционирующей свежий вопрос пользователя строго по верхней кромке видимой области.
- [x] **Полное скрытие истории:** предыдущие объемные ответы модели гарантированно вытесняются за верхний край экрана.
- [x] **Стабилизация панели ввода:** настройка `padding-bottom: 80vh` для `#chat-messages-container` в `index.html`, обеспечивающая необходимый запас высоты прокрутки без сдвига строки ввода текста.
---
## 13. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]`
- [ ] **Двухконтурный вызов ИИ:** перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`.
- [ ] **Якорный табельный номер (TabNo Matching):** - [ ] **Якорный табельный номер (TabNo Matching):**
- [ ] Извлечение `TabNo` из MS SQL Орион (`pList.TabNo`) и MS SQL 1С:ЗУП. - [ ] Извлечение `TabNo` из MS SQL СКУД Орион и 1С:ЗУП.
- [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite (`scud_logs`, `zup_staff`, `person_identity_mapping`). - [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite.
- [ ] Защита от смены фамилий и опечаток через неизменяемый табельный номер. - [ ] Защита от смены фамилий и опечаток через инвариант табельного номера.
- [ ] **Динамическая инвалидация кэша связок:** - [ ] **4-уровневая система предохранителей (Guardrails):**
- [ ] Автоматическая отбраковка записей кэша при увольнении сотрудника или несовпадении подразделения. - Уровень 1: Точный матч ФИО (100%).
- Уровень 2: Матч по табельному номеру при расхождении ФИО.
- Уровень 3: Валидация кэша `person_identity_mapping` с проверкой актуальности статуса в 1С.
- Уровень 4: ИИ-арбитраж с записью верифицированной связки в кэш.
--- ---
## 12. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]` ## 14. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]`
- [ ] **Docker/gVisor контур:** - [ ] **Docker/gVisor контур:**
- Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 3]. - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups).
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 3]. - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only.
- [ ] **Динамические Python/Pandas вычисления:** - [ ] **Динамические Python/Pandas вычисления:**
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 3]. - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету.
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 3]. - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата.
---
## 15. Архитектура обработки событий турникетов и детализация проходов (Сырые логи СКУД) `[В РАБОТЕ]`
- [ ] **Итерация 1: Устранение фантомных выходов в боевом SQL (`scud_export.py`):**
- [ ] Перестроить блок `CASE WHEN` определения `Direction`: приоритет отдавать физическому направлению контроллера (`log.Mode = 1` -> `'IN'`, `log.Mode = 2` -> `'OUT'`).
- [ ] Исключить универсальные коды событий `28` (разблокировка створки) и `32` (проворот турникета) из жесткого перечня `'IN'`, так как они зеркальны для обоих направлений.
- [ ] Закрепить расчет первого входа `FirstIn` и последнего выхода `FilteredLastOut` (строго `OUT` позже `FirstIn`).
- [ ] **Итерация 2: DDL-схема и таблица сырых событий (`core/schema.py`):**
- [ ] Создать таблицу `scud_events_raw` (поля: `log_date`, `time_val`, `hoz_organ`, `fio_clean`, `event_code`, `mode`, `direction`, `created_at`).
- [ ] Настроить индексы `idx_raw_date_fio` и `idx_raw_mode` для быстрых выборок.
- [ ] **Итерация 3: Конвейер выгрузки сырых событий (`services/scud_export.py`):**
- [ ] Добавить сохранение всех фактов физических проходов (`Event = 32` / `Event = 28`) в `scud_events_raw` параллельно с формированием агрегированного снапшота в `scud_logs`.
- [ ] **Итерация 4: Модуль оперативного присутствия и внутридневного учета:**
- [ ] Реализовать инструмент «Кто прямо сейчас в здании» по последнему зафиксированному `Mode` сотрудника за текущие сутки.
- [ ] Подготовить расчет реального суммарного времени нахождения на рабочем месте с учетом перекуров, обедов и выходов за периметр.
+997 -588
View File
File diff suppressed because it is too large Load Diff
+62 -6
View File
@@ -19,6 +19,7 @@ from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.scud_etl.svodka_generator import generate_svodka_service from services.scud_etl.svodka_generator import generate_svodka_service
from services.scud_etl.otchet_generator import generate_otchet_service from services.scud_etl.otchet_generator import generate_otchet_service
from services.text_reporter import generate_markdown_report from services.text_reporter import generate_markdown_report
from services.snapshots.retention import cleanup_old_intermediate_snapshots
from services.scud_export import run_export from services.scud_export import run_export
from services.share_copier import copy_1c_files_from_share from services.share_copier import copy_1c_files_from_share
@@ -27,19 +28,69 @@ from services.excel_exporter import export_raw_scud
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s") logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
def print_help():
print("""
===============================================================================
🛠️ SCUD ORION AI — СИСТЕМА КОНТРОЛЛИНГА И СВОДНЫХ ОТЧЕТОВ
===============================================================================
Использование:
python main_etl.py [ОПЦИИ]
Доступные аргументы:
-h, --help, help Показать эту справку и выйти
--date ДД.ММ.ГГГГ Дата расчета (по умолчанию: текущий рабочий день)
--time ЧЧ:ММ Время среза для сводки (например: 14:30)
Ищет ближайший срез (±20 мин) или запрашивает On-Demand экспорт
--snapshot ID Точный ID снапшота для расчета (например: 20260827-002)
--skip-export Пропустить выгрузку СКУД из MS SQL (работать только с SQLite)
--export-only ТОЛЬКО сделать экспорт/снапшот СКУД в БД без построения отчетов
-d, --debug Включить режим расширенной отладки
Примеры использования:
python main_etl.py
👉 Полный суточный цикл: экспорт -> отчет за вчера -> сводка за сегодня -> ИИ.
python main_etl.py --export-only
👉 Почасовой тихий срез в БД (для cron) без генерации отчетов.
python main_etl.py --date 27.08.2026 --time 12:15 --skip-export
👉 Построить сводку за 27.08 на 12:15 без запроса к внешнему MS SQL.
python main_etl.py --snapshot 20260827-001 --skip-export
👉 Расчет отчетов строго по выбранному снапшоту из SQLite.
===============================================================================
""")
def main(): def main():
parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С") if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки") print_help()
parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL") sys.exit(0)
parser.add_argument("--date", type=str, default=None, help="Дата расчета в формате ДД.ММ.ГГГГ")
parser.add_argument("--time", type=str, default=None, help="Время среза для сводки (например, 14:30)") parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--snapshot", type=str, default=None, help="Точный ID снапшота для расчета") parser.add_argument("-h", "--help", action="store_true")
parser.add_argument("-d", "--debug", action="store_true")
parser.add_argument("--skip-export", action="store_true")
parser.add_argument("--export-only", action="store_true")
parser.add_argument("--date", type=str, default=None)
parser.add_argument("--time", type=str, default=None)
parser.add_argument("--snapshot", type=str, default=None)
args = parser.parse_args() args = parser.parse_args()
if args.help:
print_help()
sys.exit(0)
print("=" * 60) print("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}") print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}")
print("=" * 60) print("=" * 60)
# Автоматическая ротация архивных почасовых срезов
if not args.skip_export and not args.snapshot:
deleted_count = cleanup_old_intermediate_snapshots(days_to_keep_all=2)
if deleted_count > 0:
print(f"[🧹] Ротация БД: очищено {deleted_count} строк промежуточных архивных срезов.")
now = datetime.now() now = datetime.now()
if args.date: if args.date:
today_str = args.date.replace('_', '.') today_str = args.date.replace('_', '.')
@@ -60,6 +111,11 @@ def main():
else: else:
print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...") print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
# Если запрошен режим тихого почасового среза — выходим без тяжелых генераций
if args.export_only:
print(f"\n[✓] Режим --export-only: срез зафиксирован в SQLite. Генерация отчетов пропущена.")
sys.exit(0)
# [Этап 0.5] Синхронизация файлов с шары 1С # [Этап 0.5] Синхронизация файлов с шары 1С
if not args.snapshot and not args.skip_export: if not args.snapshot and not args.skip_export:
print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...") print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...")
+77 -195
View File
@@ -3,19 +3,11 @@
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: Нативный оркестратор диалога, диспетчер инструментов (Function Calling) ROLE: Нативный оркестратор диалога, диспетчер Function Calling,
и управление сессионными стейтами через ContextManager. передача активных срезов в контекст модели и терминальные вызовы.
AI-CONTEXT-ANCHORS:
- ANCHOR[AGENT_IMPORTS]: Системные и доменные импорты.
- ANCHOR[AGENT_MAIN_PIPELINE]: Основная точка входа process_chat_message.
- ANCHOR[AGENT_SYSTEM_PROMPT]: Формирование динамического контекста.
- ANCHOR[AGENT_TOOL_DISPATCHER]: Исполнение нативных вызовов инструментов.
- ANCHOR[AGENT_TOPIC_DRIFT]: Защита контекста и обработка свободных тем.
=============================================================================== ===============================================================================
""" """
# ANCHOR[AGENT_IMPORTS]
import sys import sys
import json import json
import logging import logging
@@ -27,7 +19,7 @@ from .db_tools import (
db_apply_prompt_node_action, db_apply_prompt_node_action,
db_get_tool_action, db_get_tool_action,
db_get_tasks, db_get_tasks,
db_update_task_status, db_update_task_details,
db_delete_task, db_delete_task,
db_add_task, db_add_task,
db_tasks_edit, db_tasks_edit,
@@ -70,7 +62,6 @@ if not logger.handlers:
logger.addHandler(handler) logger.addHandler(handler)
# ANCHOR[AGENT_MAIN_PIPELINE]
def process_chat_message( def process_chat_message(
user_id: int, user_id: int,
user_message: str, user_message: str,
@@ -90,7 +81,7 @@ def process_chat_message(
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
# 1. Быстрый перехват строго системных кнопок UI (подтверждение превью промпта) # 1. Быстрый перехват системных кнопок UI и терминальных действий без задержек LLM
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
@@ -99,7 +90,6 @@ def process_chat_message(
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=0)
db_history = db_get_chat_history(session_id, limit=20) db_history = db_get_chat_history(session_id, limit=20)
# ANCHOR[AGENT_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 "Гость"
@@ -111,52 +101,50 @@ 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"
)
elif current_state_type == "PROMPT_FOLLOWUP":
active_state_context = (
"\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n"
"- Оператор просматривает или редактирует системный промпт.\n"
"- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n"
"- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n"
"- Запрещено путать ADD и DELETE.\n"
)
elif current_state_type == "SNAPSHOTS_VIEW": elif current_state_type == "SNAPSHOTS_VIEW":
active_date = state_data.get("query_date", "выбранную дату") active_date = state_data.get("query_date", "выбранную дату")
active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n"
elif current_state_type == "SNAPSHOT_INSPECT":
snap_id = state_data.get("snapshot_id", "")
snap_date = state_data.get("log_date", "")
records = state_data.get("records", [])
lines = []
for r in records:
st = "Присутствовал" if r.get("is_present") else "Отсутствовал"
lines.append(f"- {r.get('fio')}: Отдел={r.get('department')}, Вход={r.get('time_in')}, Активность={r.get('first_activity')}, Выход={r.get('time_out')}, ВремяВЗдании={r.get('in_building')}, Статус={st}")
dump_str = "\n".join(lines)
active_state_context = ( active_state_context = (
f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n" f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n"
f"- Сейчас на экране отображаются снапшоты за {active_date}.\n" f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, их времени входа/выхода, отделах или присутствии — "
f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n" f"ТЫ ОБЯЗАН брать данные исключительно из этого списка активного среза:\n{dump_str}\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"
) )
# ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам
system_prompt_content = ( system_prompt_content = (
f"Ты — интеллектуальный ассистент SCUD Orion AI.\n" f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
f"Ты управляешь задачами, аналитикой СКУД и системными настройками исключительно через инструменты (tools).\n\n" f"Твоя основная роль — помощь оператору в кадровом аудите СКУД/1С, управлении задачами и настройками системы.\n\n"
f"СТРОГИЕ ПРАВИЛА:\n" f"СТРОГИЕ ПРАВИЛА:\n"
f"1. К оператору всегда обращайся на Вы.\n" f"1. К оператору всегда обращайся на Вы.\n"
f"2. Для получения данных всегда вызывай соответствующий инструмент:\n" f"2. Для работы с данными системы ВСЕГДА вызывай соответствующие инструменты (tools):\n"
f" - Снапшоты и срезы логов СКУД за любые даты и дни недели -> db_get_snapshots\n" f" - Снапшоты и срезы СКУД -> db_get_snapshots\n"
f" - Удаление снапшотов -> db_delete_snapshots\n" f" - Удаление срезов -> db_delete_snapshots\n"
f" - Задачи и бэклог (просмотр, создание, смена статуса, удаление, экспорт) -> db_get_tasks, db_tasks_edit\n" f" - Задачи и бэклог -> db_get_tasks, db_tasks_edit\n"
f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n" f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n"
f" - База знаний и регламенты -> db_get_rules\n"
f" - Аномалии СКУД/1С -> db_get_anomalies\n" f" - Аномалии СКУД/1С -> db_get_anomalies\n"
f" - База знаний -> db_get_rules\n"
f" - Статистика БД -> db_get_stats\n"
f" - Справка -> db_get_reference\n" f" - Справка -> db_get_reference\n"
f"3. Запрещено сочинять данные от себя без вызова инструментов.\n\n" f"3. ЗАДАЧИ:\n"
f" - При любых запросах на просмотр задач (включая опечатки вроде 'змдачи', 'таски', 'дела', 'покажи задачи') — "
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_tasks без лишних вопросов!\n"
f" - КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО переспрашивать у оператора фильтры, статус или категорию задач текстом! "
f"Интерактивная карточка в интерфейсе содержит все нужные фильтры.\n"
f"4. ПРАВИЛА И РЕГЛАМЕНТЫ: При любых вопросах о правилах компании или арбитраже ТЫ ОБЯЗАН СРАЗУ вызвать db_get_rules.\n"
f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n"
f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы "
f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"- Пользователь: {user_info}\n" f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n" f"- {calendar_context}\n"
@@ -165,7 +153,6 @@ def process_chat_message(
user_msg_object = {"role": "user", "content": full_user_content} user_msg_object = {"role": "user", "content": full_user_content}
# ANCHOR[AGENT_TOOL_DISPATCHER]
try: try:
if image_b64: if image_b64:
user_msg_object["images"] = [image_b64] user_msg_object["images"] = [image_b64]
@@ -201,7 +188,7 @@ def process_chat_message(
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
# Ротация контекста: закрываем старую сессию инструмента # Ротация контекста
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}") close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
session_state = None session_state = None
mark_last_user_message_ephemeral(session_id) mark_last_user_message_ephemeral(session_id)
@@ -217,7 +204,7 @@ def process_chat_message(
} }
# 2. Единый диспетчер задач # 2. Единый диспетчер задач
elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]: elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_details", "db_delete_task"]:
action = fn_args.get("action", "UPDATE").upper() action = fn_args.get("action", "UPDATE").upper()
if fn_name == "db_add_task": action = "ADD" if fn_name == "db_add_task": action = "ADD"
elif fn_name == "db_delete_task": action = "DELETE" elif fn_name == "db_delete_task": action = "DELETE"
@@ -239,7 +226,7 @@ def process_chat_message(
elif action == "EXPORT": elif action == "EXPORT":
export_res = db_export_tasks_markdown( export_res = db_export_tasks_markdown(
user_id=user_id, user_id=user_id,
filename=fn_args.get("filename"), filename=fn_args.get("filename") or "ROADMAP.md",
status_filter=fn_args.get("status") status_filter=fn_args.get("status")
) )
reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.") reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
@@ -277,96 +264,46 @@ def process_chat_message(
# 3. Системный промпт # 3. Системный промпт
elif fn_name == "db_get_system_prompt": 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 = (
"📋 **АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ:**\n\n"
f"```text\n{active_prompt}\n```\n\n"
"Вы можете добавить, отредактировать или удалить любой пункт."
)
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
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), {
"type": "PROMPT_VIEW",
elif fn_name == "db_prompt_node_edit":
action = str(fn_args.get("action", "ADD")).upper()
try:
sec_id = int(str(fn_args.get("section_id", 3)).strip())
except Exception:
sec_id = 3
try:
itm_id = int(str(fn_args.get("item_id", 1)).strip())
except Exception:
itm_id = 1
content = str(fn_args.get("content", "")).strip()
baseline_prompt = db_get_active_system_prompt()
with get_db_connection() as temp_conn:
temp_cursor = temp_conn.cursor()
temp_cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = 'main_agent' AND is_active = 1
ORDER BY section_id, item_id
""")
existing_nodes = temp_cursor.fetchall()
nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} if action == "DELETE" else dict(nodes_dict)
if action != "DELETE":
nodes_dict_for_draft[(sec_id, itm_id)] = content
draft_lines = []
curr_sec = None
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
if i_id == 0:
if curr_sec is not None: draft_lines.append("")
draft_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
else:
draft_lines.append(f" {s_id}.{i_id}. {txt}")
merged_prompt = "\n".join(draft_lines)
diff_lines = []
curr_sec = None
display_nodes = dict(nodes_dict)
if action != "DELETE":
display_nodes[(sec_id, itm_id)] = content
for (s_id, i_id), txt in sorted(display_nodes.items()):
if i_id == 0:
if curr_sec is not None: diff_lines.append("")
diff_lines.append(f"{s_id}. {txt}")
curr_sec = s_id
else:
if s_id == sec_id and i_id == itm_id:
line_str = f' <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": [ "buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, {"label": "✏️ Редактировать промпт", "value": "action:open_editor", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"}, {"label": "База знаний", "value": "покажи правила компании", "style": "secondary"}
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
] ]
} }
# 4. Снапшоты СКУД # 4. База знаний и правила компании (терминальный возврат)
elif fn_name == "db_get_rules":
rules_data = db_get_rules()
if isinstance(rules_data, dict) and "rules" in rules_data:
rules_list = rules_data["rules"]
elif isinstance(rules_data, list):
rules_list = rules_data
else:
rules_list = [str(rules_data)]
formatted_rules = "\n\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
reply_text = (
"📖 **БАЗА ЗНАНИЙ И ПРАВИЛА КОМПАНИИ (КАДРОВЫЙ АРБИТРАЖ):**\n\n"
f"```text\n{formatted_rules}\n```"
)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "RULES_VIEW",
"buttons": [
{"label": "✏️ Редактировать правила", "value": "action:open_rules_editor", "style": "primary"},
{"label": "📋 Системный промпт", "value": "покажи системный промпт", "style": "secondary"}
]
}
# 5. Снапшоты СКУД
elif fn_name == "db_get_snapshots": 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) 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", "выбранную дату") query_date = snapshots_res.get("query_date", "выбранную дату")
@@ -423,40 +360,13 @@ def process_chat_message(
"data": updated_snapshots_res "data": updated_snapshots_res
} }
elif fn_name == "db_delete_snapshots": # 6. Прочие сервисные инструменты
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
raw_ids = fn_args.get("snapshot_ids") or ([raw_id] if raw_id else [])
# Защита: итоговые Y-срезы удалять запрещено
safe_ids = [s for s in raw_ids if not str(s).startswith("Y")]
if not safe_ids:
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), None
# Прямое выполнение удаления в SQLite без лишних текстовых подтверждений
del_res = db_delete_snapshots(snapshot_ids=safe_ids)
# Получаем свежий реестр за ту же дату
query_date = fn_args.get("day_str", "")
updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": updated_snapshots_res
}
# 5. Сервисные запросы
elif fn_name == "db_get_current_server_time": elif fn_name == "db_get_current_server_time":
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
elif fn_name == "db_get_stats": elif fn_name == "db_get_stats":
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
elif fn_name == "db_get_anomalies": 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) 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": elif fn_name == "db_get_reference":
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
else: else:
@@ -468,43 +378,15 @@ def process_chat_message(
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)) or "Запрос выполнен." final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен."
for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]: db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
if final_content.lower().startswith(artifact):
final_content = final_content[len(artifact):].lstrip(",.!?:; -")
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] # Свободный диалог (Topic Drift)
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
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]:
idle_turns += 1
if idle_turns > 3:
db_clear_session_state(session_id)
db_purge_ephemeral_messages(session_id)
action_payload = None
session_state = None
elif idle_turns == 3:
tool_action = db_get_tool_action("db_confirm_prompt_preview")
guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?"
buttons = tool_action.get("buttons", []) if tool_action else []
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
action_payload = {"type": "PROMPT_FOLLOWUP", "buttons": buttons}
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns})
else:
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=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), None
except Exception as ex: except Exception as ex:
logger.exception(f"Непредвиденная ошибка агента: {ex}") logger.exception(f"Непредвиденная ошибка агента: {ex}")
+238 -89
View File
@@ -3,42 +3,247 @@
FILE: modules/web_api/llm/core/fast_path.py FILE: modules/web_api/llm/core/fast_path.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core MODULE: web_api / llm / core
ROLE: Детерминированный мгновенный перехват нажатий кнопок подтверждения ROLE: Мгновенный перехват UI-действий, инспекции срезов, экспорта и Diff-превью.
(без задержек LLM и обращения к Ollama).
AI-CONTEXT-ANCHORS:
- ANCHOR[FAST_PATH_MAIN]: Точка входа handle_fast_path_intercept.
=============================================================================== ===============================================================================
""" """
import json import difflib
import logging import logging
from typing import Dict, Any, Tuple, Optional from typing import Dict, Any, Tuple, Optional
# Прямые вызовы чистых доменных сервисов
from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt
from services.tasks.service import get_tasks, delete_task from services.knowledge.service import get_rules, add_rule
from services.tasks.service import get_tasks, delete_task, execute_task_action
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
# Чат и сессии
from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state, db_get_tool_action from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state
from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup
logger = logging.getLogger("FAST_PATH") logger = logging.getLogger("FAST_PATH")
# ANCHOR[FAST_PATH_MAIN] def _generate_prompt_diff_html(baseline_text: str, draft_text: str) -> str:
base_lines = [line.rstrip() for line in baseline_text.strip().splitlines()]
draft_lines = [line.rstrip() for line in draft_text.strip().splitlines()]
matcher = difflib.SequenceMatcher(None, base_lines, draft_lines)
diff_html_lines = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == 'equal':
for line in base_lines[i1:i2]:
diff_html_lines.append(line)
elif tag == 'delete':
for line in base_lines[i1:i2]:
diff_html_lines.append(
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">{line} [УДАЛЕНИЕ]</span>'
)
elif tag == 'insert':
for line in draft_lines[j1:j2]:
diff_html_lines.append(
f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
)
elif tag == 'replace':
for line in base_lines[i1:i2]:
diff_html_lines.append(
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">{line} [УДАЛЕНИЕ]</span>'
)
for line in draft_lines[j1:j2]:
diff_html_lines.append(
f'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{line}</span>'
)
return "\n".join(diff_html_lines)
def handle_fast_path_intercept( def handle_fast_path_intercept(
session_id: str, session_id: str,
user_message: str, user_message: str,
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]]]]:
""" msg_raw = user_message.strip()
Мгновенный перехват нажатий кнопок подтверждения (Fast-Path). msg_lower = msg_raw.lower()
Возвращает (reply, history, action_type) или None, если требуется передать управление LLM.
""" # -------------------------------------------------------------------------
# 0.0 ЭКСПОРТ ЗАДАЧ В ФАЙЛ (МГНОВЕННО, БЕЗ ЛИШНИХ ДИАЛОГОВ)
# -------------------------------------------------------------------------
if any(msg_lower.startswith(p) for p in ["экспортируй задачи", "экспорт задач", "выгрузи задачи", "скачать задачи"]):
filename = "ROADMAP.md"
if " в " in msg_lower:
parts = msg_raw.split(" в ", 1)[1].strip().split()
if parts and ("." in parts[0] or parts[0].endswith("md")):
filename = parts[0]
res = execute_task_action(user_id=1, action="EXPORT", filename=filename)
reply = res.get("message", f"Отчет по задачам сформирован в `{filename}`.")
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
action_payload = {
"type": "FILE_DOWNLOAD_CARD",
"filename": res.get("filename", filename),
"download_url": res.get("download_url", "#"),
"tasks_count": res.get("tasks_count", 0)
}
return reply, db_get_chat_history(session_id), action_payload
# -------------------------------------------------------------------------
# 0.1 ИНСПЕКЦИЯ КОНКРЕТНОГО СРЕЗА СКУД (ПО ID СНАПШОТА)
# -------------------------------------------------------------------------
if msg_lower.startswith("покажи срез ") or msg_lower.startswith("инспекция среза "):
target_snap_id = msg_raw.split()[-1].replace("#", "").strip()
from core.database import load_scud_from_db_by_snapshot
df_snap = load_scud_from_db_by_snapshot(date_str="", snapshot_param=target_snap_id)
if df_snap is None or df_snap.empty:
reply = f"⚠️ Срез СКУД `#{target_snap_id}` не найден в базе данных."
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), None
total_cnt = len(df_snap)
present_cnt = len(df_snap[df_snap['Пришел'] == True]) if 'Пришел' in df_snap.columns else 0
absent_cnt = total_cnt - present_cnt
snap_time = df_snap['snapshot_time'].iloc[0] if 'snapshot_time' in df_snap.columns else '—'
log_date = df_snap['log_date'].iloc[0] if 'log_date' in df_snap.columns else '—'
rows_list = []
for _, r in df_snap.iterrows():
rows_list.append({
"fio": r.get('Сотрудник', r.get('fio', '')),
"department": r.get('Подразделение', r.get('department_scud', '—')),
"time_in": r.get('Начало_дня', 'Нет входа'),
"first_activity": r.get('Первая_активность', '—'),
"time_out": r.get('Конец_дня', 'Нет выхода'),
"in_building": r.get('Находился_в_здании', '00:00'),
"is_present": bool(r.get('Пришел', False)),
"anomaly": r.get('anomaly_flag', 'NONE')
})
# Фиксация среза в памяти сессии для последующих вопросов к LLM
db_set_session_state(session_id, "SNAPSHOT_INSPECT", {
"snapshot_id": target_snap_id,
"log_date": log_date,
"snapshot_time": snap_time,
"records": rows_list,
"idle_turns": 0
})
reply = f"🔍 **Инспекция среза #{target_snap_id}** (Дата: {log_date}, Время: {snap_time}). Всего записей: {total_cnt} (Присутствовали: {present_cnt}, Отсутствовали: {absent_cnt})."
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "SNAPSHOT_INSPECT_CARD",
"snapshot_id": target_snap_id,
"log_date": log_date,
"snapshot_time": snap_time,
"total_count": total_cnt,
"present_count": present_cnt,
"absent_count": absent_cnt,
"records": rows_list
}
# -------------------------------------------------------------------------
# 0.2 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРОМПТА
# -------------------------------------------------------------------------
if msg_lower in ["action:open_editor", "редактировать промпт", "открыть редактор"]:
active_prompt = get_active_system_prompt()
state_data = session_state.get("data_json") if session_state else {}
draft_text = state_data.get("draft_text") if isinstance(state_data, dict) and state_data.get("draft_text") else active_prompt
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": draft_text,
"action": "MANUAL_EDIT",
"idle_turns": 0
})
reply = "✏️ Внесите необходимые изменения в текст промпта и нажмите «Показать превью изменений»:"
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "PROMPT_EDITOR",
"raw_draft": draft_text,
"baseline_prompt": active_prompt
}
# -------------------------------------------------------------------------
# 0.3 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРАВИЛ
# -------------------------------------------------------------------------
if msg_lower in ["action:open_rules_editor", "редактировать правила", "изменить правила"]:
rules_data = get_rules()
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
raw_rules_text = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
db_set_session_state(session_id, "RULES_PREVIEW", {
"draft_text": raw_rules_text,
"action": "MANUAL_EDIT_RULES",
"idle_turns": 0
})
reply = "✏️ Редактор базы знаний и правил компании. Внесите изменения и нажмите «Показать превью изменений»:"
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "RULES_EDITOR",
"raw_draft": raw_rules_text,
"baseline_prompt": raw_rules_text
}
# -------------------------------------------------------------------------
# 0.4 ПОСТУПЛЕНИЕ ДРАФТА ПРОМПТА -> DIFF-ПРЕВЬЮ
# -------------------------------------------------------------------------
if msg_raw.startswith("action:save_draft_prompt:::"):
new_draft_content = msg_raw.replace("action:save_draft_prompt:::", "").strip()
active_prompt = get_active_system_prompt()
diff_html = _generate_prompt_diff_html(active_prompt, new_draft_content)
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": new_draft_content,
"action": "MANUAL_EDIT",
"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": new_draft_content,
"baseline_prompt": active_prompt,
"diff_html": diff_html,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
]
}
# -------------------------------------------------------------------------
# 0.5 ПОСТУПЛЕНИЕ ДРАФТА ПРАВИЛ -> DIFF-ПРЕВЬЮ
# -------------------------------------------------------------------------
if msg_raw.startswith("action:save_draft_rules:::"):
new_draft_content = msg_raw.replace("action:save_draft_rules:::", "").strip()
rules_data = get_rules()
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
baseline_rules = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
diff_html = _generate_prompt_diff_html(baseline_rules, new_draft_content)
db_set_session_state(session_id, "RULES_PREVIEW", {
"draft_text": new_draft_content,
"action": "MANUAL_EDIT_RULES",
"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": new_draft_content,
"baseline_prompt": baseline_rules,
"diff_html": diff_html,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю сохранение правил", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_rules_editor", "style": "secondary"}
]
}
if not session_state: if not session_state:
return None return None
@@ -47,38 +252,22 @@ def handle_fast_path_intercept(
if not isinstance(state_data, dict): if not isinstance(state_data, dict):
state_data = {} state_data = {}
msg_lower = user_message.strip().lower()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# 1. ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ СИСТЕМНОГО ПРОМПТА # 1. ПОДТВЕРЖДЕНИЕ ПРОМПТА
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
if state_type == "PROMPT_PREVIEW": if state_type == "PROMPT_PREVIEW":
is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"] is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"] is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
if is_confirm: if is_confirm:
action = state_data.get("action", "MANUAL_EDIT")
draft_text = state_data.get("draft_text", "") draft_text = state_data.get("draft_text", "")
if draft_text:
# Применение изменений
if action == "MANUAL_EDIT" and draft_text:
save_full_prompt_draft(draft_text, prompt_name="main_agent") save_full_prompt_draft(draft_text, prompt_name="main_agent")
else:
sec_id = state_data.get("section_id")
itm_id = state_data.get("item_id")
content = state_data.get("content", "")
if sec_id is not None and itm_id is not None:
apply_prompt_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
elif draft_text:
save_full_prompt_draft(draft_text, prompt_name="main_agent")
# Закрытие сессии и зачистка
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY") close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
db_clear_session_state(session_id) db_clear_session_state(session_id)
tool_action = db_get_tool_action("db_confirm_prompt_preview") reply = "✅ Системный промпт успешно сохранен и применен в базе данных."
reply = tool_action.get("success_template", "✅ Системный промпт успешно сохранен и применен в базе данных.") if tool_action else "✅ Системный промпт успешно сохранен и применен в базе данных."
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=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None return reply, db_get_chat_history(session_id), None
@@ -87,80 +276,40 @@ def handle_fast_path_intercept(
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED") close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED")
db_clear_session_state(session_id) db_clear_session_state(session_id)
tool_action = db_get_tool_action("db_cancel_prompt_preview") reply = "❌ Изменения системного промпта отменены."
reply = tool_action.get("success_template", "❌ Изменения системного промпта отменены.") if tool_action else "❌ Изменения системного промпта отменены."
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=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None return reply, db_get_chat_history(session_id), None
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# 2. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ СКУД # 2. ПОДТВЕРЖДЕНИЕ ПРАВИЛ
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
elif state_type == "SNAPSHOT_DELETE_CONFIRM": elif state_type == "RULES_PREVIEW":
is_confirm = "подтверждаю удаление снапшот" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"] is_confirm = "сохранение правил" in msg_lower or msg_lower in ["подтверждаю", "да", "сохраняй", "применить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет"] is_cancel = msg_lower in ["отмена", "отменить", "нет"]
if is_confirm: if is_confirm:
snap_ids = state_data.get("snapshot_ids", []) draft_text = state_data.get("draft_text", "")
query_date = state_data.get("query_date", "") lines = [l.strip() for l in draft_text.splitlines() if l.strip()]
for line in lines:
clean_line = line
if line[0].isdigit() and "." in line[:5]:
clean_line = line.split(".", 1)[1].strip()
add_rule(clean_line)
# Безопасное удаление через сервис close_tool_session_and_cleanup(session_id, close_reason="RULES_SAVED_SUCCESS")
del_res = delete_snapshots_safely(snapshot_ids=snap_ids)
close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOTS_DELETED")
# Получаем свежий список за ту же дату
updated_data = get_snapshots_registry(date_str=query_date if query_date else None)
db_set_session_state(session_id, "SNAPSHOTS_VIEW", updated_data)
reply = f"✅ Успешно удалено снапшотов: {len(snap_ids)} шт."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": updated_data
}
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOT_DELETE_CANCELLED")
db_clear_session_state(session_id) db_clear_session_state(session_id)
reply = "❌ Удаление снапшотов отменено." reply = "✅ База знаний и правила компании успешно сохранены в базе данных."
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=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None return reply, db_get_chat_history(session_id), None
# -------------------------------------------------------------------------
# 3. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ
# -------------------------------------------------------------------------
elif state_type == "TASK_DELETE_CONFIRM":
is_confirm = "подтверждаю удаление задачи" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
if is_confirm:
task_id = state_data.get("task_id")
delete_task(user_id=1, task_id=str(task_id))
close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETED")
db_clear_session_state(session_id)
raw_tasks = get_tasks(user_id=1)
reply = f"🗑 Задача #{task_id} удалена."
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
elif is_cancel: elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETE_CANCELLED") close_tool_session_and_cleanup(session_id, close_reason="RULES_EDIT_CANCELLED")
db_clear_session_state(session_id) db_clear_session_state(session_id)
reply = "❌ Удаление задачи отменено." reply = "❌ Изменение правил компании отменено."
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=0)
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return reply, db_get_chat_history(session_id), None return reply, db_get_chat_history(session_id), None
+1 -1
View File
@@ -15,7 +15,7 @@ from typing import Dict, Any, Optional
logger = logging.getLogger("OLLAMA_CLIENT") logger = logging.getLogger("OLLAMA_CLIENT")
OLLAMA_URL = "http://10.121.17.227:11434/api/chat" OLLAMA_URL = "http://10.121.17.227:11434/api/chat"
TEXT_MODEL = "qwen2.5:14b-instruct-q8_0" TEXT_MODEL = "qwen2.5:14b"
VISION_MODEL = "qwen2.5vl:7b-q8_0" VISION_MODEL = "qwen2.5vl:7b-q8_0"
LLM_OPTIONS = { LLM_OPTIONS = {
+3 -105
View File
@@ -3,11 +3,7 @@
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: Семантический анализ намерений оператора (Intent Classifier) и ROLE: Базовая санитарная очистка вывода и тегов инструментов.
детерминированная сборка вызовов инструментов при сбоях нативного Function Calling.
AI-CONTEXT-ANCHORS:
- ANCHOR[INTENT_INJECTOR_MAIN]: Точка входа inject_tools_if_needed.
=============================================================================== ===============================================================================
""" """
@@ -32,107 +28,9 @@ def clean_output(text: str) -> str:
return text.strip() if text else "" return text.strip() if text else ""
# ANCHOR[INTENT_INJECTOR_MAIN]
def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
""" """
Гибридный семантический анализатор: Модель управляется через TOOLS_SCHEMA и системный контекст.
Если модель ответила текстом с сомнениями или пропустила tool_call, Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP.
распознает доменное намерение и конструирует синтетический tool_call.
""" """
if existing_tool_calls:
return existing_tool_calls
msg_clean = user_message.strip().lower()
# 1. СЕМАНТИКА: Просмотр системного промпта
# Паттерны: "покажи системный промпт", "выведи промпт", "текущий промпт", "какой сейчас системный промпт"
if "промпт" in msg_clean and any(kw in msg_clean for kw in ["покажи", "выведи", "какой", "дай", "текст", "актуальн"]):
logger.info("[IntentInjector] Распознано намерение просмотра системного промпта")
return [{
"function": {
"name": "db_get_system_prompt",
"arguments": {}
}
}]
# 2. СЕМАНТИКА: Удаление задач
# Паттерны: "удали задачу 37", "убери 37 задачу", "сотри таску #37", "сними с повестки задачу 37"
if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "сотри", "сними"]) and any(kw in msg_clean for kw in ["задач", "таск"]):
task_match = re.search(r'#?\s*(\d+)', msg_clean)
if task_match:
task_id = task_match.group(1)
logger.info(f"[IntentInjector] Распознано намерение удаления задачи: #{task_id}")
return [{
"function": {
"name": "db_tasks_edit",
"arguments": {"action": "DELETE", "task_id": task_id}
}
}]
# 3. СЕМАНТИКА: Управление системным промптом (удаление и мульти-удаление)
# Паттерны: "удали 1.8 и 3.4", "удали пункт 2.3", "вычеркни 1.8, 3.4 из промпта"
if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "вычеркни", "сотри"]) and not any(kw in msg_clean for kw in ["задач", "снапшот", "срез"]):
node_matches = re.findall(r'(\d+)[\.\s]+(\d+)', user_message)
if node_matches:
formatted_nodes = [f"{s}.{i}" for s, i in node_matches]
logger.info(f"[IntentInjector] Распознано намерение удаления узлов промпта: {formatted_nodes}")
return [{
"function": {
"name": "db_prompt_node_edit",
"arguments": {
"action": "BATCH_DELETE" if len(formatted_nodes) > 1 else "DELETE",
"section_id": int(node_matches[0][0]),
"item_id": int(node_matches[0][1]),
"nodes_list": formatted_nodes,
"content": ""
}
}
}]
# 4. СЕМАНТИКА: Добавление пункта промпта
# Паттерны: "добавь 3.4 Текст", "добавь пункт 3.4 Текст", "впиши в 3.4 Текст"
if any(kw in msg_clean for kw in ["добавь", "добавить", "впиши", "запиши"]) and not any(kw in msg_clean for kw in ["задач", "таск"]):
add_match = re.search(r'(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)', user_message)
if add_match:
sec_id = int(add_match.group(1))
itm_id = int(add_match.group(2))
content = add_match.group(3).strip()
logger.info(f"[IntentInjector] Распознано намерение добавления узла промпта: {sec_id}.{itm_id}")
return [{
"function": {
"name": "db_prompt_node_edit",
"arguments": {
"action": "ADD",
"section_id": sec_id,
"item_id": itm_id,
"content": content
}
}
}]
# 5. СЕМАНТИКА: Смена статуса задач
if any(kw in msg_clean for kw in ["в работу", "начни", "стартуй", "за работу"]):
task_match = re.search(r'#?\s*(\d+)', msg_clean)
if task_match:
task_id = task_match.group(1)
logger.info(f"[IntentInjector] Распознано намерение взятия в работу задачи: #{task_id}")
return [{
"function": {
"name": "db_tasks_edit",
"arguments": {"action": "UPDATE", "task_id": task_id, "status": "IN_PROGRESS"}
}
}]
if any(kw in msg_clean for kw in ["заверши", "закрой", "готово", "выполнено"]):
task_match = re.search(r'#?\s*(\d+)', msg_clean)
if task_match:
task_id = task_match.group(1)
logger.info(f"[IntentInjector] Распознано намерение закрытия задачи: #{task_id}")
return [{
"function": {
"name": "db_tasks_edit",
"arguments": {"action": "UPDATE", "task_id": task_id, "status": "COMPLETED"}
}
}]
return existing_tool_calls return existing_tool_calls
+3
View File
@@ -22,6 +22,9 @@ from services.tasks.service import (
delete_task as db_delete_task, delete_task as db_delete_task,
execute_task_action as db_tasks_edit execute_task_action as db_tasks_edit
) )
# Защитный алиас для обратной совместимости
db_update_task_status = db_update_task_details
from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
from services.tasks.repository import normalize_task_id from services.tasks.repository import normalize_task_id
+6 -2
View File
@@ -69,14 +69,18 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_tasks", "name": "db_get_tasks",
"description": "Просмотр реестра задач и бэклога текущего пользователя.", "description": (
"Просмотр реестра задач и бэклога текущего пользователя.\n"
"Вызывай этот инструмент ВСЕГДА при любых запросах просмотра задач ('покажи задачи', 'мои задачи', опечатки 'змдачи').\n"
"Запрещено переспрашивать статус или параметры текстом: просто вызывай функцию с аргументами {}."
),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"status": { "status": {
"type": "string", "type": "string",
"enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"], "enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
"description": "Опциональный фильтр статуса задач" "description": "Опциональный фильтр статуса задач (по умолчанию ALL)"
} }
} }
} }
+14
View File
@@ -32,6 +32,10 @@ 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 from routers.files import router as files_router
from routers.exceptions import router as exceptions_router
from routers.snapshots import router as snapshots_router
from routers.remote_workers import router as remote_workers_router
from routers.context import router as context_router
# ANCHOR[APP_CONFIG] # ANCHOR[APP_CONFIG]
logging.basicConfig( logging.basicConfig(
@@ -61,6 +65,10 @@ 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) app.include_router(files_router)
app.include_router(exceptions_router)
app.include_router(snapshots_router)
app.include_router(remote_workers_router)
app.include_router(context_router)
# ANCHOR[ROOT_STATIC_ROUTES] # ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/") @app.get("/")
@@ -80,6 +88,12 @@ async def favicon():
@app.get("/{file_path:path}") @app.get("/{file_path:path}")
def serve_static_fallback(file_path: str): def serve_static_fallback(file_path: str):
clean_path = file_path.lstrip("/") clean_path = file_path.lstrip("/")
# Жесткая блокировка скрытых файлов (.env, .git) и служебных форматов
forbidden_patterns = [".env", ".git", ".yml", ".yaml", ".json", ".sql", ".php", ".bak"]
if clean_path.startswith(".") or any(p in clean_path.lower() for p in forbidden_patterns):
raise HTTPException(status_code=403, detail="Access denied")
target = os.path.join(STATIC_DIR, clean_path) target = os.path.join(STATIC_DIR, clean_path)
if os.path.isfile(target): if os.path.isfile(target):
+12 -2
View File
@@ -72,7 +72,7 @@ def login(req: AuthRequest):
username = req.username.strip().lower() username = req.username.strip().lower()
conn = get_db() conn = get_db()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) cursor.execute("SELECT id, username, password_hash, full_name, is_admin FROM users WHERE username = ?", (username,))
user = cursor.fetchone() user = cursor.fetchone()
conn.close() conn.close()
@@ -82,7 +82,17 @@ def login(req: AuthRequest):
is_admin = bool(user["is_admin"]) or (user["username"] == "puh") is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin) token = create_access_token(user["id"], user["username"], is_admin)
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} # Возвращаем full_name (если не задано — отдаем username)
full_name = user["full_name"] if user["full_name"] else user["username"]
return {
"status": "success",
"token": token,
"username": user["username"],
"full_name": full_name,
"user_id": user["id"],
"is_admin": is_admin
}
@router.post("/change-password") @router.post("/change-password")
+101 -44
View File
@@ -1,22 +1,29 @@
""" """
=============================================================================== ===============================================================================
FILE: modules/web_api/routers/chat.py FILE: modules/web_api/routers/chat.py
ROLE: Обработка сообщений веб-чата с поддержкой токенов и гостевого доступа. ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов,
поддержкой Function Calling, Fast-Path и оптического распознавания OCR.
=============================================================================== ===============================================================================
""" """
from fastapi import APIRouter, Header, HTTPException, Request import os
from pydantic import BaseModel import shutil
from typing import Optional, List, Dict, Any import base64
import logging import logging
from typing import Optional, List, Dict, Any
from services.text_reporter import ask_ollama from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
from services.knowledge_base import load_knowledge_base from pydantic import BaseModel
from core.database import get_connection
from llm.agent import process_chat_message
from config import BASE_DIR
logger = logging.getLogger("CHAT_API") logger = logging.getLogger("CHAT_API")
router = APIRouter(prefix="/api/v1", tags=["Chat"]) router = APIRouter(prefix="/api/v1", tags=["Chat"])
UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "data", "uploads")
os.makedirs(UPLOAD_TMP_DIR, exist_ok=True)
class ChatMessageRequest(BaseModel): class ChatMessageRequest(BaseModel):
message: str message: str
@@ -25,17 +32,11 @@ class ChatMessageRequest(BaseModel):
def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int: def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int:
"""
Извлекает ID пользователя из Bearer-токена.
Если токен не передан или сессия новая — использует user_id=1 по умолчанию,
не блокируя работу ошибкой 403 Forbidden.
"""
if explicit_user_id and explicit_user_id > 0: if explicit_user_id and explicit_user_id > 0:
return explicit_user_id return explicit_user_id
if authorization and authorization.startswith("Bearer "): if authorization and authorization.startswith("Bearer "):
token = authorization.replace("Bearer ", "").strip() token = authorization.replace("Bearer ", "").strip()
# Если используется простой токен вида 'user_1' или JWT
if token.isdigit(): if token.isdigit():
return int(token) return int(token)
elif token.startswith("dev_token_"): elif token.startswith("dev_token_"):
@@ -43,8 +44,6 @@ def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optio
return int(token.replace("dev_token_", "")) return int(token.replace("dev_token_", ""))
except ValueError: except ValueError:
pass pass
# Дефолтный пользователь (гостевой / основной аккаунт)
return 1 return 1
@@ -59,39 +58,97 @@ async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}") logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}")
# Загружаем контекст базы знаний reply_text, history, action_payload = process_chat_message(
kb = load_knowledge_base() user_id=user_id,
rules_text = "\n".join([f"- {r}" for r in kb.get("rules", [])]) user_message=user_msg,
session_id=session_id
system_prompt = (
"Ты — ИИ-ассистент системы кадровой безопасности и контроллинга СКУД Orion AI.\n"
"Отвечай четко, профессионально и на русском языке.\n"
f"Актуальные правила системы:\n{rules_text}"
) )
try: return {
reply_text = ask_ollama(user_msg, system_prompt=system_prompt) "status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}
# Сохранение истории в SQLite при необходимости
@router.post("/chat/upload")
async def chat_upload_endpoint(
file: UploadFile = File(...),
message: Optional[str] = Form(""),
session_id: Optional[str] = Form("web_session_main"),
authorization: Optional[str] = Header(None)
):
user_id = resolve_user_id(authorization, 1)
file_path = os.path.join(UPLOAD_TMP_DIR, file.filename)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
file_context = ""
image_b64 = None
fn_lower = file.filename.lower()
# 1. Текстовые форматы
if fn_lower.endswith((".txt", ".csv", ".log", ".md")):
try: try:
with get_connection() as conn: with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
conn.execute( file_context = f.read(6000)
"INSERT INTO chat_messages (session_id, role, content) VALUES (?, ?, ?), (?, ?, ?)", except Exception as e:
(session_id, "user", user_msg, session_id, "assistant", reply_text) logger.warning(f"Не удалось прочитать текст: {e}")
)
conn.commit() # 2. Изображения (прямой OCR)
elif fn_lower.endswith((".png", ".jpg", ".jpeg", ".webp")):
try:
with open(file_path, "rb") as f:
image_b64 = base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
logger.warning(f"Ошибка кодирования картинки в base64: {e}")
# 3. PDF документы (текстовый слой + рендеринг скана при необходимости)
elif fn_lower.endswith(".pdf"):
# Попытка извлечь встроенный текстовый слой
try:
import pypdf
reader = pypdf.PdfReader(file_path)
extracted = []
for page in reader.pages:
t = page.extract_text()
if t:
extracted.append(t)
file_context = "\n".join(extracted).strip()
except Exception: except Exception:
pass pass
return { # Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для Vision OCR
"status": "success", if len(file_context) < 40:
"user_id": user_id, try:
"session_id": session_id, import fitz # PyMuPDF
"response": reply_text doc = fitz.open(file_path)
} if len(doc) > 0:
except Exception as e: page = doc[0]
logger.error(f"Ошибка вызова нейросети: {e}") pix = page.get_pixmap(dpi=150)
return { img_bytes = pix.tobytes("png")
"status": "error", image_b64 = base64.b64encode(img_bytes).decode("utf-8")
"response": f"⚠️ Ошибка обработки запроса: {str(e)}" file_context = ""
} except Exception as e:
logger.warning(f"PyMuPDF не установлен или сбой рендеринга PDF: {e}")
user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}"
reply_text, history, action_payload = process_chat_message(
user_id=user_id,
user_message=user_msg,
file_context=file_context,
image_b64=image_b64,
session_id=session_id
)
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}
+50
View File
@@ -0,0 +1,50 @@
"""
===============================================================================
FILE: modules/web_api/routers/context.py
ROLE: REST API мониторинга состояния сессии и очистки памяти диалога.
===============================================================================
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional
from routers.auth import get_current_user
from modules.web_api.llm.db.db_prompts import db_get_session_state, db_clear_session_state
from modules.web_api.llm.db.db_chat import db_get_chat_history, db_purge_ephemeral_messages, db_clear_chat_history
router = APIRouter(prefix="/api/v1/context", tags=["Context"])
class SessionActionRequest(BaseModel):
session_id: Optional[str] = "web_session_main"
@router.get("/state")
def api_get_context_state(session_id: str = "web_session_main", current_user = Depends(get_current_user)):
state = db_get_session_state(session_id) or {}
history = db_get_chat_history(session_id, limit=50)
ephemeral_count = sum(1 for m in history if dict(m).get("is_ephemeral") == 1)
total_messages = len(history)
return {
"session_id": session_id,
"active_state": state.get("state_type", "IDLE"),
"state_data": state.get("data_json", {}),
"total_messages": total_messages,
"ephemeral_messages": ephemeral_count
}
@router.post("/purge-ephemeral")
def api_purge_ephemeral(req: SessionActionRequest, current_user = Depends(get_current_user)):
purged = db_purge_ephemeral_messages(req.session_id)
return {"status": "success", "purged_count": purged}
@router.post("/clear-all")
def api_clear_all_context(req: SessionActionRequest, current_user = Depends(get_current_user)):
db_clear_session_state(req.session_id)
db_clear_chat_history(req.session_id)
return {"status": "success", "message": "Контекст сессии полностью очищен"}
+157
View File
@@ -0,0 +1,157 @@
"""
===============================================================================
FILE: modules/web_api/routers/remote_workers.py
ROLE: REST API реестра удаленщиков (CRUD, редактирование сроков, автоочистка).
===============================================================================
"""
import os
from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from routers.auth import get_current_user
from config import BASE_DIR, DATA_DIR
router = APIRouter(prefix="/api/v1/remote-workers", tags=["RemoteWorkers"])
CSV_PATH = os.path.join(DATA_DIR, "static_reason_workers.csv")
def _load_workers() -> List[Dict[str, Any]]:
"""Читает CSV, гарантирует структуру колонок и удаляет просроченные записи."""
if not os.path.exists(CSV_PATH):
return []
try:
df = pd.read_csv(CSV_PATH, dtype=str, on_bad_lines='skip').fillna("")
for col in ["fio", "department", "reason", "date_from", "date_to"]:
if col not in df.columns:
df[col] = ""
today_date = datetime.now().date()
valid_workers = []
has_expired = False
for row in df.to_dict(orient="records"):
fio = str(row.get("fio", "")).strip()
if not fio:
continue
d_to_str = str(row.get("date_to", "")).strip()
if d_to_str and d_to_str.lower() not in ["nan", "none", ""]:
try:
d_to = datetime.strptime(d_to_str.replace('_', '.'), "%d.%m.%Y").date()
# Если срок завершился вчера или ранее — запись удаляется из файла
if today_date > d_to:
has_expired = True
continue
except ValueError:
pass
valid_workers.append(row)
# Синхронная перезапись файла при обнаружении истекших сроков
if has_expired:
_save_workers(valid_workers)
return valid_workers
except Exception:
return []
def _save_workers(workers: List[Dict[str, Any]]):
"""Сохраняет актуальный список в CSV с сохранением колонок."""
df = pd.DataFrame(workers)
for col in ["fio", "department", "reason", "date_from", "date_to"]:
if col not in df.columns:
df[col] = ""
os.makedirs(os.path.dirname(CSV_PATH), exist_ok=True)
df.to_csv(CSV_PATH, index=False, encoding="utf-8")
class RemoteWorkerItem(BaseModel):
fio: str
department: Optional[str] = "Все"
reason: Optional[str] = "Удаленная работа"
date_from: Optional[str] = ""
date_to: Optional[str] = ""
class UpdateDatesRequest(BaseModel):
fio: str
date_from: Optional[str] = ""
date_to: Optional[str] = ""
@router.get("")
def api_get_remote_workers(current_user = Depends(get_current_user)):
return {"workers": _load_workers()}
@router.post("")
def api_add_remote_worker(item: RemoteWorkerItem, current_user = Depends(get_current_user)):
workers = _load_workers()
fio_clean = item.fio.strip()
if not fio_clean:
raise HTTPException(status_code=400, detail="ФИО не может быть пустым")
# Если начало не указано — берем сегодня
today_str = datetime.now().strftime("%d.%m.%Y")
date_from = item.date_from.strip() if item.date_from and item.date_from.strip() else today_str
date_to = item.date_to.strip() if item.date_to else ""
# Проверка на совпадение ФИО (обновление существующей записи)
for w in workers:
if str(w.get("fio", "")).strip().lower() == fio_clean.lower():
w["department"] = item.department.strip() if item.department else (w.get("department") or "Все")
w["reason"] = item.reason.strip() if item.reason else (w.get("reason") or "Удаленная работа")
w["date_from"] = date_from
w["date_to"] = date_to
_save_workers(workers)
return {"status": "success", "message": "Срок удаленки обновлен", "workers": workers}
workers.append({
"fio": fio_clean,
"department": item.department.strip() if item.department else "Все",
"reason": item.reason.strip() if item.reason else "Удаленная работа",
"date_from": date_from,
"date_to": date_to
})
_save_workers(workers)
return {"status": "success", "workers": workers}
@router.put("")
def api_update_worker_dates(req: UpdateDatesRequest, current_user = Depends(get_current_user)):
"""Редактирование срока удаленки (продление или сокращение)."""
workers = _load_workers()
target_fio = req.fio.strip().lower()
found = False
for w in workers:
if str(w.get("fio", "")).strip().lower() == target_fio:
w["date_from"] = req.date_from.strip() if req.date_from is not None else w.get("date_from", "")
w["date_to"] = req.date_to.strip() if req.date_to is not None else w.get("date_to", "")
found = True
break
if not found:
raise HTTPException(status_code=404, detail="Сотрудник не найден в списке")
_save_workers(workers)
return {"status": "success", "message": "Сроки успешно изменены", "workers": workers}
@router.delete("")
def api_delete_remote_worker(fio: str, current_user = Depends(get_current_user)):
workers = _load_workers()
fio_clean = fio.strip().lower()
initial_len = len(workers)
workers = [w for w in workers if str(w.get("fio", "")).strip().lower() != fio_clean]
if len(workers) == initial_len:
raise HTTPException(status_code=404, detail="Сотрудник не найден")
_save_workers(workers)
return {"status": "success", "workers": workers}
+53
View File
@@ -0,0 +1,53 @@
"""
===============================================================================
FILE: modules/web_api/routers/snapshots.py
ROLE: REST API эндпоинты для управления и моментального создания срезов СКУД.
===============================================================================
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from routers.auth import get_current_user
from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
from services.scud_export import run_export
router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"])
class CreateSnapshotRequest(BaseModel):
date_str: Optional[str] = None
class DeleteSnapshotsRequest(BaseModel):
snapshot_ids: List[str]
@router.get("")
def api_get_snapshots(date_str: Optional[str] = None, current_user = Depends(get_current_user)):
return get_snapshots_registry(date_str=date_str)
@router.post("/create")
def api_create_instant_snapshot(req: CreateSnapshotRequest, current_user = Depends(get_current_user)):
"""Моментальный опрос MS SQL СКУД и запись свежего среза в SQLite."""
try:
success = run_export(input_date=req.date_str, save_xlsx=True, debug=False)
if not success:
raise HTTPException(status_code=500, detail="Ошибка при обращении к MS SQL Орион")
fresh_data = get_snapshots_registry(date_str=req.date_str)
return {"status": "success", "message": "Срез успешно создан", "data": fresh_data}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка создания среза: {str(e)}")
@router.delete("")
def api_delete_snapshots(req: DeleteSnapshotsRequest, current_user = Depends(get_current_user)):
safe_ids = [s for s in req.snapshot_ids if not str(s).startswith("Y")]
if not safe_ids:
raise HTTPException(status_code=400, detail="Итоговый Y-срез защищен от удаления")
res = delete_snapshots_safely(snapshot_ids=safe_ids)
return {"status": "success", "deleted_count": res.get("deleted_count", len(safe_ids))}
+575 -135
View File
@@ -1,165 +1,605 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru" class="h-full bg-slate-100">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SCUD Orion AI — Управление и Аналитика</title> <title>SCUD Orion AI Assistant</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/css/styles.css"> <link rel="icon" href="/favicon.ico" type="image/x-icon">
<style>
/* Запрещаем браузеру насильно удерживать скролл внизу при появлении ответа */
* {
overflow-anchor: none !important;
}
#chat-messages-container, main {
overflow-anchor: none !important;
}
/* ⭐️ Воздух снизу для возможности поднятия вопроса на самый верх */
#chat-messages-container {
/*
clamp(минимальный отступ, желаемый адаптивный, максимальный предел)
Это гарантирует, что на огромных экранах отступ не раздуется до бесконечности,
а на маленьких — не сожмет ленту в ноль.
*/
padding-bottom: clamp(400px, 85vh, 900px) !important;
}
/* Принудительное увеличение шрифта сообщений чата */
#chat-messages-container .message-content,
#chat-messages-container .text-xs,
#chat-messages-container .text-sm {
font-size: 14.5px !important;
line-height: 1.6 !important;
}
#chat-messages-container pre,
#chat-messages-container code {
font-size: 13.5px !important;
}
/* Смещение для точной прокрутки под фиксированный заголовок */
.user-chat-bubble {
scroll-margin-top: 24px !important;
}
</style>
</head> </head>
<body class="bg-slate-100 font-sans h-screen flex overflow-hidden text-slate-800"> <body class="h-full flex flex-col font-sans antialiased text-slate-800 bg-slate-100 selection:bg-indigo-500 selection:text-white">
<!-- Боковая панель (Задачи и Навигация) --> <div id="app-container" class="flex-1 flex overflow-hidden w-full h-full">
<aside id="task-drawer" class="w-80 sm:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full z-20 shadow-sm transition-all duration-300">
<!-- Шапка панели задач --> <!-- ЛЕВАЯ КОЛОНКА (ДИНАМИЧЕСКИЙ САЙДБАР 5-ХАБОВ) -->
<div class="p-4 border-b border-slate-200 flex items-center justify-between bg-slate-50/70"> <aside class="w-80 md:w-96 bg-white border-r border-slate-200 flex flex-col shrink-0 h-full shadow-sm z-10 select-none">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white shadow-sm"> <!-- ДИНАМИЧЕСКИЙ ТАБ-БАР ХАБОВ И ПОДВКЛАДОК -->
<i class="fa-solid fa-list-check text-sm"></i> <div id="sidebar-dynamic-header" class="shrink-0 bg-slate-50 border-b border-slate-200"></div>
</div>
<div> <!-- ДИНАМИЧЕСКИЙ КОНТЕНТНЫЙ СЛОТ -->
<h2 class="font-bold text-sm text-slate-900 leading-tight">Бэклог задач</h2> <div id="sidebar-dynamic-content" class="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
<p class="text-[11px] text-slate-500">SCUD Orion AI Roadmap</p> <div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
<div class="text-center py-10 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...
</div>
</div> </div>
</div> </div>
<button type="button" onclick="openAddTaskModal()" title="Добавить задачу"
class="px-2.5 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border border-indigo-200 rounded-lg text-xs font-semibold flex items-center gap-1 transition cursor-pointer">
<i class="fa-solid fa-plus"></i>
<span>Задача</span>
</button>
</div>
<!-- Фильтры задач --> <!-- ПОДВАЛ САЙДБАРА: ПРОФИЛЬ, НАСТРОЙКИ, АДМИНКА И ВЫХОД -->
<div class="px-4 py-2.5 border-b border-slate-100 flex items-center justify-between gap-1 text-xs"> <div class="p-3 border-t border-slate-200 bg-slate-50 flex items-center justify-between shrink-0">
<button onclick="filterTasksByTab('IN_PROGRESS')" id="tab-in-progress" class="task-tab-btn font-semibold px-2.5 py-1 rounded-md text-indigo-600 bg-indigo-50 transition">В работе</button> <div class="flex items-center gap-2.5 min-w-0">
<button onclick="filterTasksByTab('BACKLOG')" id="tab-backlog" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">В планах</button> <div class="w-8 h-8 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold text-xs shadow-sm shrink-0">
<button onclick="filterTasksByTab('COMPLETED')" id="tab-completed" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">Готово</button> <i class="fa-solid fa-user"></i>
<button onclick="filterTasksByTab('ALL')" id="tab-all" class="task-tab-btn font-medium px-2.5 py-1 rounded-md text-slate-600 hover:bg-slate-100 transition">Все</button> </div>
</div> <div class="min-w-0 flex flex-col">
<span id="user-display-name" class="text-xs font-bold text-slate-800 truncate">Пользователь</span>
<!-- Список задач с прокруткой (ID исправлен на tasks-list) --> <span id="user-display-role" class="text-[10px] text-slate-400">Оператор</span>
<div id="tasks-list" class="flex-1 overflow-y-auto p-3 space-y-2.5">
<div class="text-center py-8 text-xs text-slate-400">Загрузка задач...</div>
</div>
<!-- Подвал панели пользователя -->
<div class="p-3 border-t border-slate-200 bg-slate-50/50 flex items-center justify-between text-xs">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-full bg-slate-300 flex items-center justify-center text-slate-700 font-bold">
<i class="fa-solid fa-user text-xs"></i>
</div>
<div class="truncate">
<span id="current-username" class="font-semibold text-slate-900 block truncate">Александр Пушков</span>
<span id="user-role-badge" class="text-[10px] text-indigo-600 font-medium">Администратор</span>
</div>
</div>
<button onclick="logout()" title="Выйти" class="p-1.5 text-slate-400 hover:text-rose-600 transition">
<i class="fa-solid fa-arrow-right-from-bracket"></i>
</button>
</div>
</aside>
<!-- Основная рабочая область чата -->
<main class="flex-1 flex flex-col min-w-0 bg-white relative h-full overflow-hidden">
<!-- Верхний заголовок чата -->
<header class="h-14 border-b border-slate-200 px-4 flex items-center justify-between bg-white shrink-0 z-10">
<div class="flex items-center gap-3">
<button type="button" onclick="toggleTaskDrawer()" class="p-1.5 text-slate-500 hover:text-indigo-600 hover:bg-slate-100 rounded-lg transition" title="Переключить боковую панель">
<i class="fa-solid fa-bars"></i>
</button>
<div>
<h1 class="font-bold text-sm text-slate-900 flex items-center gap-2">
<span>SCUD Orion AI Assistant</span>
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-100 text-emerald-800">Online</span>
</h1>
<p class="text-[11px] text-slate-500">Система интеллектуального аудита и контроля СКУД / 1С</p>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button type="button" onclick="handleActionButtonClick('покажи системный промпт')" class="px-2.5 py-1 text-slate-600 hover:text-indigo-600 hover:bg-slate-100 rounded-md border border-slate-200 transition">
<i class="fa-solid fa-terminal mr-1"></i> Промпт
</button>
<button type="button" onclick="handleActionButtonClick('покажи снапшоты')" class="px-2.5 py-1 text-slate-600 hover:text-indigo-600 hover:bg-slate-100 rounded-md border border-slate-200 transition">
<i class="fa-solid fa-camera mr-1"></i> Срезы СКУД
</button>
</div>
</header>
<!-- Drop Overlay при перетаскивании файлов -->
<div id="drop-overlay" class="hidden absolute inset-0 bg-indigo-600/10 backdrop-blur-[2px] border-2 border-dashed border-indigo-500 rounded-2xl m-4 z-50 items-center justify-center flex-col gap-2 pointer-events-none">
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
<p class="text-xs font-bold text-indigo-900">Перетащите файл сюда для отправки в диалог</p>
</div>
<!-- Окно сообщений с центрированием контента -->
<div id="chat-window" class="flex-1 p-4 overflow-y-auto bg-slate-50/50 scroll-smooth">
<!-- Центрирующая колонка для сообщений -->
<div class="max-w-4xl w-full mx-auto space-y-4">
<!-- Стартовое приветственное сообщение -->
<div class="chat-message-card bg-white border border-slate-200 rounded-2xl p-4 shadow-sm w-full">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент SCUD Orion AI
</p>
<div class="text-slate-800 text-xs sm:text-sm leading-relaxed">
Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
</div> </div>
</div> </div>
<!-- Невидимая распорка в самом низу для свободного скролла любого вопроса наверх --> <div class="flex items-center gap-1">
<div id="chat-bottom-spacer" class="min-h-[85vh] pointer-events-none w-full"></div> <button id="admin-panel-btn" type="button" onclick="openAdminModal()" class="hidden p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-slate-200 rounded-lg transition" title="Управление пользователями">
</div> <i class="fa-solid fa-users-gear text-sm"></i>
</div> </button>
<!-- Центрированная нижняя панель ввода --> <button type="button" onclick="openProfileModal()" class="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-200 rounded-lg transition" title="Сменить пароль">
<div class="p-3 bg-white border-t border-slate-200 shrink-0 z-10"> <i class="fa-solid fa-gear text-sm"></i>
<div class="max-w-4xl w-full mx-auto"> </button>
<!-- Блок предпросмотра прикрепленного файла -->
<div id="file-preview-container" class="hidden mb-2 p-2 bg-indigo-50 border border-indigo-200 rounded-xl flex items-center justify-between"> <button type="button" onclick="AuthManager.logout()" class="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition" title="Выйти из системы">
<div class="flex items-center gap-2 min-w-0"> <i class="fa-solid fa-arrow-right-from-bracket text-sm"></i>
<i class="fa-solid fa-file-arrow-up text-indigo-600 text-sm"></i> </button>
<span id="file-name-display" class="text-xs font-semibold text-slate-800 truncate"></span> </div>
<span id="file-size-display" class="text-[10px] text-slate-500"></span> </div>
</aside>
<!-- ПРАВАЯ ОБЛАСТЬ (ЧАТ И ИИ-АССИСТЕНТ) -->
<main class="flex-1 flex flex-col h-full min-w-0 bg-slate-50 relative">
<header class="h-14 bg-white border-b border-slate-200 px-4 flex items-center justify-between shrink-0 shadow-sm z-10">
<div class="flex items-center gap-2.5">
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shadow-sm">
<i class="fa-solid fa-robot text-xs"></i>
</div> </div>
<button type="button" onclick="clearAttachedFile()" class="text-slate-400 hover:text-rose-600 transition p-1"> <div>
<div class="flex items-center gap-2">
<h1 class="text-sm font-bold text-slate-800">SCUD Orion AI Assistant</h1>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">Online</span>
</div>
<p class="text-[10px] text-slate-400">Система интеллектуального аудита и контроля СКУД / 1С</p>
</div>
</div>
</header>
<!-- ЛЕНТА ЧАТА -->
<div id="chat-messages-container" class="flex-1 overflow-y-auto p-4 md:p-6 flex flex-col gap-4">
<div class="flex gap-3 max-w-4xl mx-auto w-full">
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-xs"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
<div class="text-xs text-slate-700 leading-relaxed">
Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
</div>
</div>
</div>
</div>
<!-- БЕЙДЖ ПРИКРЕПЛЕННОГО ФАЙЛА -->
<div id="file-attachment-preview" class="hidden max-w-4xl mx-auto w-full px-4 pt-2">
<div class="inline-flex items-center gap-2 px-3 py-1 bg-indigo-50 border border-indigo-200 rounded-xl text-xs text-indigo-700 shadow-sm">
<i class="fa-solid fa-file text-indigo-600"></i>
<span id="file-attachment-name" class="font-medium truncate max-w-xs"></span>
<button type="button" onclick="clearAttachedFile()" class="text-indigo-400 hover:text-rose-600 ml-1">
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
</button> </button>
</div> </div>
</div>
<!-- Форма отправки --> <!-- Контейнер строки ввода сообщения -->
<form id="chat-form" onsubmit="sendMessage(event)" class="flex items-end gap-2"> <div class="px-4 py-2 bg-white border-t border-slate-200">
<input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" /> <div id="chat-input-box" class="max-w-4xl mx-auto w-full flex items-center gap-2 bg-slate-50 border border-slate-300 rounded-xl px-2.5 py-1 transition focus-within:border-indigo-500 focus-within:bg-white focus-within:ring-1 focus-within:ring-indigo-100">
<!-- Скрепка файлов -->
<button type="button" onclick="document.getElementById('file-input').click()" <button type="button" onclick="document.getElementById('file-upload-input').click()" class="text-slate-400 hover:text-indigo-600 p-1 transition shrink-0">
title="Прикрепить файл" <i class="fa-solid fa-paperclip text-xs"></i>
class="p-2.5 text-slate-500 hover:text-indigo-600 hover:bg-slate-100 rounded-xl transition shrink-0 cursor-pointer">
<i class="fa-solid fa-paperclip text-sm"></i>
</button> </button>
<input type="file" id="file-upload-input" class="hidden" />
<div class="flex-1 bg-slate-100 border border-slate-300 focus-within:border-indigo-600 focus-within:bg-white rounded-2xl p-1.5 transition flex items-center shadow-inner"> <!-- Поле ввода -->
<textarea id="user-input" rows="1" <textarea id="user-input" rows="1" placeholder="Команда, вопрос (Enter - отправить, Shift+Enter - перенос строки)..."
placeholder="Команда, вопрос или перетащите файл сюда..." class="flex-1 bg-transparent border-0 focus:outline-none text-xs text-slate-800 resize-none py-0 leading-5" style="height: 24px; line-height: 24px;"></textarea>
class="w-full bg-transparent px-2 text-xs sm:text-sm text-slate-800 focus:outline-none resize-none overflow-y-auto leading-relaxed"
style="height: 24px; max-height: 120px;"></textarea> <!-- Кнопка отправки -->
<button type="button" onclick="window.sendMessage()" class="w-6 h-6 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white flex items-center justify-center shrink-0 shadow-sm transition">
<i class="fa-solid fa-paper-plane text-[10px]"></i>
</button>
</div>
</div>
</main>
</div>
<!-- МОДАЛЬНОЕ ОКНО: УДАЛЕННЫЙ СОТРУДНИК -->
<div id="remote-worker-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 id="remote-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-house-laptop text-emerald-600"></i>
<span>Параметры удаленной работы</span>
</h3>
<button type="button" onclick="closeRemoteWorkerModal()" class="text-slate-400 hover:text-slate-600">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<form id="remote-worker-form" onsubmit="handleRemoteWorkerSubmit(event)" class="flex flex-col gap-3">
<input type="hidden" id="rw-mode" value="ADD" />
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
<input type="text" id="rw-fio" required placeholder="Например: Иванов Иван Иванович"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Подразделение:</label>
<input type="text" id="rw-dept" placeholder="Все"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата начала:</label>
<input type="date" id="rw-date-from"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = с сегодняшнего дня</span>
</div> </div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Дата окончания:</label>
<input type="date" id="rw-date-to"
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-slate-50 text-slate-700" />
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = бессрочно</span>
</div>
</div>
<button type="submit" id="send-btn" <div id="rw-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white p-2.5 rounded-xl transition shrink-0 shadow-sm cursor-pointer">
<i class="fa-solid fa-paper-plane text-sm"></i> <div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
<button type="button" onclick="closeRemoteWorkerModal()"
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
Отмена
</button> </button>
</form> <button type="submit" id="rw-submit-btn"
class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition">
Сохранить
</button>
</div>
</form>
</div>
</div>
<!-- МОДАЛЬНОЕ ОКНО АВТОРИЗАЦИИ -->
<div id="auth-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-indigo-600 text-white flex items-center justify-center font-bold text-lg shadow">
<i class="fa-solid fa-shield-halved"></i>
</div>
<div>
<h2 class="text-sm font-bold text-slate-800">Авторизация в системе</h2>
<p class="text-[11px] text-slate-400">SCUD Orion AI Security Access</p>
</div>
</div>
<form id="auth-form" onsubmit="handleLoginSubmit(event)" class="flex flex-col gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Имя пользователя:</label>
<input type="text" id="auth-username" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" placeholder="Логин" />
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Пароль:</label>
<input type="password" id="auth-password" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" placeholder="••••••••" />
</div>
<div id="auth-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
<button type="submit" class="w-full py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg text-xs shadow transition mt-1">
Войти в систему
</button>
</form>
</div>
</div>
<!-- МОДАЛЬНОЕ ОКНО СМЕНЫ ПАРОЛЯ -->
<div id="profile-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
</h3>
<button onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
</div>
<form onsubmit="handleChangePassword(event)" class="flex flex-col gap-3">
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Текущий пароль:</label>
<input type="password" id="old-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-[11px] font-bold text-slate-600 mb-1">Новый пароль (мин. 4 симв.):</label>
<input type="password" id="new-pass" required class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500" />
</div>
<div id="pass-error" class="text-[11px] font-semibold text-rose-600 hidden"></div>
<div class="flex items-center justify-end gap-2 mt-2">
<button type="button" onclick="closeProfileModal()" class="px-3 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium">Отмена</button>
<button type="submit" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">Сохранить</button>
</div>
</form>
</div>
</div>
<!-- МОДАЛЬНОЕ ОКНО ПАНЕЛИ АДМИНИСТРАТОРА -->
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-lg w-full p-6 flex flex-col gap-4 max-h-[85vh]">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление учетными записями
</h3>
<button onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-600"><i class="fa-solid fa-xmark"></i></button>
</div>
<form onsubmit="handleCreateUser(event)" class="p-3 bg-slate-50 border border-slate-200 rounded-xl flex flex-col gap-2">
<span class="text-xs font-bold text-slate-700">Создать нового пользователя:</span>
<div class="grid grid-cols-2 gap-2">
<input type="text" id="new-user-username" placeholder="Логин" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
<input type="password" id="new-user-password" placeholder="Пароль" required class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
</div>
<input type="text" id="new-user-fullname" placeholder="ФИО" class="text-xs px-2.5 py-1.5 border border-slate-300 rounded-lg bg-white" />
<div class="flex items-center justify-between">
<label class="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer">
<input type="checkbox" id="new-user-admin" class="rounded border-slate-300 text-indigo-600" />
<span>Права администратора</span>
</label>
<button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow">
Создать
</button>
</div>
</form>
<div class="flex-1 overflow-y-auto flex flex-col gap-1.5" id="admin-users-list">
<div class="text-center py-4 text-xs text-slate-400">Загрузка пользователей...</div>
</div> </div>
</div> </div>
</main> </div>
<!-- Скрипты клиентской логики --> <!-- ПОДКЛЮЧЕНИЕ СКРИПТОВ (Версия v=2.5.6) -->
<script src="/js/auth.js"></script> <script src="/static/js/auth.js?v=2.5.6"></script>
<script src="/js/tasks.js"></script> <script src="/static/js/tasks.js?v=2.5.6"></script>
<script src="/js/chat/task_widget.js"></script> <script src="/static/js/sidebar.js?v=2.5.6"></script>
<script src="/js/chat/core.js"></script> <script src="/static/js/chat/task_widget.js?v=2.5.6"></script>
<script src="/js/app.js"></script> <script src="/static/js/chat/core.js?v=2.5.6"></script>
<script src="/static/js/app.js?v=2.5.6"></script>
<script>
function showAuthModal() {
const modal = document.getElementById("auth-modal");
if (modal) modal.classList.remove("hidden");
}
function hideAuthModal() {
const modal = document.getElementById("auth-modal");
if (modal) modal.classList.add("hidden");
}
function updateUIState() {
const nameEl = document.getElementById("user-display-name");
const roleEl = document.getElementById("user-display-role");
const adminBtn = document.getElementById("admin-panel-btn");
if (nameEl) nameEl.innerText = AuthManager.getFullName();
if (roleEl) roleEl.innerText = AuthManager.isAdmin() ? "Администратор" : "Оператор";
if (adminBtn) {
if (AuthManager.isAdmin()) {
adminBtn.classList.remove("hidden");
} else {
adminBtn.classList.add("hidden");
}
}
}
async function handleLoginSubmit(e) {
e.preventDefault();
const uInput = document.getElementById("auth-username");
const pInput = document.getElementById("auth-password");
const errEl = document.getElementById("auth-error");
errEl.classList.add("hidden");
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: uInput.value.trim(),
password: pInput.value
})
});
if (res.ok) {
const data = await res.json();
AuthManager.setSession(data.token, data.username, data.full_name, data.is_admin, data.user_id);
hideAuthModal();
updateUIState();
if (window.SidebarManager) SidebarManager.setHub('TASKS');
} else {
const err = await res.json();
errEl.innerText = err.detail || "Неверный логин или пароль";
errEl.classList.remove("hidden");
}
} catch (err) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
function openProfileModal() {
document.getElementById("profile-modal").classList.remove("hidden");
}
function closeProfileModal() {
document.getElementById("profile-modal").classList.add("hidden");
}
async function handleChangePassword(e) {
e.preventDefault();
const oldP = document.getElementById("old-pass").value;
const newP = document.getElementById("new-pass").value;
const errEl = document.getElementById("pass-error");
errEl.classList.add("hidden");
try {
const res = await fetch("/api/v1/auth/change-password", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ old_password: oldP, new_password: newP })
});
if (res.ok) {
alert("Пароль успешно изменен");
closeProfileModal();
} else {
const err = await res.json();
errEl.innerText = err.detail || "Ошибка изменения пароля";
errEl.classList.remove("hidden");
}
} catch (e) {
errEl.innerText = "Ошибка сети";
errEl.classList.remove("hidden");
}
}
function openAdminModal() {
document.getElementById("admin-modal").classList.remove("hidden");
loadAdminUsers();
}
function closeAdminModal() {
document.getElementById("admin-modal").classList.add("hidden");
}
async function loadAdminUsers() {
const listEl = document.getElementById("admin-users-list");
listEl.innerHTML = `<div class="text-center py-4 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка...</div>`;
try {
const res = await fetch("/api/v1/admin/users", { headers: AuthManager.getAuthHeaders() });
if (res.ok) {
const users = await res.json();
listEl.innerHTML = users.map(u => `
<div class="flex items-center justify-between p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs">
<div>
<div class="font-bold text-slate-800">${escapeHtml(u.full_name || u.username)} <span class="text-slate-400 font-mono text-[10px]">(${escapeHtml(u.username)})</span></div>
<div class="text-[10px] ${u.is_admin ? 'text-indigo-600 font-bold' : 'text-slate-400'}">${u.is_admin ? 'Администратор' : 'Оператор'}</div>
</div>
<button onclick="deleteAdminUser(${u.id}, '${escapeHtml(u.username)}')" class="text-slate-400 hover:text-rose-600 p-1.5" title="Удалить">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
`).join('');
} else {
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка загрузки пользователей</div>`;
}
} catch (e) {
listEl.innerHTML = `<div class="text-center py-4 text-xs text-rose-500">Ошибка сети</div>`;
}
}
async function handleCreateUser(e) {
e.preventDefault();
const u = document.getElementById("new-user-username").value;
const p = document.getElementById("new-user-password").value;
const f = document.getElementById("new-user-fullname").value;
const a = document.getElementById("new-user-admin").checked;
try {
const res = await fetch("/api/v1/admin/users", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ username: u, password: p, full_name: f, is_admin: a })
});
if (res.ok) {
document.getElementById("new-user-username").value = "";
document.getElementById("new-user-password").value = "";
document.getElementById("new-user-fullname").value = "";
document.getElementById("new-user-admin").checked = false;
loadAdminUsers();
} else {
const err = await res.json();
alert(err.detail || "Ошибка создания пользователя");
}
} catch (e) {
alert("Ошибка сети");
}
}
async function deleteAdminUser(id, username) {
if (!confirm(`Удалить пользователя ${username}?`)) return;
try {
const res = await fetch(`/api/v1/admin/users/${id}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) {
loadAdminUsers();
} else {
const err = await res.json();
alert(err.detail || "Ошибка удаления");
}
} catch (e) {
alert("Ошибка сети");
}
}
function dmyToYmd(str) {
if (!str) return "";
const parts = str.replace(/_/g, '.').split('.');
if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
return "";
}
function ymdToDmy(str) {
if (!str) return "";
const parts = str.split('-');
if (parts.length === 3) return `${parts[2]}.${parts[1]}.${parts[0]}`;
return "";
}
function openRemoteWorkerModal(mode = 'ADD', fio = '', dept = 'Все', dateFrom = '', dateTo = '') {
const modal = document.getElementById("remote-worker-modal");
const titleEl = document.getElementById("remote-modal-title");
const modeInput = document.getElementById("rw-mode");
const fioInput = document.getElementById("rw-fio");
const deptInput = document.getElementById("rw-dept");
const fromInput = document.getElementById("rw-date-from");
const toInput = document.getElementById("rw-date-to");
const errEl = document.getElementById("rw-error");
errEl.classList.add("hidden");
modeInput.value = mode;
if (mode === 'EDIT') {
titleEl.innerHTML = `<i class="fa-solid fa-pen-to-square text-emerald-600"></i><span>Изменение сроков удаленки</span>`;
fioInput.value = fio;
fioInput.readOnly = true;
fioInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
deptInput.value = dept || "Все";
deptInput.readOnly = true;
deptInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
fromInput.value = dmyToYmd(dateFrom);
toInput.value = dmyToYmd(dateTo);
} else {
titleEl.innerHTML = `<i class="fa-solid fa-house-laptop text-emerald-600"></i><span>Добавление удаленщика</span>`;
fioInput.value = "";
fioInput.readOnly = false;
fioInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
deptInput.value = "Все";
deptInput.readOnly = false;
deptInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
const today = new Date().toISOString().split('T')[0];
fromInput.value = today;
toInput.value = "";
}
modal.classList.remove("hidden");
}
function closeRemoteWorkerModal() {
document.getElementById("remote-worker-modal").classList.add("hidden");
}
async function handleRemoteWorkerSubmit(e) {
e.preventDefault();
const mode = document.getElementById("rw-mode").value;
const fio = document.getElementById("rw-fio").value.trim();
const dept = document.getElementById("rw-dept").value.trim() || "Все";
const fromVal = ymdToDmy(document.getElementById("rw-date-from").value);
const toVal = ymdToDmy(document.getElementById("rw-date-to").value);
const errEl = document.getElementById("rw-error");
errEl.classList.add("hidden");
const method = (mode === 'EDIT') ? "PUT" : "POST";
const payload = (mode === 'EDIT')
? { fio: fio, date_from: fromVal, date_to: toVal }
: { fio: fio, department: dept, reason: "Удаленная работа", date_from: fromVal, date_to: toVal };
try {
const res = await fetch("/api/v1/remote-workers", {
method: method,
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify(payload)
});
if (res.ok) {
closeRemoteWorkerModal();
if (window.SidebarManager) SidebarManager.renderContent();
} else {
const err = await res.json();
errEl.innerText = err.detail || "Ошибка сохранения";
errEl.classList.remove("hidden");
}
} catch (err) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
document.addEventListener("DOMContentLoaded", () => {
if (window.AuthManager && AuthManager.isAuthenticated()) {
updateUIState();
}
});
</script>
<!-- Глобальная Drag-and-Drop зона на весь экран -->
<div id="global-drag-overlay"
class="fixed inset-0 bg-indigo-900/40 backdrop-blur-xs z-50 hidden flex items-center justify-center pointer-events-none transition-all duration-200">
<div class="bg-white border-2 border-dashed border-indigo-500 rounded-3xl p-10 flex flex-col items-center gap-3 shadow-2xl scale-100 transition-transform">
<div class="w-16 h-16 rounded-2xl bg-indigo-50 text-indigo-600 flex items-center justify-center text-3xl shadow-inner">
<i class="fa-solid fa-cloud-arrow-up animate-bounce"></i>
</div>
<div class="text-base font-bold text-slate-800">Перетащите файл в любую точку окна</div>
<div class="text-xs text-slate-500 font-medium">PDF-документы, сканы, отчеты или изображения</div>
</div>
</div>
</body> </body>
</html> </html>
+50 -37
View File
@@ -1,62 +1,75 @@
/** /**
* =============================================================================== * ===============================================================================
* FILE: static/js/auth.js * FILE: modules/web_api/static/js/auth.js
* ROLE: Управление сессией пользователя, токенами и корректным выходом (Logout). * ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков.
* =============================================================================== * ===============================================================================
*/ */
const AUTH_STORAGE_KEY = "scud_auth_token";
const USERNAME_STORAGE_KEY = "scud_username";
const FULLNAME_STORAGE_KEY = "scud_full_name";
const IS_ADMIN_STORAGE_KEY = "scud_is_admin";
const USER_ID_STORAGE_KEY = "scud_user_id";
const AuthManager = { const AuthManager = {
getToken() { getToken() {
let token = localStorage.getItem("auth_token") || localStorage.getItem("token"); return localStorage.getItem(AUTH_STORAGE_KEY) || "";
if (!token) {
// Если токена нет — инициализируем рабочий дефолтный токен
token = "dev_token_1";
localStorage.setItem("auth_token", token);
localStorage.setItem("user_id", "1");
}
return token;
}, },
getUserId() { getUserId() {
return parseInt(localStorage.getItem("user_id") || "1", 10); const uid = localStorage.getItem(USER_ID_STORAGE_KEY);
return uid ? parseInt(uid, 10) : 1;
},
getUsername() {
return localStorage.getItem(USERNAME_STORAGE_KEY) || "";
},
getFullName() {
return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь";
},
isAdmin() {
return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true";
},
isAuthenticated() {
return Boolean(this.getToken());
},
setSession(token, username, fullName, isAdmin, userId = 1) {
localStorage.setItem(AUTH_STORAGE_KEY, token);
localStorage.setItem(USERNAME_STORAGE_KEY, username);
localStorage.setItem(FULLNAME_STORAGE_KEY, fullName || username);
localStorage.setItem(IS_ADMIN_STORAGE_KEY, String(isAdmin));
localStorage.setItem(USER_ID_STORAGE_KEY, String(userId));
localStorage.setItem("scud_api_auth_token", token);
localStorage.setItem("auth_token", token);
},
getAuthHeaders() {
const token = this.getToken();
const headers = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
}, },
logout() { logout() {
console.log("[Auth] Выполняется выход из учетной записи..."); console.log("[Auth] Полный выход из системы...");
localStorage.clear();
// 1. Полная очистка хранилищ браузера
localStorage.removeItem("auth_token");
localStorage.removeItem("token");
localStorage.removeItem("user_id");
sessionStorage.clear(); sessionStorage.clear();
// 2. Сброс авторизационных cookies (если присутствуют)
document.cookie.split(";").forEach((cookie) => { document.cookie.split(";").forEach((cookie) => {
const eqPos = cookie.indexOf("="); const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`; document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
}); });
// 3. Перезагрузка страницы для сброса состояния интерфейса window.location.href = "/";
window.location.reload();
},
init() {
// Привязка клика ко всем элементам с классом или id logout
document.addEventListener("DOMContentLoaded", () => {
const logoutBtns = document.querySelectorAll("#logout-btn, .logout-btn, [data-action='logout']");
logoutBtns.forEach(btn => {
btn.addEventListener("click", (e) => {
e.preventDefault();
AuthManager.logout();
});
});
});
} }
}; };
// Инициализация при загрузке скрипта window.AuthManager = AuthManager;
AuthManager.init();
// Глобальная доступность функции для onclick в HTML
window.logout = () => AuthManager.logout(); window.logout = () => AuthManager.logout();
+575 -56
View File
@@ -1,67 +1,586 @@
/** /**
* =============================================================================== * ===============================================================================
* FILE: static/js/chat/core.js * FILE: modules/web_api/static/js/chat/core.js
* ROLE: Отправка сообщений в API с токеном и обработка ошибок. * ROLE: Ядро чата: полноэкранный Drag-and-Drop оверлей, авто-высота инпута (24px),
* надежный расчет скролла вопроса к верху окна, крупный шрифт text-sm.
* =============================================================================== * ===============================================================================
*/ */
async function sendMessage(userMessageText) { let currentAttachedFile = null;
const chatInput = document.getElementById("chat-input");
const message = userMessageText || (chatInput ? chatInput.value.trim() : "");
if (!message) return;
if (chatInput && !userMessageText) { const CHAT_INPUT_STORAGE_KEY = "scud_chat_input_history";
chatInput.value = ""; let chatInputHistory = JSON.parse(localStorage.getItem(CHAT_INPUT_STORAGE_KEY) || "[]");
let chatHistoryIndex = -1;
let temporaryCurrentInput = "";
function saveCommandToHistory(commandText) {
if (!commandText || !commandText.trim()) return;
const cleanCmd = commandText.trim();
if (cleanCmd.startsWith("action:save_draft_")) return;
chatInputHistory = chatInputHistory.filter(item => item !== cleanCmd);
chatInputHistory.push(cleanCmd);
if (chatInputHistory.length > 50) chatInputHistory.shift();
localStorage.setItem(CHAT_INPUT_STORAGE_KEY, JSON.stringify(chatInputHistory));
chatHistoryIndex = -1;
}
// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: плавное выравнивание вопроса к верхней границе
function scrollToUserMessageTop() {
const container = document.getElementById("chat-messages-container");
if (!container) return;
const userBubbles = container.querySelectorAll(".user-chat-bubble");
const targetEl = userBubbles[userBubbles.length - 1] || container.lastElementChild;
if (!targetEl) return;
requestAnimationFrame(() => {
setTimeout(() => {
const containerTop = container.getBoundingClientRect().top;
const targetTop = targetEl.getBoundingClientRect().top;
const targetScroll = container.scrollTop + (targetTop - containerTop) - 16;
container.scrollTo({
top: Math.max(0, targetScroll),
behavior: 'smooth'
});
}, 50);
});
}
function updateInputHeightAndFade(textarea) {
if (!textarea) return;
if (!textarea.value || textarea.value.trim() === '') {
textarea.style.height = '24px';
textarea.style.overflowY = 'hidden';
textarea.style.maskImage = 'none';
textarea.style.webkitMaskImage = 'none';
return;
} }
// Отображаем сообщение пользователя в чате textarea.style.height = 'auto';
if (typeof appendMessageToUI === "function") { const minHeight = 24;
appendMessageToUI("user", message); const maxHeight = 120;
} const currentScrollHeight = textarea.scrollHeight;
// Подготовка заголовков с гарантированным токеном if (currentScrollHeight <= minHeight + 2) {
const token = (typeof AuthManager !== "undefined") ? AuthManager.getToken() : (localStorage.getItem("auth_token") || "dev_token_1"); textarea.style.height = minHeight + 'px';
const userId = (typeof AuthManager !== "undefined") ? AuthManager.getUserId() : parseInt(localStorage.getItem("user_id") || "1", 10); textarea.style.overflowY = 'hidden';
const sessionId = localStorage.getItem("chat_session_id") || "web_session_main"; textarea.style.maskImage = 'none';
textarea.style.webkitMaskImage = 'none';
try { } else if (currentScrollHeight > maxHeight) {
const response = await fetch("/api/v1/chat", { textarea.style.height = maxHeight + 'px';
method: "POST", textarea.style.overflowY = 'auto';
headers: { textarea.style.maskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
"Content-Type": "application/json", textarea.style.webkitMaskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
"Authorization": `Bearer ${token}` } else {
}, textarea.style.height = currentScrollHeight + 'px';
body: JSON.stringify({ textarea.style.overflowY = 'hidden';
message: message, textarea.style.maskImage = 'none';
session_id: sessionId, textarea.style.webkitMaskImage = 'none';
user_id: userId
})
});
if (response.status === 403) {
console.warn("[Chat] Получен 403 Forbidden. Сбрасываем сессию и повторяем...");
localStorage.removeItem("auth_token");
if (typeof appendMessageToUI === "function") {
appendMessageToUI("assistant", "⚠️ Сессия была обновлена. Пожалуйста, отправьте сообщение повторно.");
}
return;
}
if (!response.ok) {
throw new Error(`Ошибка сервера (HTTP ${response.status})`);
}
const data = await response.json();
const reply = data.response || data.message || "Ответ получен без текста.";
if (typeof appendMessageToUI === "function") {
appendMessageToUI("assistant", reply);
}
} catch (err) {
console.error("[Chat Error]:", err);
if (typeof appendMessageToUI === "function") {
appendMessageToUI("assistant", `⚠️ Не удалось связаться с сервером: ${err.message}`);
}
} }
} }
function appendUserMessage(text, filename = null) {
const container = document.getElementById("chat-messages-container");
if (!container) return;
const msgId = 'user-msg-' + Date.now();
let fileBadge = '';
if (filename) {
fileBadge = `
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 mb-2 bg-indigo-700/80 rounded-lg text-xs font-semibold text-white shadow-xs">
<i class="fa-solid fa-paperclip text-xs"></i>
<span class="truncate max-w-xs">${escapeHtml(filename)}</span>
</div>
`;
}
const msgHtml = `
<div id="${msgId}" class="user-chat-bubble flex gap-3 max-w-4xl mx-auto w-full justify-end pt-3 scroll-mt-4">
<div class="flex-1 max-w-2xl bg-indigo-600 text-white rounded-2xl rounded-tr-none p-4 shadow-sm">
${fileBadge}
<div class="text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(text)}</div>
</div>
<div class="w-8 h-8 rounded-lg bg-slate-200 text-slate-600 flex items-center justify-center shrink-0 shadow-sm mt-0.5 font-bold text-sm">
<i class="fa-solid fa-user"></i>
</div>
</div>
`;
container.insertAdjacentHTML("beforeend", msgHtml);
}
function appendAssistantLoading() {
const container = document.getElementById("chat-messages-container");
if (!container) return null;
const loadingId = 'loading-' + Date.now();
const html = `
<div id="${loadingId}" class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-sm"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-sm text-slate-500 flex items-center gap-2">
<i class="fa-solid fa-spinner fa-spin text-indigo-600"></i>
<span>ИИ обрабатывает запрос...</span>
</div>
</div>
</div>
`;
container.insertAdjacentHTML("beforeend", html);
return loadingId;
}
function appendAssistantMessage(text, buttons = [], actionPayload = null) {
const container = document.getElementById("chat-messages-container");
if (!container) return;
let payloadHtml = '';
let isHtmlBody = false;
if (actionPayload) {
if (actionPayload.type === 'SNAPSHOTS_CARD' && typeof renderSnapshotsCard === 'function') {
payloadHtml = renderSnapshotsCard(actionPayload.data);
} else if (actionPayload.type === 'TASK_INTERACTIVE_CARD' && typeof renderInteractiveTaskCard === 'function') {
payloadHtml = renderInteractiveTaskCard(actionPayload.tasks);
} else if (actionPayload.type === 'FILE_DOWNLOAD_CARD') {
const dlUrl = actionPayload.download_url || '#';
const fName = actionPayload.filename || 'ROADMAP.md';
const count = actionPayload.tasks_count || '';
payloadHtml = `
<div class="mt-3 p-3.5 bg-indigo-50/80 border border-indigo-200 rounded-xl flex items-center justify-between gap-3 shadow-sm">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm">
<i class="fa-solid fa-file-lines text-base"></i>
</div>
<div class="min-w-0">
<div class="text-sm font-bold text-slate-800 truncate">${escapeHtml(fName)}</div>
<div class="text-xs text-slate-500">Задач выгружено: ${count} шт. · Markdown</div>
</div>
</div>
<a href="${dlUrl}" download="${escapeHtml(fName)}" target="_blank"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-lg text-xs font-bold shadow-sm transition flex items-center gap-1.5 shrink-0">
<i class="fa-solid fa-download text-xs"></i>
<span>Скачать файл</span>
</a>
</div>
`;
} else if (actionPayload.type === 'SNAPSHOT_INSPECT_CARD') {
const records = actionPayload.records || [];
const inspectTableId = 'inspect-table-' + Date.now();
const searchInputId = 'inspect-search-' + Date.now();
const rowsHtml = records.map((r, idx) => `
<tr class="inspect-row border-b border-slate-100 text-xs ${r.is_present ? 'bg-white' : 'bg-slate-50/60'} hover:bg-indigo-50/40"
data-fio="${escapeHtml(r.fio).toLowerCase()}" data-dept="${escapeHtml(r.department).toLowerCase()}">
<td class="p-2.5 text-slate-400 text-center font-mono w-10">${idx + 1}</td>
<td class="p-2.5 font-medium text-slate-800">${escapeHtml(r.fio)}</td>
<td class="p-2.5 text-slate-500 text-center">${escapeHtml(r.department)}</td>
<td class="p-2.5 text-center ${r.time_in !== 'Нет входа' ? 'font-bold text-emerald-700' : 'text-slate-400'}">${escapeHtml(r.time_in)}</td>
<td class="p-2.5 text-center text-slate-500">${escapeHtml(r.first_activity)}</td>
<td class="p-2.5 text-center ${r.time_out !== 'Нет выхода' ? 'font-bold text-slate-800' : 'text-slate-400'}">${escapeHtml(r.time_out)}</td>
<td class="p-2.5 text-center font-mono text-slate-600">${escapeHtml(r.in_building)}</td>
<td class="p-2.5 text-center font-bold">${r.is_present ? '<span class="text-emerald-600">✓ Да</span>' : '<span class="text-slate-400">Нет</span>'}</td>
</tr>
`).join('');
payloadHtml = `
<div class="mt-3 bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm flex flex-col">
<div class="px-4 py-3 bg-slate-100/90 border-b border-slate-200 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-800">Срез #${escapeHtml(actionPayload.snapshot_id)}</span>
<span class="text-xs text-slate-500">· Всего: ${records.length} чел. (Присутствуют: ${actionPayload.present_count || 0})</span>
</div>
<div class="flex items-center gap-2">
<input type="text" id="${searchInputId}" placeholder="Поиск в срезе (ФИО / отдел)..."
oninput="window.filterInspectTable('${inspectTableId}', this.value)"
class="text-xs px-3 py-1.5 bg-white border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 w-60" />
<button onclick="window.sendChatAction('покажи срезы')" class="text-xs text-indigo-600 hover:underline font-semibold">Все срезы</button>
</div>
</div>
<div class="max-h-96 overflow-y-auto">
<table id="${inspectTableId}" class="w-full text-left border-collapse">
<thead class="bg-slate-50 text-[11px] uppercase text-slate-500 sticky top-0 border-b border-slate-200 shadow-xs">
<tr>
<th class="p-2.5 text-center w-10">№</th>
<th class="p-2.5">Сотрудник</th>
<th class="p-2.5 text-center">Отдел</th>
<th class="p-2.5 text-center">Вход</th>
<th class="p-2.5 text-center">Активность</th>
<th class="p-2.5 text-center">Выход</th>
<th class="p-2.5 text-center">В здании</th>
<th class="p-2.5 text-center">Статус</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>
</div>
`;
} else if (actionPayload.type === 'PROMPT_EDITOR') {
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
const editorId = 'prompt-editor-' + Date.now();
payloadHtml = `
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
<div class="flex items-center justify-between text-xs font-bold text-slate-700">
<span><i class="fa-solid fa-pen-to-square text-indigo-600 mr-1"></i> Инлайн-редактор системного промпта:</span>
<span class="text-[11px] text-slate-400 font-normal">Прямое редактирование текста</span>
</div>
<textarea id="${editorId}" rows="14"
class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
<button type="button" onclick="window.submitPromptDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
<i class="fa-solid fa-eye text-xs"></i>
<span>Показать превью изменений</span>
</button>
</div>
</div>
`;
} else if (actionPayload.type === 'RULES_EDITOR') {
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
const editorId = 'rules-editor-' + Date.now();
payloadHtml = `
<div class="mt-3 p-3.5 bg-slate-50 border border-slate-300 rounded-xl flex flex-col gap-2 shadow-inner">
<div class="flex items-center justify-between text-xs font-bold text-slate-700">
<span><i class="fa-solid fa-book-bookmark text-emerald-600 mr-1"></i> Редактор базы знаний и правил компании:</span>
<span class="text-[11px] text-slate-400 font-normal">Прямое изменение правил кадрового арбитража</span>
</div>
<textarea id="${editorId}" rows="12"
class="w-full text-xs font-mono p-3 border border-slate-300 rounded-lg focus:outline-none focus:border-emerald-500 bg-white leading-relaxed resize-y">${escapeHtml(draftText)}</textarea>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" onclick="window.sendChatAction('отмена')" class="px-3.5 py-1.5 text-xs text-slate-600 hover:bg-slate-200 rounded-lg transition font-medium">Отменить</button>
<button type="button" onclick="window.submitRulesDraftToDiff('${editorId}')" class="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
<i class="fa-solid fa-eye text-xs"></i>
<span>Показать превью изменений</span>
</button>
</div>
</div>
`;
} else if (actionPayload.type === 'PROMPT_PREVIEW') {
isHtmlBody = true;
}
}
let buttonsHtml = '';
if (!actionPayload || (actionPayload.type !== 'PROMPT_EDITOR' && actionPayload.type !== 'RULES_EDITOR')) {
const allButtons = (buttons && buttons.length > 0) ? buttons : (actionPayload && actionPayload.buttons ? actionPayload.buttons : []);
if (allButtons && Array.isArray(allButtons) && allButtons.length > 0) {
buttonsHtml = `
<div class="flex flex-wrap gap-2 mt-3.5 pt-2.5 border-t border-slate-100">
${allButtons.map(b => `
<button onclick="window.sendChatAction('${escapeHtml(b.value || b.action || b.title || '')}')"
class="px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg text-xs font-semibold border border-indigo-200 transition">
${escapeHtml(b.label || b.title || b.action)}
</button>
`).join('')}
</div>
`;
}
}
const bodyContent = isHtmlBody ? text : escapeHtml(text);
const html = `
<div class="flex gap-3 max-w-4xl mx-auto w-full pt-1">
<div class="w-8 h-8 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-sm"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm min-w-0">
<div class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1.5">ИИ-ассистент SCUD Orion AI</div>
<div class="text-sm text-slate-800 leading-relaxed whitespace-pre-wrap">${bodyContent}</div>
${payloadHtml}
${buttonsHtml}
</div>
</div>
`;
container.insertAdjacentHTML("beforeend", html);
}
window.filterInspectTable = function(tableId, query) {
const table = document.getElementById(tableId);
if (!table) return;
const q = (query || '').trim().toLowerCase();
const rows = table.querySelectorAll('.inspect-row');
rows.forEach(r => {
const fio = r.getAttribute('data-fio') || '';
const dept = r.getAttribute('data-dept') || '';
if (!q || fio.includes(q) || dept.includes(q)) {
r.classList.remove('hidden');
} else {
r.classList.add('hidden');
}
});
};
window.sendChatAction = function(actionText) {
if (!actionText || !actionText.trim()) return;
const input = document.getElementById("user-input");
if (input) {
input.value = actionText.trim();
updateInputHeightAndFade(input);
}
window.sendMessage();
};
window.submitPromptDraftToDiff = function(editorId) {
const textarea = document.getElementById(editorId);
if (!textarea) return;
const newText = textarea.value.trim();
if (!newText) {
alert("Текст системного промпта не может быть пустым");
return;
}
const command = `action:save_draft_prompt:::${newText}`;
const input = document.getElementById("user-input");
if (input) input.value = command;
window.sendMessage();
};
window.submitRulesDraftToDiff = function(editorId) {
const textarea = document.getElementById(editorId);
if (!textarea) return;
const newText = textarea.value.trim();
if (!newText) {
alert("Правила не могут быть пустыми");
return;
}
const command = `action:save_draft_rules:::${newText}`;
const input = document.getElementById("user-input");
if (input) input.value = command;
window.sendMessage();
};
window.sendMessage = async function() {
const input = document.getElementById("user-input");
if (!input) return;
const messageText = input.value.trim();
const fileToSend = currentAttachedFile;
if (!messageText && !fileToSend) return;
saveCommandToHistory(messageText);
input.value = "";
input.style.height = '24px';
input.style.overflowY = 'hidden';
input.style.maskImage = 'none';
input.style.webkitMaskImage = 'none';
window.clearAttachedFile();
// 1. Отрисовка сообщения пользователя в ленте
if (!messageText.startsWith("action:save_draft_prompt:::") && !messageText.startsWith("action:save_draft_rules:::")) {
appendUserMessage(
messageText || (fileToSend ? `Прикреплен файл: ${fileToSend.name}` : ''),
fileToSend ? fileToSend.name : null
);
} else {
appendUserMessage("Сформировать предпросмотр изменений");
}
// 2. Вставка лоадера
const loadingId = appendAssistantLoading();
// 3. Вызов точного скролла
scrollToUserMessageTop();
try {
let res;
if (fileToSend) {
const formData = new FormData();
formData.append("file", fileToSend);
formData.append("message", messageText);
formData.append("session_id", "web_session_main");
res = await fetch("/api/v1/chat/upload", {
method: "POST",
headers: {
"Authorization": AuthManager.getAuthHeaders()["Authorization"]
},
body: formData
});
if (res.status === 404) {
res = await fetch("/api/v1/chat", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
message: messageText || `Загружен файл: ${fileToSend.name}`,
session_id: "web_session_main"
})
});
}
} else {
res = await fetch("/api/v1/chat", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
message: messageText,
session_id: "web_session_main"
})
});
}
const loaderEl = document.getElementById(loadingId);
if (loaderEl) loaderEl.remove();
if (res.ok) {
const data = await res.json();
const replyText = data.response || data.text || data.message || "Запрос выполнен.";
const buttons = data.buttons || [];
const actionPayload = data.action_payload || null;
appendAssistantMessage(replyText, buttons, actionPayload);
if (window.SidebarManager) {
SidebarManager.renderContent();
}
} else {
const err = await res.json().catch(() => ({}));
appendAssistantMessage(`⚠️ Ошибка сервера (${res.status}): ${err.detail || "Не удалось получить ответ"}`);
}
} catch (err) {
console.error("Ошибка отправки сообщения:", err);
const loaderEl = document.getElementById(loadingId);
if (loaderEl) loaderEl.remove();
appendAssistantMessage("⚠️ Ошибка соединения с сервером при отправке сообщения.");
}
};
window.clearAttachedFile = function() {
currentAttachedFile = null;
const preview = document.getElementById("file-attachment-preview");
const nameEl = document.getElementById("file-attachment-name");
const fileInput = document.getElementById("file-upload-input");
if (preview) preview.classList.add("hidden");
if (nameEl) nameEl.innerText = "";
if (fileInput) fileInput.value = "";
};
function handleFileSelected(file) {
if (!file) return;
currentAttachedFile = file;
const preview = document.getElementById("file-attachment-preview");
const nameEl = document.getElementById("file-attachment-name");
if (preview && nameEl) {
nameEl.innerText = file.name;
preview.classList.remove("hidden");
}
}
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("user-input");
const fileInput = document.getElementById("file-upload-input");
const overlay = document.getElementById("global-drag-overlay");
if (input) {
input.style.lineHeight = '24px';
input.style.height = '24px';
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
if (e.shiftKey) return;
e.preventDefault();
window.sendMessage();
return;
}
if (e.key === "ArrowUp") {
const isSingleLine = !input.value.includes("\n");
const isAtBeginning = input.selectionStart === 0 && input.selectionEnd === 0;
if (isSingleLine || isAtBeginning) {
if (chatInputHistory.length > 0) {
e.preventDefault();
if (chatHistoryIndex === -1) {
temporaryCurrentInput = input.value;
chatHistoryIndex = chatInputHistory.length - 1;
} else if (chatHistoryIndex > 0) {
chatHistoryIndex--;
}
input.value = chatInputHistory[chatHistoryIndex];
updateInputHeightAndFade(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
} else if (e.key === "ArrowDown") {
const isSingleLine = !input.value.includes("\n");
const isAtEnd = input.selectionStart === input.value.length;
if (isSingleLine || isAtEnd) {
if (chatHistoryIndex !== -1) {
e.preventDefault();
if (chatHistoryIndex < chatInputHistory.length - 1) {
chatHistoryIndex++;
input.value = chatInputHistory[chatHistoryIndex];
} else {
chatHistoryIndex = -1;
input.value = temporaryCurrentInput;
}
updateInputHeightAndFade(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
});
input.addEventListener("input", function() {
updateInputHeightAndFade(this);
chatHistoryIndex = -1;
});
}
if (fileInput) {
fileInput.addEventListener("change", (e) => {
if (e.target.files && e.target.files[0]) {
handleFileSelected(e.target.files[0]);
}
});
}
let dragCounter = 0;
window.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
if (overlay) {
overlay.classList.remove('hidden');
}
}, false);
window.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
if (overlay) {
overlay.classList.add('hidden');
}
}
}, false);
window.addEventListener('dragover', (e) => {
e.preventDefault();
}, false);
window.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
if (overlay) {
overlay.classList.add('hidden');
}
const dt = e.dataTransfer;
if (dt && dt.files && dt.files[0]) {
handleFileSelected(dt.files[0]);
}
}, false);
});
+472
View File
@@ -0,0 +1,472 @@
/**
* ===============================================================================
* FILE: modules/web_api/static/js/sidebar.js
* ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами,
* подробным описанием управления контекстом и интеграцией чата.
* ===============================================================================
*/
// Глобальная функция безопасного экранирования HTML
window.escapeHtml = function(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
window.SidebarManager = {
currentHub: 'TASKS',
currentSubTab: {
'REGISTRIES': 'REMOTE'
},
hubs: [
{ id: 'TASKS', label: 'Задачи', icon: 'fa-list-check' },
{ id: 'SNAPSHOTS', label: 'Срезы', icon: 'fa-camera' },
{ id: 'REGISTRIES', label: 'Реестры', icon: 'fa-address-book' },
{ id: 'PROMPT', label: 'Промпт', icon: 'fa-terminal' },
{ id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' }
],
subTabs: {
'REGISTRIES': [
{ id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' },
{ id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' }
]
},
init() {
this.renderHeader();
this.renderContent();
},
setHub(hubId) {
this.currentHub = hubId;
this.renderHeader();
this.renderContent();
},
setSubTab(subTabId) {
this.currentSubTab[this.currentHub] = subTabId;
this.renderHeader();
this.renderContent();
},
renderHeader() {
const headerContainer = document.getElementById("sidebar-dynamic-header");
if (!headerContainer) return;
// 1. Основные 5 Хабов
const hubsHtml = `
<div class="flex items-center border-b border-slate-200 bg-slate-50/80 px-1 pt-1.5 overflow-x-auto gap-0.5">
${this.hubs.map(h => {
const isActive = this.currentHub === h.id;
return `
<button onclick="SidebarManager.setHub('${h.id}')"
class="flex-1 py-1.5 px-1 flex flex-col items-center gap-1 border-b-2 font-bold text-[10px] transition ${
isActive
? 'border-indigo-600 text-indigo-600 bg-white rounded-t-lg shadow-sm'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:bg-slate-100/60 rounded-t-lg'
}">
<i class="fa-solid ${h.icon} text-xs"></i>
<span class="truncate">${h.label}</span>
</button>
`;
}).join('')}
</div>
`;
// 2. Подвкладки (только для хабов, где они требуются, например Реестры)
let subTabsHtml = '';
if (this.subTabs[this.currentHub]) {
const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id;
subTabsHtml = `
<div class="flex items-center gap-1.5 p-1.5 bg-slate-100/90 border-b border-slate-200">
${this.subTabs[this.currentHub].map(st => {
const isSubActive = currentActiveSub === st.id;
return `
<button onclick="SidebarManager.setSubTab('${st.id}')"
class="flex-1 py-1 px-2 rounded-md text-[11px] font-semibold flex items-center justify-center gap-1.5 transition ${
isSubActive
? 'bg-white text-indigo-700 shadow-sm'
: 'text-slate-600 hover:text-slate-900 hover:bg-white/50'
}">
<i class="fa-solid ${st.icon} text-[10px]"></i>
<span>${st.label}</span>
</button>
`;
}).join('')}
</div>
`;
}
headerContainer.innerHTML = hubsHtml + subTabsHtml;
},
renderContent() {
const contentContainer = document.getElementById("sidebar-dynamic-content");
if (!contentContainer) return;
contentContainer.scrollTop = 0;
switch (this.currentHub) {
case 'TASKS':
this.renderTasksView(contentContainer);
break;
case 'SNAPSHOTS':
this.renderSnapshotsView(contentContainer);
break;
case 'REGISTRIES':
this.renderRegistriesView(contentContainer);
break;
case 'PROMPT':
this.renderPromptView(contentContainer);
break;
case 'CONTEXT':
this.renderContextView(contentContainer);
break;
default:
contentContainer.innerHTML = `<div class="p-4 text-xs text-slate-400 text-center">Раздел в разработке</div>`;
}
},
// =========================================================================
// ХАБ 1: ЗАДАЧИ
// =========================================================================
renderTasksView(container) {
container.innerHTML = `
<div id="tasks-list-container" class="flex-1 flex flex-col gap-2">
<div class="text-center py-10 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
</div>
</div>
`;
if (window.loadTasks) {
window.loadTasks();
}
},
// =========================================================================
// ХАБ 2: СРЕЗЫ СКУД
// =========================================================================
async renderSnapshotsView(container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка срезов...</div>`;
try {
const res = await fetch("/api/v1/snapshots", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : { snapshots: [] };
const snaps = data.snapshots || [];
if (snaps.length === 0) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400">Срезы СКУД не найдены</div>`;
return;
}
const itemsHtml = snaps.map(s => `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-bold text-slate-800">${escapeHtml(s.snapshot_id)}</span>
${s.is_final ? '<span class="px-1.5 py-0.2 rounded text-[9px] font-bold bg-amber-50 text-amber-700 border border-amber-200">Финал Y</span>' : ''}
</div>
<div class="text-[10px] text-slate-400 mt-0.5">${escapeHtml(s.snapshot_time)} · ${s.record_count || 0} зап.</div>
</div>
<button onclick="window.sendChatAction('покажи срез ${escapeHtml(s.snapshot_id)}')" class="px-2 py-1 bg-slate-100 hover:bg-indigo-50 text-slate-600 hover:text-indigo-600 rounded-lg text-[10px] font-semibold transition" title="Открыть в чате">
Инспекция
</button>
</div>
`).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">Всего срезов: ${snaps.length}</span>
<button onclick="SidebarManager.renderSnapshotsView(document.getElementById('sidebar-dynamic-content'))" class="text-slate-400 hover:text-indigo-600 p-1" title="Обновить">
<i class="fa-solid fa-arrows-rotate text-xs"></i>
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">${itemsHtml}</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки срезов</div>`;
}
},
// =========================================================================
// ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ)
// =========================================================================
renderRegistriesView(container) {
const subTab = this.currentSubTab['REGISTRIES'] || 'REMOTE';
if (subTab === 'REMOTE') {
this.renderRemoteWorkersView(container);
} else {
this.renderExceptionsView(container);
}
},
async renderRemoteWorkersView(container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка удаленщиков...</div>`;
try {
const res = await fetch("/api/v1/remote-workers", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : { workers: [] };
const workers = data.workers || [];
const listHtml = workers.map(w => {
const dFrom = w.date_from ? w.date_from : 'сегодня';
const dTo = w.date_to ? w.date_to : 'бессрочно';
const periodLabel = (!w.date_to) ? `с ${dFrom} (бессрочно)` : `${dFrom} — ${dTo}`;
return `
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-emerald-300 transition">
<div class="min-w-0 flex-1">
<div class="font-bold text-slate-800 truncate">${escapeHtml(w.fio)}</div>
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(w.department || 'Все')}</div>
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
<i class="fa-regular fa-calendar-days text-[8px]"></i>
<span>${escapeHtml(periodLabel)}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<button onclick="openRemoteWorkerModal('EDIT', '${escapeHtml(w.fio)}', '${escapeHtml(w.department || 'Все')}', '${escapeHtml(w.date_from || '')}', '${escapeHtml(w.date_to || '')}')"
class="text-slate-400 hover:text-emerald-600 p-1.5 rounded-lg hover:bg-emerald-50 transition"
title="Изменить сроки удаленки">
<i class="fa-solid fa-pen-to-square text-xs"></i>
</button>
<button onclick="SidebarManager.deleteRemoteWorker('${escapeHtml(w.fio)}')"
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
title="Удалить">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="p-2 flex flex-col gap-2.5">
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold text-slate-700">В реестре: ${workers.length} чел.</span>
<button onclick="openRemoteWorkerModal('ADD')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
</button>
</div>
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${workers.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список удаленщиков пуст</div>'}
</div>
</div>
`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра удаленщиков</div>`;
}
},
async deleteRemoteWorker(fio) {
if (!confirm(`Удалить сотрудника ${fio} из реестра удаленщиков?`)) return;
try {
const res = await fetch(`/api/v1/remote-workers?fio=${encodeURIComponent(fio)}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) {
this.renderContent();
} else {
alert("Ошибка удаления");
}
} catch (e) {
alert("Ошибка сети");
}
},
async renderExceptionsView(container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка исключений...</div>`;
try {
const res = await fetch("/api/v1/exceptions/", { headers: AuthManager.getAuthHeaders() });
const data = res.ok ? await res.json() : {};
const categories = [
{ key: 'include_fio', title: 'Белый список (ФИО)' },
{ key: 'fio', title: 'Исключенные сотрудники (ФИО)' },
{ key: 'departments', title: 'Исключенные отделы' },
{ key: 'positions', title: 'Исключенные должности' }
];
const html = categories.map(cat => {
const items = data[cat.key] || [];
return `
<div class="bg-white border border-slate-200 rounded-xl p-3 flex flex-col gap-2 shadow-sm">
<div class="flex items-center justify-between">
<span class="font-bold text-xs text-slate-700">${cat.title} (${items.length})</span>
<button onclick="SidebarManager.addExceptionPrompt('${cat.key}')" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">
+ Добавить
</button>
</div>
<div class="flex flex-wrap gap-1">
${items.map(it => `
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-slate-100 text-slate-700 border border-slate-200">
${escapeHtml(it)}
<button onclick="SidebarManager.deleteExceptionItem('${cat.key}', '${escapeHtml(it)}')" class="hover:text-rose-600 ml-0.5">×</button>
</span>
`).join('') || '<span class="text-[10px] text-slate-400">Пусто</span>'}
</div>
</div>
`;
}).join('');
container.innerHTML = `<div class="p-2 flex flex-col gap-2 max-h-[75vh] overflow-y-auto">${html}</div>`;
} catch (e) {
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки исключений</div>`;
}
},
async addExceptionPrompt(category) {
const val = prompt(`Введите значение для категории [${category}]:`);
if (!val || !val.trim()) return;
try {
const res = await fetch("/api/v1/exceptions/", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ category: category, value: val.trim() })
});
if (res.ok) this.renderContent();
else alert("Ошибка добавления");
} catch (e) {
alert("Ошибка сети");
}
},
async deleteExceptionItem(category, value) {
if (!confirm(`Удалить "${value}" из ${category}?`)) return;
try {
const res = await fetch(`/api/v1/exceptions/?category=${encodeURIComponent(category)}&value=${encodeURIComponent(value)}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) this.renderContent();
else alert("Ошибка удаления");
} catch (e) {
alert("Ошибка сети");
}
},
// =========================================================================
// ХАБ 4: СИСТЕМНЫЙ ПРОМПТ И БАЗА ЗНАНИЙ
// =========================================================================
renderPromptView(container) {
container.innerHTML = `
<div class="p-3 flex flex-col gap-3">
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-1.5">
<span class="font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-sliders text-indigo-600"></i> Инструкции и регламенты ИИ
</span>
<span>Управление системными директивами, базой знаний и правилами арбитража кадровых аномалий СКУД и 1С.</span>
</div>
<div class="flex flex-col gap-2">
<button onclick="window.sendChatAction('покажи системный промпт')"
class="w-full py-2 px-3 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-terminal text-xs"></i>
<span>Показать системный промпт</span>
</button>
<button onclick="window.sendChatAction('покажи правила компании')"
class="w-full py-2 px-3 bg-white hover:bg-slate-50 active:bg-slate-100 text-slate-700 border border-slate-300 rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-book-bookmark text-emerald-600 text-xs"></i>
<span>База знаний и правила компании</span>
</button>
</div>
</div>
`;
},
// =========================================================================
// ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ (С ПОДРОБНЫМ ОПИСАНИЕМ)
// =========================================================================
renderContextView(container) {
container.innerHTML = `
<div class="p-3 flex flex-col gap-3">
<div class="text-[11px] text-slate-600 leading-relaxed bg-white border border-slate-200 rounded-xl p-3 shadow-sm flex flex-col gap-2">
<span class="font-bold text-slate-800 flex items-center gap-1.5">
<i class="fa-solid fa-brain text-indigo-600"></i> Управление памятью чата
</span>
<div class="flex flex-col gap-1.5 pt-1 border-t border-slate-100">
<div class="flex items-start gap-1.5">
<span class="w-2 h-2 rounded-full bg-amber-500 mt-1 shrink-0"></span>
<div>
<span class="font-bold text-slate-700">Мягкая очистка:</span>
<span class="text-slate-500"> удаляет только служебные транзакции (карточки срезов, временные превью промпта, промежуточные подтверждения). Смысловой диалог пользователя сохраняется.</span>
</div>
</div>
<div class="flex items-start gap-1.5">
<span class="w-2 h-2 rounded-full bg-rose-500 mt-1 shrink-0"></span>
<div>
<span class="font-bold text-slate-700">Полный сброс:</span>
<span class="text-slate-500"> полностью стирает контекст активной сессии из базы данных и очищает окно чата. Используется при переходе к новой дате или новой теме анализа.</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<button onclick="window.sendChatAction('очисти контекст')"
class="w-full py-2 px-3 bg-amber-500 hover:bg-amber-600 active:bg-amber-700 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-broom text-xs"></i>
<span>Мягкая очистка контекста</span>
</button>
<button onclick="SidebarManager.handleFullSessionReset()"
class="w-full py-2 px-3 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white rounded-xl text-xs font-bold shadow-sm flex items-center justify-center gap-2 transition">
<i class="fa-solid fa-trash-arrow-up text-xs"></i>
<span>Полный сброс сессии</span>
</button>
</div>
</div>
`;
},
async handleFullSessionReset() {
if (!confirm("Вы действительно хотите полностью очистить историю диалога и сбросить сессию чата?")) {
return;
}
// Очищаем локальное окно чата до стартового приветствия
const chatContainer = document.getElementById("chat-messages-container");
if (chatContainer) {
chatContainer.innerHTML = `
<div class="flex gap-3 max-w-4xl mx-auto w-full">
<div class="w-7 h-7 rounded-lg bg-indigo-600 text-white flex items-center justify-center shrink-0 shadow-sm mt-0.5">
<i class="fa-solid fa-robot text-xs"></i>
</div>
<div class="flex-1 bg-white border border-slate-200 rounded-2xl rounded-tl-none p-4 shadow-sm">
<div class="text-[10px] font-bold text-indigo-600 uppercase tracking-wider mb-1">ИИ-ассистент SCUD Orion AI</div>
<div class="text-xs text-slate-700 leading-relaxed">
Сессия чата очищена. Память ассистента сброшена. Задайте новый вопрос или команду.
</div>
</div>
</div>
`;
}
try {
await fetch("/api/v1/chat", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
message: "сбрось сессию полностью",
session_id: "web_session_main"
})
});
} catch (e) {
console.error("Ошибка запроса сброса сессии:", e);
}
}
};
document.addEventListener("DOMContentLoaded", () => {
if (window.AuthManager && AuthManager.isAuthenticated()) {
SidebarManager.init();
}
});
+167 -84
View File
@@ -1,117 +1,200 @@
/** /**
=============================================================================== * ===============================================================================
FILE: modules/web_api/static/js/tasks.js * FILE: modules/web_api/static/js/tasks.js
PROJECT: SCUD Orion AI (Unified Architecture) * ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер).
MODULE: web_api / static / js * ===============================================================================
ROLE: Загрузка, фильтрация и рендеринг списка задач в боковой панели (Drawer). */
===============================================================================
*/
let currentTaskFilter = 'IN_PROGRESS'; let currentTasksFilter = 'ALL';
let tasksCache = [];
function getTasksContainer() {
return document.getElementById("tasks-list-container") ||
document.getElementById("sidebar-dynamic-content") ||
document.getElementById("tasks-list");
}
async function loadTasks() { async function loadTasks() {
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); const container = getTasksContainer();
if (!token) return; if (!container) return;
container.innerHTML = `
<div class="text-center py-8 text-xs text-slate-400">
<i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка задач...
</div>
`;
try { try {
const res = await fetch("/api/v1/tasks", { const res = await fetch("/api/v1/tasks", {
headers: { "Authorization": "Bearer " + token } headers: AuthManager.getAuthHeaders()
}); });
if (res.ok) {
const data = await res.json(); if (!res.ok) {
renderSidebarTasks(data.tasks || []); if (res.status === 401) {
} else { showAuthModal();
renderSidebarError("Ошибка доступа. Авторизуйтесь снова."); return;
}
throw new Error(`Ошибка сервера (${res.status})`);
} }
} catch (e) {
console.error("Ошибка загрузки задач:", e); const data = await res.json();
renderSidebarError("Ошибка сети. Сервер недоступен."); tasksCache = Array.isArray(data) ? data : (data.tasks || []);
renderTasksUI();
} catch (err) {
console.error("[Tasks] Ошибка загрузки:", err);
container.innerHTML = `
<div class="p-4 text-xs text-rose-500 text-center flex flex-col items-center gap-2">
<i class="fa-solid fa-triangle-exclamation text-base"></i>
<span>Не удалось загрузить задачи</span>
<button onclick="loadTasks()" class="px-2.5 py-1 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded text-[11px] font-semibold transition">Повторить</button>
</div>
`;
} }
} }
function renderSidebarError(msg) { function setTaskFilter(filter) {
// Поддерживаем оба варианта ID (новый и старый) для обратной совместимости currentTasksFilter = filter;
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container"); renderTasksUI();
if (container) {
container.innerHTML = `<div class="text-center py-8 text-xs text-rose-500 font-semibold">${msg}</div>`;
}
} }
function filterTasksByTab(status) { function renderTasksUI() {
currentTaskFilter = status; const container = getTasksContainer();
// Сброс стилей всех кнопок-вкладок
document.querySelectorAll('.task-tab-btn').forEach(btn => {
btn.classList.remove('text-indigo-600', 'bg-indigo-50');
btn.classList.add('text-slate-600');
});
// Установка активного стиля для выбранной вкладки
const activeBtnId = {
'IN_PROGRESS': 'tab-in-progress',
'BACKLOG': 'tab-backlog',
'COMPLETED': 'tab-completed',
'ALL': 'tab-all'
}[status];
if (activeBtnId) {
const btn = document.getElementById(activeBtnId);
if (btn) {
btn.classList.remove('text-slate-600', 'hover:bg-slate-100');
btn.classList.add('text-indigo-600', 'bg-indigo-50');
}
}
loadTasks();
}
function renderSidebarTasks(tasks) {
const container = document.getElementById("tasks-list") || document.getElementById("tasks-list-container");
if (!container) return; if (!container) return;
let filtered = tasks; let filtered = tasksCache;
if (currentTaskFilter !== 'ALL') { if (currentTasksFilter === 'IN_PROGRESS') {
filtered = tasks.filter(t => t.status === currentTaskFilter); filtered = tasksCache.filter(t => t.status === 'IN_PROGRESS');
} else if (currentTasksFilter === 'BACKLOG') {
filtered = tasksCache.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED');
} else if (currentTasksFilter === 'COMPLETED') {
filtered = tasksCache.filter(t => t.status === 'COMPLETED' || t.status === 'DONE');
} }
const filtersHtml = `
<div class="flex items-center gap-1 p-1 bg-slate-200/70 rounded-lg text-[11px] font-semibold mb-2">
<button onclick="setTaskFilter('ALL')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'ALL' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Все</button>
<button onclick="setTaskFilter('IN_PROGRESS')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'IN_PROGRESS' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">В работе</button>
<button onclick="setTaskFilter('BACKLOG')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'BACKLOG' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Планы</button>
<button onclick="setTaskFilter('COMPLETED')" class="flex-1 py-1 rounded text-center transition ${currentTasksFilter === 'COMPLETED' ? 'bg-white text-indigo-700 shadow-sm' : 'text-slate-600 hover:text-slate-900'}">Готово</button>
</div>
`;
const addBtnHtml = `
<div class="flex items-center justify-between px-1 mb-1.5">
<span class="text-xs font-bold text-slate-700">Задачи: ${filtered.length}</span>
<button onclick="openCreateTaskModal()" class="px-2 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
<i class="fa-solid fa-plus text-[10px]"></i> Новая
</button>
</div>
`;
if (filtered.length === 0) { if (filtered.length === 0) {
container.innerHTML = `<div class="text-center py-8 text-[11px] font-medium text-slate-400 bg-slate-50 rounded-xl border border-dashed border-slate-200">Нет задач в этой категории</div>`; container.innerHTML = `
${filtersHtml}
${addBtnHtml}
<div class="text-center py-8 text-xs text-slate-400 bg-white border border-slate-200 rounded-xl p-4">
Нет задач в выбранной категории
</div>
`;
return; return;
} }
container.innerHTML = filtered.map(t => { const itemsHtml = filtered.map(t => {
const isCompleted = t.status === 'COMPLETED'; const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
const priorityColor = t.priority === 'HIGH' || t.priority === 'CRITICAL' const priorityColors = {
? 'text-rose-600 bg-rose-50 border-rose-200' 'HIGH': 'bg-rose-50 text-rose-700 border-rose-200',
: t.priority === 'MEDIUM' 'MEDIUM': 'bg-amber-50 text-amber-700 border-amber-200',
? 'text-amber-600 bg-amber-50 border-amber-200' 'LOW': 'bg-slate-50 text-slate-600 border-slate-200'
: 'text-slate-600 bg-slate-50 border-slate-200'; };
const pClass = priorityColors[t.priority] || priorityColors['MEDIUM'];
return ` return `
<div class="p-3 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-indigo-400 hover:shadow-md transition cursor-pointer flex flex-col gap-2 group" <div class="flex flex-col p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-1.5 shadow-sm hover:border-indigo-300 transition">
onclick="handleActionButtonClick('покажи задачу ${t.id}')">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider group-hover:text-indigo-500 transition">#${t.id}</span> <div class="flex items-center gap-1.5 min-w-0">
${isCompleted <button onclick="toggleTaskStatus(${t.id}, '${t.status}')" class="text-slate-400 hover:text-indigo-600 transition shrink-0">
? `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md bg-emerald-50 text-emerald-600 border border-emerald-200 shadow-sm"><i class="fa-solid fa-check mr-0.5"></i> Готово</span>` <i class="fa-${isDone ? 'solid fa-circle-check text-emerald-500' : 'regular fa-circle'} text-sm"></i>
: `<span class="text-[10px] font-bold px-1.5 py-0.5 rounded-md ${priorityColor} shadow-sm">${t.priority || 'LOW'}</span>` </button>
} <span class="font-bold text-slate-800 ${isDone ? 'line-through text-slate-400' : ''} truncate">${escapeHtml(t.title)}</span>
</div>
<span class="px-1.5 py-0.5 rounded text-[9px] font-semibold border ${pClass} shrink-0">${t.priority || 'NORMAL'}</span>
</div> </div>
<div class="text-xs font-semibold text-slate-700 leading-snug line-clamp-3">${escapeHtml(t.title || 'Без названия')}</div> <div class="flex items-center justify-between text-[10px] text-slate-400 pt-1 border-t border-slate-100">
<span>${t.task_id || ('#' + t.id)} · ${escapeHtml(t.module || 'general')}</span>
<div class="flex items-center justify-between text-[10px] text-slate-400 mt-1"> <div class="flex items-center gap-1">
<span class="bg-slate-100 px-1.5 py-0.5 rounded font-mono truncate max-w-[120px]">${escapeHtml(t.module || 'general')}</span> <button onclick="deleteTaskItem(${t.id})" class="text-slate-400 hover:text-rose-600 p-0.5 transition" title="Удалить">
${t.due_date ? `<span class="shrink-0 font-medium text-slate-500"><i class="fa-regular fa-calendar mr-1"></i>${escapeHtml(t.due_date)}</span>` : ''} <i class="fa-solid fa-trash-can text-[11px]"></i>
</button>
</div>
</div> </div>
</div> </div>
`; `;
}).join(''); }).join('');
container.innerHTML = `
${filtersHtml}
${addBtnHtml}
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
${itemsHtml}
</div>
`;
} }
// Глобальная инициализация при загрузке DOM async function toggleTaskStatus(id, currentStatus) {
document.addEventListener("DOMContentLoaded", () => { const newStatus = (currentStatus === 'COMPLETED' || currentStatus === 'DONE') ? 'IN_PROGRESS' : 'COMPLETED';
// Небольшая задержка, чтобы гарантировать применение токена try {
setTimeout(loadTasks, 200); const res = await fetch(`/api/v1/tasks/${id}`, {
}); method: "PATCH",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ status: newStatus })
});
if (res.ok) {
loadTasks();
}
} catch (e) {
console.error("Ошибка смены статуса задачи:", e);
}
}
async function deleteTaskItem(id) {
if (!confirm("Удалить эту задачу?")) return;
try {
const res = await fetch(`/api/v1/tasks/${id}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) {
loadTasks();
}
} catch (e) {
alert("Ошибка сети при удалении");
}
}
async function openCreateTaskModal() {
const title = prompt("Введите описание новой задачи:");
if (!title || !title.trim()) return;
try {
const res = await fetch("/api/v1/tasks", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
title: title.trim(),
priority: "MEDIUM",
status: "IN_PROGRESS"
})
});
if (res.ok) {
loadTasks();
} else {
alert("Не удалось создать задачу");
}
} catch (e) {
alert("Ошибка сети");
}
}
window.loadTasks = loadTasks;
window.setTaskFilter = setTaskFilter;
+73 -15
View File
@@ -7,6 +7,7 @@ ROLE: Надежная загрузка штата и отсутствий (MS S
import os import os
import warnings import warnings
from datetime import datetime # <-- ДОБАВИТЬ ЭТУ СТРОКУ
import pandas as pd import pandas as pd
from config import ( from config import (
normalize_fio, clean_scud_fio_light, load_exceptions, normalize_fio, clean_scud_fio_light, load_exceptions,
@@ -101,27 +102,84 @@ def load_absent_data(date_str):
if df_absent is None: if df_absent is None:
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
# Обогащение удаленщиками из static_reason_workers.csv # Обогащение удаленщиками из static_reason_workers.csv с ротацией просроченных записей
try: try:
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if os.path.exists(static_path): if os.path.exists(static_path):
df_static = pd.read_csv(static_path, encoding='utf-8') df_static = pd.read_csv(static_path, dtype=str, on_bad_lines='skip').fillna("")
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
new_rows = [] for col in ['fio', 'reason', 'department', 'date_from', 'date_to']:
for _, s_row in df_static.iterrows(): if col not in df_static.columns:
if s_row['fio_clean'] not in existing_fios: df_static[col] = ""
new_rows.append({
'fio_clean': s_row['fio_clean'], today_date = datetime.now().date()
'Вид_отсутствия': s_row['reason']
# Разбор целевой даты отчета
clean_target_str = str(date_str).replace('_', '.')
try:
clean_target_date = datetime.strptime(clean_target_str, "%d.%m.%Y").date()
except ValueError:
clean_target_date = today_date
def parse_date_safe(d_val):
if not d_val or str(d_val).lower() in ['nan', 'none', '', 'nat']:
return None
s = str(d_val).strip().replace('_', '.')
for fmt in ("%d.%m.%Y", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
pass
return None
active_for_file = []
new_rows_for_report = []
file_changed = False
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
for _, s_row in df_static.iterrows():
fio_raw = str(s_row.get('fio', '')).strip()
if not fio_raw:
continue
fio_c = normalize_fio(fio_raw)
reason = str(s_row.get('reason', '')).strip() or "Дистанционная работа"
d_from = parse_date_safe(s_row.get('date_from'))
d_to = parse_date_safe(s_row.get('date_to'))
# 1. Физическая ротация просроченных: только если текущий реальный день (today) строго больше date_to
if d_to is not None and today_date > d_to:
print(f" [🧹] Удаленка истекла: {fio_raw} (до {d_to.strftime('%d.%m.%Y')}). Удалена из CSV.")
file_changed = True
continue
active_for_file.append(s_row.to_dict())
# 2. Проверка действия удаленки на дату формируемого отчета:
# Если date_from не указана — действует всегда до date_to
is_after_start = (d_from is None) or (clean_target_date >= d_from)
is_before_end = (d_to is None) or (clean_target_date <= d_to)
if is_after_start and is_before_end:
if fio_c not in existing_fios:
new_rows_for_report.append({
'fio_clean': fio_c,
'Вид_отсутствия': reason
}) })
if new_rows: existing_fios.add(fio_c)
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True)
print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows)} чел. из static_reason_workers.csv") # Перезаписываем CSV только если реально были удалены просроченные сотрудники
if file_changed:
pd.DataFrame(active_for_file).to_csv(static_path, index=False, encoding='utf-8')
print(" [✓] Файл static_reason_workers.csv синхронизирован без просроченных записей.")
if new_rows_for_report:
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows_for_report)], ignore_index=True)
print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows_for_report)} чел. в отчет за {date_str}")
except Exception as e: except Exception as e:
print(f" [⚠️] Ошибка чтения static_reason_workers.csv: {e}") print(f" [⚠️] Ошибка обработки static_reason_workers.csv: {e}")
return df_absent if not df_absent.empty else None return df_absent if not df_absent.empty else None
+278 -213
View File
@@ -1,12 +1,18 @@
import math """
===============================================================================
FILE: services/excel_exporter.py
ROLE: Генерация Excel-отчетов (Сводка, Детальный отчет, Сырой СКУД) через XlsxWriter.
Корректный расчет часов удаленщиков и исключение лишних списков.
===============================================================================
"""
import os import os
import math
import time import time
import openpyxl
import pandas as pd import pandas as pd
import xlsxwriter
from datetime import datetime, timedelta from datetime import datetime, timedelta
from openpyxl import Workbook from xlsxwriter.exceptions import FileCreateError
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from config import REPORTS_DIR from config import REPORTS_DIR
MONTHS_RU_GENITIVE = { MONTHS_RU_GENITIVE = {
@@ -47,29 +53,25 @@ def get_dated_reports_dir(date_str):
return target_dir return target_dir
THIN_SIDE = Side(border_style="thin", color="D3D3D3") def safe_close_workbook(wb, output_path, target_dir, filename):
THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE) try:
wb.close()
FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") print(f"[✓] Успешно сохранен: {output_path}")
FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") return output_path
FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") except (FileCreateError, OSError, PermissionError):
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", fill_type="solid") alt_path = os.path.join(target_dir, alt_filename)
FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") try:
FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") wb.filename = alt_path
FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") wb._store_workbook()
print(f"[⚠️] Исходный файл открыт в Excel! Сохранено как: {alt_path}")
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"] return alt_path
YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") except Exception as e:
LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") print(f"[❌] Ошибка сохранения даже резервного файла: {e}")
GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") return output_path
def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]: def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]:
"""
⭐️ Правило 8.5ч: при наличии входа и отсутствии выхода
рассчитывает время выхода (Вход + 8ч 30мин) с нормой 8:00 и отклонением 0:00.
"""
try: try:
parts = time_in_str.strip().split(':') parts = time_in_str.strip().split(':')
hh = int(parts[0]) hh = int(parts[0])
@@ -84,10 +86,25 @@ def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]:
def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "": """
Расчет отклонения от нормы.
Для удаленщиков при наличии физического времени в здании вычисляется реальное отклонение.
"""
reason_clean = str(reason).strip().lower() if pd.notna(reason) else ""
is_remote = "удален" in reason_clean or "дистанцион" in reason_clean
has_building_time = isinstance(time_in_building_str, str) and time_in_building_str not in ['00:00', '0', '', 'None', 'nan', 'NaN']
# Если есть уважительная причина (больничный, отпуск, командировка и т.д.) не удаленка
if reason_clean != "" and not is_remote:
return "0:00" return "0:00"
if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']: # Если удаленщик работал исключительно из дома (00:00 в здании)
if is_remote and not has_building_time:
return "0:00"
# Если сотрудника не было в здании и нет уважительной причины
if not has_building_time:
return f"-{norm_hours}:00" return f"-{norm_hours}:00"
try: try:
@@ -97,7 +114,7 @@ def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_min
total_in_building_minutes = hh * 60 + mm total_in_building_minutes = hh * 60 + mm
if total_in_building_minutes == 0: if total_in_building_minutes == 0:
return f"-{norm_hours}:00" return "0:00" if is_remote else f"-{norm_hours}:00"
work_minutes = max(0, total_in_building_minutes - lunch_minutes) work_minutes = max(0, total_in_building_minutes - lunch_minutes)
norm_minutes = norm_hours * 60 norm_minutes = norm_hours * 60
@@ -113,29 +130,12 @@ def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_min
return f"{sign}{res_hh}:{res_mm:02d}" return f"{sign}{res_hh}:{res_mm:02d}"
except Exception: except Exception:
return f"-{norm_hours}:00" return "0:00" if is_remote else f"-{norm_hours}:00"
def apply_borders_to_cell(cell, border=THIN_BORDER): # =============================================================================
cell.border = border # 1. ЕЖЕДНЕВНАЯ СВОДКА НА СЕГОДНЯ
# =============================================================================
def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False):
cell_a = ws.cell(row=r_num, column=1)
cell_b = ws.cell(row=r_num, column=2)
if fill_obj:
cell_a.fill = fill_obj
cell_b.fill = fill_obj
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
if is_bold and bold_font:
cell_a.font = bold_font
cell_b.font = bold_font
if align_b:
cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b)
# --- 1. СВОДКА НА СЕГОДНЯ ---
def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
date_clean = str(date_str).replace('_', '.') date_clean = str(date_str).replace('_', '.')
if not filename: if not filename:
@@ -143,65 +143,92 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
target_dir = get_dated_reports_dir(date_clean) target_dir = get_dated_reports_dir(date_clean)
output_path = os.path.join(target_dir, filename) output_path = os.path.join(target_dir, filename)
wb = Workbook()
ws = wb.active
ws.title = "Лист_1"
ws.sheet_properties.outlinePr.summaryBelow = False wb = xlsxwriter.Workbook(output_path)
ws.sheet_properties.outlinePr.summaryRight = False ws = wb.add_worksheet("Лист_1")
ws.sheet_properties.outlinePr.showOutlineSymbols = True
ws.sheet_view.showOutlineSymbols = True
bold_font = Font(name="Calibri", size=11, bold=True) ws.outline_settings(visible=True, symbols_below=False, symbols_right=False, auto_style=False)
ws.cell(row=1, column=1, value="Сводка на") def make_fmt(bg_color=None, bold=False, align="left", wrap=False):
ws.cell(row=1, column=2, value=date_clean) d = {
format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font) 'font_name': 'Calibri',
'font_size': 11,
'bold': bold,
'align': align,
'valign': 'vcenter',
'border': 1,
'border_color': '#D3D3D3',
'text_wrap': wrap
}
if bg_color:
d['bg_color'] = bg_color
return wb.add_format(d)
apply_borders_to_cell(ws.cell(row=2, column=1)) fmt_hdr_l = make_fmt(bg_color='#D9E1F2', bold=True, align="left")
apply_borders_to_cell(ws.cell(row=2, column=2)) fmt_hdr_r = make_fmt(bg_color='#D9E1F2', bold=True, align="right")
fmt_tot_l = make_fmt(bg_color='#F2F2F2', bold=True, align="left")
fmt_tot_r = make_fmt(bg_color='#F2F2F2', bold=True, align="right")
fmt_empty = make_fmt()
ws.cell(row=3, column=1, value="По списку") ws.set_row(0, 20)
ws.cell(row=3, column=2, value=len(merged_df)) ws.write(0, 0, "Сводка на", fmt_hdr_l)
format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font) ws.write(0, 1, date_clean, fmt_hdr_r)
current_row = 4 ws.set_row(1, 20)
ws.write(1, 0, "", fmt_empty)
ws.write(1, 1, "", fmt_empty)
ws.set_row(2, 20)
ws.write(2, 0, "По списку", fmt_tot_l)
ws.write(2, 1, len(merged_df), fmt_tot_r)
current_row = 3
is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False
is_exc = merged_df.get('is_excluded', False) == True is_exc = merged_df.get('is_excluded', False) == True
# 1. Неизвестно (Раскрыто по умолчанию)
unexplained = merged_df[ unexplained = merged_df[
(merged_df['Пришел'] == False) & (merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass) & (~is_no_pass) &
(~is_exc) (~is_exc)
] ]
ws.cell(row=current_row, column=1, value="неизвестно") fmt_unexp_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left")
ws.cell(row=current_row, column=2, value=len(unexplained)) fmt_unexp_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right")
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) fmt_unexp_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left")
fmt_unexp_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "неизвестно", fmt_unexp_hl)
ws.write(current_row, 1, len(unexplained), fmt_unexp_hr)
current_row += 1 current_row += 1
for fio in sorted(unexplained['Сотрудник'].dropna().unique()): for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio) ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) ws.write(current_row, 0, fio, fmt_unexp_rl)
ws.row_dimensions[current_row].outlineLevel = 1 ws.write(current_row, 1, "", fmt_unexp_rr)
ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() # 2. Нет пропуска (Раскрыто по умолчанию)
ws.cell(row=current_row, column=1, value="Нет пропуска") no_pass_df = merged_df[is_no_pass & (~is_exc)] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
ws.cell(row=current_row, column=2, value=len(no_pass_df)) fmt_np_hl = make_fmt(bg_color='#E1F5FE', bold=True, align="left")
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font) fmt_np_hr = make_fmt(bg_color='#E1F5FE', bold=True, align="right")
fmt_np_rl = make_fmt(bg_color='#E1F5FE', bold=False, align="left")
fmt_np_rr = make_fmt(bg_color='#E1F5FE', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Нет пропуска", fmt_np_hl)
ws.write(current_row, 1, len(no_pass_df), fmt_np_hr)
current_row += 1 current_row += 1
if not no_pass_df.empty: if not no_pass_df.empty:
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio) ws.set_row(current_row, 20, None, {'level': 1, 'hidden': False})
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False) ws.write(current_row, 0, fio, fmt_np_rl)
ws.row_dimensions[current_row].outlineLevel = 1 ws.write(current_row, 1, "", fmt_np_rr)
ws.row_dimensions[current_row].hidden = False
current_row += 1 current_row += 1
# 3. Официальные отсутствия (Сотрудники из исключений при наличии документа 1С попадают сюда)
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
@@ -209,55 +236,63 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
(merged_df['Пришел'] == False) & (merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) & (merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
(~is_remote_reason) & (~is_remote_reason)
(~is_exc)
] ]
absent_groups = absent_only.groupby('Вид_отсутствия') absent_groups = absent_only.groupby('Вид_отсутствия')
pastels = ['#FFF2CC', '#E1D5E7', '#E1F5FE', '#FFF0F5', '#FCF3CF']
for idx_cat, (cat_name, group) in enumerate(absent_groups): for idx_cat, (cat_name, group) in enumerate(absent_groups):
hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)] hex_c = pastels[idx_cat % len(pastels)]
cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid") fmt_cat_hl = make_fmt(bg_color=hex_c, bold=True, align="left")
fmt_cat_hr = make_fmt(bg_color=hex_c, bold=True, align="right")
fmt_cat_rl = make_fmt(bg_color=hex_c, bold=False, align="left")
fmt_cat_rr = make_fmt(bg_color=hex_c, bold=False, align="right")
ws.cell(row=current_row, column=1, value=cat_name) ws.set_row(current_row, 20)
ws.cell(row=current_row, column=2, value=len(group)) ws.write(current_row, 0, cat_name, fmt_cat_hl)
format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) ws.write(current_row, 1, len(group), fmt_cat_hr)
current_row += 1 current_row += 1
for fio in sorted(group['Сотрудник'].dropna().unique()): for fio in sorted(group['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio) ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
format_row_cells(ws, current_row, cat_fill, is_bold=False) ws.write(current_row, 0, fio, fmt_cat_rl)
ws.row_dimensions[current_row].outlineLevel = 1 ws.write(current_row, 1, "", fmt_cat_rr)
ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] # 4. Итого на работе (Только общее число, без раскрывающегося списка ФИО. Включает исключения без справок)
ws.cell(row=current_row, column=1, value="Итого на работе") exc_without_doc = merged_df[is_exc & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip().isin(['', 'nan', 'Исключение'])))]
ws.cell(row=current_row, column=2, value=len(present)) present_scud = merged_df[(merged_df['Пришел'] == True) & (~is_exc)]
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
total_present_count = len(present_scud) + len(exc_without_doc)
fmt_pres_hl = make_fmt(bg_color='#E2EFDA', bold=True, align="left")
fmt_pres_hr = make_fmt(bg_color='#E2EFDA', bold=True, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "Итого на работе", fmt_pres_hl)
ws.write(current_row, 1, total_present_count, fmt_pres_hr)
current_row += 1 current_row += 1
if not present.empty: # 5. Удаленная работа (Свернуто)
for fio in sorted(present['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
ws.cell(row=current_row, column=1, value="В том числе на удаленной работе") fmt_rem_hl = make_fmt(bg_color='#E8F8F5', bold=True, align="left")
ws.cell(row=current_row, column=2, value=len(remote_home)) fmt_rem_hr = make_fmt(bg_color='#E8F8F5', bold=True, align="right")
format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font) fmt_rem_rl = make_fmt(bg_color='#E8F8F5', bold=False, align="left")
fmt_rem_rr = make_fmt(bg_color='#E8F8F5', bold=False, align="right")
ws.set_row(current_row, 20)
ws.write(current_row, 0, "В том числе на удаленной работе", fmt_rem_hl)
ws.write(current_row, 1, len(remote_home), fmt_rem_hr)
current_row += 1 current_row += 1
if not remote_home.empty: if not remote_home.empty:
for fio in sorted(remote_home['Сотрудник'].dropna().unique()): for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio) ws.set_row(current_row, 20, None, {'level': 1, 'hidden': True, 'collapsed': True})
format_row_cells(ws, current_row, FILL_REMOTE, is_bold=False) ws.write(current_row, 0, fio, fmt_rem_rl)
ws.row_dimensions[current_row].outlineLevel = 1 ws.write(current_row, 1, "", fmt_rem_rr)
ws.row_dimensions[current_row].hidden = True
current_row += 1 current_row += 1
# 6. Аномалии СКУД и 1С (Свернуто)
anomalies = merged_df[ anomalies = merged_df[
(~is_exc) & ( (~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
@@ -267,10 +302,14 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
) )
] ]
fmt_anom_hl = make_fmt(bg_color='#FCE4D6', bold=True, align="left")
fmt_anom_hr = make_fmt(bg_color='#FCE4D6', bold=True, align="right")
fmt_anom_rl = make_fmt(bg_color='#FCE4D6', bold=False, align="left")
fmt_anom_rr = make_fmt(bg_color='#FCE4D6', bold=False, align="left", wrap=True)
ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С") ws.set_row(current_row, 20)
ws.cell(row=current_row, column=2, value=len(anomalies)) ws.write(current_row, 0, "Аномалии СКУД и 1С", fmt_anom_hl)
format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font) ws.write(current_row, 1, len(anomalies), fmt_anom_hr)
current_row += 1 current_row += 1
chars_per_line_b = 30 chars_per_line_b = 30
@@ -286,49 +325,23 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
else: else:
reason_text = f"В 1С: {reason}" reason_text = f"В 1С: {reason}"
cell_a = ws.cell(row=current_row, column=1, value=f"{fio}")
cell_b = ws.cell(row=current_row, column=2, value=reason_text)
cell_a.fill = FILL_ANOMALY
cell_b.fill = FILL_ANOMALY
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
cell_a.alignment = Alignment(horizontal="left", vertical="center")
lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1 lines_count = math.ceil(len(reason_text) / chars_per_line_b) if len(reason_text) > chars_per_line_b else 1
ws.row_dimensions[current_row].height = max(lines_count * 18, 20) row_h = max(lines_count * 18, 20)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True ws.set_row(current_row, row_h, None, {'level': 1, 'hidden': True, 'collapsed': True})
ws.write(current_row, 0, fio, fmt_anom_rl)
ws.write(current_row, 1, reason_text, fmt_anom_rr)
current_row += 1 current_row += 1
exceptions_df = merged_df[is_exc] ws.set_column(0, 0, 45)
ws.cell(row=current_row, column=1, value="Исключения") ws.set_column(1, 1, 38)
ws.cell(row=current_row, column=2, value=len(exceptions_df))
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=True, bold_font=bold_font)
current_row += 1
if not exceptions_df.empty: safe_close_workbook(wb, output_path, target_dir, filename)
for fio in sorted(exceptions_df['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
ws.column_dimensions['A'].width = 45.0
ws.column_dimensions['B'].width = 38.0
try:
wb.save(output_path)
print(f"[✓] Ежедневная сводка сохранена: {output_path}")
except (PermissionError, OSError):
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(target_dir, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С ПРАВИЛОМ 8.5ч) --- # =============================================================================
# 2. ДЕТАЛЬНЫЙ СУТОЧНЫЙ ОТЧЕТ ЗА ВЧЕРА
# =============================================================================
def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
date_clean = str(date_str).replace('_', '.') date_clean = str(date_str).replace('_', '.')
if not filename: if not filename:
@@ -341,29 +354,37 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
target_dir = get_dated_reports_dir(date_clean) target_dir = get_dated_reports_dir(date_clean)
output_path = os.path.join(target_dir, filename) output_path = os.path.join(target_dir, filename)
wb = Workbook()
ws = wb.active
ws.title = "Детальный_отчет"
ws["B2"] = "Дата:" wb = xlsxwriter.Workbook(output_path)
ws["D2"] = date_clean ws = wb.add_worksheet("Детальный_отчет")
ws["B2"].font = Font(name="Arial", size=10, bold=True)
ws["D2"].font = Font(name="Arial", size=10, bold=True) def make_fmt(bg_color=None, bold=False, align="left", wrap=False):
d = {
'font_name': 'Arial',
'font_size': 10,
'bold': bold,
'align': align,
'valign': 'vcenter',
'border': 1,
'border_color': '#D3D3D3',
'text_wrap': wrap
}
if bg_color:
d['bg_color'] = bg_color
return wb.add_format(d)
fmt_date_lbl = wb.add_format({'font_name': 'Arial', 'font_size': 10, 'bold': True})
ws.write(1, 1, "Дата:", fmt_date_lbl)
ws.write(1, 3, date_clean, fmt_date_lbl)
headers = [ headers = [
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода", "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы" "находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
] ]
ws.append([]) fmt_hdr = make_fmt(bg_color='#D9E1F2', bold=True, align="center", wrap=True)
ws.append(headers) ws.set_row(3, 26)
for col_idx, h_text in enumerate(headers):
header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") ws.write(3, col_idx, h_text, fmt_hdr)
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=4, column=col_idx)
cell.fill = header_fill
cell.font = Font(name="Arial", size=10, bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
apply_borders_to_cell(cell)
start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня' start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня'
end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня' end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня'
@@ -372,6 +393,7 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
chars_per_line_h = 24 chars_per_line_h = 24
for idx, row in df_export.reset_index(drop=True).iterrows(): for idx, row in df_export.reset_index(drop=True).iterrows():
row_num = 4 + idx
is_present = row.get('Пришел', False) is_present = row.get('Пришел', False)
absence_reason = row.get('Вид_отсутствия', '') absence_reason = row.get('Вид_отсутствия', '')
has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
@@ -382,7 +404,6 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
first_act_val = str(row.get('Первая_активность', '—')).strip() first_act_val = str(row.get('Первая_активность', '—')).strip()
has_first_act = first_act_val not in ['—', '', 'None', 'nan'] has_first_act = first_act_val not in ['—', '', 'None', 'nan']
# ⭐️ ПРАВИЛО 8.5ч: если есть вход, но нет выхода (и нет официального документа отсутствия)
if in_val not in ['Нет входа', '—', '', 'nan', 'None'] and out_val in ['Нет выхода', '—', '', 'nan', 'None'] and not has_reason: if in_val not in ['Нет входа', '—', '', 'nan', 'None'] and out_val in ['Нет выхода', '—', '', 'nan', 'None'] and not has_reason:
out_val, in_building_str, deviation_val = calculate_autoclose_time(in_val) out_val, in_building_str, deviation_val = calculate_autoclose_time(in_val)
else: else:
@@ -390,60 +411,104 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
ws.append([ row_color = None
idx + 1,
row.get('Сотрудник', ''),
dept_scud_val,
in_val,
first_act_val,
out_val,
in_building_str,
absence_reason if has_reason else '',
8,
deviation_val
])
row_num = 5 + idx
if is_present and has_reason: if is_present and has_reason:
row_fill = GREEN_FILL row_color = '#E2EFDA'
elif not is_present and has_reason: elif not is_present and has_reason:
row_fill = YELLOW_FILL row_color = '#FFF2CC'
elif not is_present and not has_reason and not has_first_act: elif not is_present and not has_reason and not has_first_act:
row_fill = LIGHT_RED_FILL row_color = '#FCE4D6'
else:
row_fill = None
val_h_str = str(absence_reason) if has_reason else "" val_h_str = str(absence_reason) if has_reason else ""
lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1 lines_count = math.ceil(len(val_h_str) / chars_per_line_h) if len(val_h_str) > chars_per_line_h else 1
ws.row_dimensions[row_num].height = max(lines_count * 18, 20) ws.set_row(row_num, max(lines_count * 18, 20))
for col_idx in range(1, len(headers) + 1): values = [
cell = ws.cell(row=row_num, column=col_idx) (idx + 1, 'center', False),
apply_borders_to_cell(cell) (row.get('Сотрудник', ''), 'left', False),
if row_fill: (dept_scud_val, 'center', False),
cell.fill = row_fill (in_val, 'center', False),
if col_idx == 8: (first_act_val, 'center', False),
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) (out_val, 'center', False),
elif col_idx in [1, 4, 5, 6, 7, 9, 10]: (in_building_str, 'center', False),
cell.alignment = Alignment(horizontal="center", vertical="center") (absence_reason if has_reason else '', 'left', True),
else: (8, 'center', False),
cell.alignment = Alignment(horizontal="left", vertical="center") (deviation_val, 'center', False)
]
for col in ws.columns: for col_idx, (val, align_type, is_wrap) in enumerate(values):
col_letter = get_column_letter(col[0].column) fmt = make_fmt(bg_color=row_color, align=align_type, wrap=is_wrap)
max_len = max((len(str(cell.value or '')) for cell in col), default=8) ws.write(row_num, col_idx, val, fmt)
ws.column_dimensions[col_letter].width = min(max(max_len + 2, 8), 35)
try: col_widths = {
wb.save(output_path) 0: 4,
print(f"[✓] Детальный отчет сохранен: {output_path}") 1: 33,
except (PermissionError, OSError): 2: 13,
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") 3: 11,
alt_path = os.path.join(target_dir, alt_filename) 4: 11,
wb.save(alt_path) 5: 11,
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") 6: 12,
7: 24,
8: 6,
9: 11
}
for col_idx, width in col_widths.items():
ws.set_column(col_idx, col_idx, width)
safe_close_workbook(wb, output_path, target_dir, filename)
# =============================================================================
# 3. СЫРОЙ СКУД
# =============================================================================
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"): def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
output_path = os.path.join(REPORTS_DIR, filename) output_path = os.path.join(REPORTS_DIR, filename)
df_scud.to_excel(output_path, index=False) target_dir = os.path.dirname(output_path)
wb = xlsxwriter.Workbook(output_path)
ws = wb.add_worksheet("Сырые_данные")
fmt_hdr = wb.add_format({
'font_name': 'Calibri',
'font_size': 11,
'bold': True,
'bg_color': '#D9E1F2',
'border': 1,
'border_color': '#D3D3D3',
'align': 'center',
'valign': 'vcenter'
})
fmt_cell = wb.add_format({
'font_name': 'Calibri',
'font_size': 11,
'border': 1,
'border_color': '#D3D3D3',
'valign': 'vcenter',
'align': 'left'
})
headers = list(df_scud.columns)
ws.set_row(3, 28)
for col_idx, header in enumerate(headers):
ws.write(0, col_idx, str(header), fmt_hdr)
col_widths = [len(str(h)) for h in headers]
for row_idx, row_values in enumerate(df_scud.values, start=1):
ws.set_row(row_idx, 19)
for col_idx, val in enumerate(row_values):
if pd.isna(val) or val is None:
val_str = ""
elif isinstance(val, bool):
val_str = "Да" if val else "Нет"
else:
val_str = str(val)
ws.write(row_idx, col_idx, val_str, fmt_cell)
if len(val_str) > col_widths[col_idx]:
col_widths[col_idx] = len(val_str)
for col_idx, width in enumerate(col_widths):
ws.set_column(col_idx, col_idx, min(max(width + 3, 10), 45))
safe_close_workbook(wb, output_path, target_dir, filename)
+21 -20
View File
@@ -2,6 +2,7 @@
=============================================================================== ===============================================================================
FILE: services/scud_etl/merger.py FILE: services/scud_etl/merger.py
ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки. ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки.
Распределение исключений в общий рабочий пул при отсутствии справок.
=============================================================================== ===============================================================================
""" """
@@ -17,10 +18,8 @@ logger = logging.getLogger("SCUD_MERGER")
def load_identity_mappings() -> Dict[str, str]: def load_identity_mappings() -> Dict[str, str]:
"""Загружает подтвержденные сопоставления ФИО (СКУД -> 1С:ЗУП) из SQLite."""
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Автоматическая инициализация таблицы при первом обращении
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS person_identity_mapping ( CREATE TABLE IF NOT EXISTS person_identity_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -44,7 +43,6 @@ def load_identity_mappings() -> Dict[str, str]:
def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame: def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
"""Схлопывает дубликаты пропусков одного человека в СКУД."""
if df_scud is None or df_scud.empty: if df_scud is None or df_scud.empty:
return df_scud return df_scud
@@ -53,7 +51,6 @@ def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
fio_col = 'Сотрудник' if 'Сотрудник' in df.columns else 'ФИО' fio_col = 'Сотрудник' if 'Сотрудник' in df.columns else 'ФИО'
df['fio_clean'] = df[fio_col].apply(normalize_fio) df['fio_clean'] = df[fio_col].apply(normalize_fio)
# Применяем сохраненный кэш связок ФИО
mapping_dict = load_identity_mappings() mapping_dict = load_identity_mappings()
if mapping_dict: if mapping_dict:
df['fio_clean'] = df['fio_clean'].apply(lambda f: mapping_dict.get(f, f)) df['fio_clean'] = df['fio_clean'].apply(lambda f: mapping_dict.get(f, f))
@@ -95,10 +92,6 @@ def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame:
def select_best_zup_position(df_staff_1c: pd.DataFrame, df_scud_agg: pd.DataFrame) -> pd.DataFrame: def select_best_zup_position(df_staff_1c: pd.DataFrame, df_scud_agg: pd.DataFrame) -> pd.DataFrame:
"""
⭐️ Для совместителей с несколькими должностями в 1С:ЗУП
выбирает ту ставку, которая соответствует отделу физического нахождения по СКУД.
"""
if df_staff_1c is None or df_staff_1c.empty: if df_staff_1c is None or df_staff_1c.empty:
return pd.DataFrame() return pd.DataFrame()
@@ -210,15 +203,20 @@ def merge_scud_and_1c(
fio = row.get("fio_clean", "") fio = row.get("fio_clean", "")
has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() not in ["", "nan", "None", "Исключение"] has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() not in ["", "nan", "None", "Исключение"]
if fio in whitelist_fios or has_official_absence: if fio in whitelist_fios:
df_res.at[idx, "is_excluded"] = False df_res.at[idx, "is_excluded"] = False
continue continue
dep = str(row.get("Подразделение", "")).upper() dep = str(row.get("Подразделение", "")).upper()
pos = str(row.get("Должность", "")).lower() pos = str(row.get("Должность", "")).lower()
if fio in exc_fios or dep in exc_depts or any(d in dep for d in exc_depts) or pos in exc_pos or any(k in pos for k in pos_kw): is_match_exc = (fio in exc_fios or dep in exc_depts or any(d in dep for d in exc_depts) or pos in exc_pos or any(k in pos for k in pos_kw))
df_res.at[idx, "is_excluded"] = True
if is_match_exc:
if has_official_absence:
df_res.at[idx, "is_excluded"] = False
else:
df_res.at[idx, "is_excluded"] = True
mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == "")) mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == ""))
df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение" df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение"
@@ -228,21 +226,24 @@ def merge_scud_and_1c(
def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
active_df = df_merged[df_merged.get("is_excluded", False) == False].copy() total_staff = len(df_merged)
total_staff = len(active_df)
came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа") came_to_office_mask = (df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")) & (df_merged.get("is_excluded", False) == False)
working_in_office = active_df[came_to_office_mask] exc_without_doc_mask = (df_merged.get("is_excluded", False) == True) & (
working_in_office_count = len(working_in_office) df_merged["Вид_отсутствия"].isna() |
df_merged["Вид_отсутствия"].astype(str).str.strip().isin(["", "nan", "Исключение"])
)
df_not_came = active_df[~came_to_office_mask] working_in_office_count = len(df_merged[came_to_office_mask | exc_without_doc_mask])
reason_series = df_not_came["причина отсутствия"].astype(str).str.lower() df_not_working = df_merged[~came_to_office_mask & ~exc_without_doc_mask]
reason_series = df_not_working["причина отсутствия"].astype(str).str.lower()
is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False) is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
remote_home = df_not_came[is_remote_mask] remote_home = df_not_working[is_remote_mask]
remote_home_count = len(remote_home) remote_home_count = len(remote_home)
df_remaining_absent = df_not_came[~is_remote_mask] df_remaining_absent = df_not_working[~is_remote_mask]
has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \ has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \
df_remaining_absent["причина отсутствия"].ne("") & \ df_remaining_absent["причина отсутствия"].ne("") & \
df_remaining_absent["причина отсутствия"].ne("nan") & \ df_remaining_absent["причина отсутствия"].ne("nan") & \
+95 -57
View File
@@ -1,6 +1,9 @@
""" """
Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel. ===============================================================================
Добавлена фиксация Первой Активности (без учета направления) и среза по времени. FILE: services/scud_export.py
ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter).
Корректная фильтрация транзитных проходов турникетов парковки и двора.
===============================================================================
""" """
import argparse import argparse
@@ -10,9 +13,14 @@ import sys
import warnings import warnings
from datetime import datetime, timedelta from datetime import datetime, timedelta
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
import pandas as pd import pandas as pd
import pyodbc import pyodbc
from openpyxl.utils import get_column_letter import xlsxwriter
from config import SCUD_DIR, clean_scud_fio_light, load_exceptions from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot
@@ -31,12 +39,17 @@ logger.setLevel(logging.INFO)
logger.propagate = False logger.propagate = False
if not logger.handlers: if not logger.handlers:
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") try:
_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_file_handler.setFormatter(_formatter)
logger.addHandler(_file_handler)
except (PermissionError, OSError) as e:
sys.stderr.write(f"Предупреждение: невозможно создать лог-файл {LOG_FILE}: {e}\n")
_console_handler = logging.StreamHandler(sys.stdout) _console_handler = logging.StreamHandler(sys.stdout)
_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
_file_handler.setFormatter(_formatter)
_console_handler.setFormatter(_formatter) _console_handler.setFormatter(_formatter)
logger.addHandler(_file_handler)
logger.addHandler(_console_handler) logger.addHandler(_console_handler)
@@ -73,8 +86,8 @@ WITH DailyLogs AS (
log.Event, log.Event,
log.Mode, log.Mode,
CASE CASE
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT' WHEN log.Mode = 2 OR log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT'
WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN' WHEN log.Mode = 1 OR log.Event IN (1, 21, 26, 28, 32, 54, 64) THEN 'IN'
ELSE 'OTHER' ELSE 'OTHER'
END AS Direction, END AS Direction,
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
@@ -82,7 +95,7 @@ WITH DailyLogs AS (
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0 AND log.HozOrgan > 0
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65) AND log.Event IN (1, 2, 21, 26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
), ),
Passages AS ( Passages AS (
SELECT SELECT
@@ -90,10 +103,22 @@ Passages AS (
MIN(TimeVal) AS FirstRawEvent, MIN(TimeVal) AS FirstRawEvent,
MAX(TimeVal) AS LastRawEvent, MAX(TimeVal) AS LastRawEvent,
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn, MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut, -- Реальный выход: должен быть позже входа минимум на 5 минут (отсекает проезд через шлагбаум/парковку)
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS RawLastOut
FROM DailyLogs FROM DailyLogs
GROUP BY EmployeeID GROUP BY EmployeeID
),
EvaluatedPassages AS (
SELECT
p.*,
CASE
WHEN p.RawLastOut IS NOT NULL
AND p.FirstIn IS NOT NULL
AND p.RawLastOut > DATEADD(MINUTE, 5, p.FirstIn)
THEN p.RawLastOut
ELSE NULL
END AS FilteredLastOut
FROM Passages p
) )
SELECT SELECT
N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма], N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
@@ -115,12 +140,8 @@ SELECT
ELSE N'—' ELSE N'—'
END AS [Первая_активность], END AS [Первая_активность],
CASE CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) WHEN pass.FilteredLastOut IS NOT NULL
THEN N'Нет выхода' THEN CAST(CONVERT(VARCHAR(8), pass.FilteredLastOut, 108) AS NVARCHAR(20))
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
ELSE N'Нет выхода' ELSE N'Нет выхода'
END AS [Конец_дня], END AS [Конец_дня],
CASE CASE
@@ -128,14 +149,16 @@ SELECT
RIGHT('0' + CAST(DATEDIFF(MINUTE, RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent), ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
ELSE ISNULL(pass.LastOut, pass.LastRawEvent) WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE()
ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent)
END) / 60 AS VARCHAR), 2) + ':' + END) / 60 AS VARCHAR), 2) + ':' +
RIGHT('0' + CAST(DATEDIFF(MINUTE, RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent), ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
ELSE ISNULL(pass.LastOut, pass.LastRawEvent) WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE()
ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent)
END) % 60 AS VARCHAR), 2) END) % 60 AS VARCHAR), 2)
ELSE N'00:00' ELSE N'00:00'
END AS [Находился_в_здании], END AS [Находился_в_здании],
@@ -146,7 +169,7 @@ SELECT
FROM pList p WITH (NOLOCK) FROM pList p WITH (NOLOCK)
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
LEFT JOIN Passages pass ON p.ID = pass.EmployeeID LEFT JOIN EvaluatedPassages pass ON p.ID = pass.EmployeeID
WHERE WHERE
ISNULL(p.StatusRecord, 0) = 0 ISNULL(p.StatusRecord, 0) = 0
AND p.DateTimeInArchive IS NULL AND p.DateTimeInArchive IS NULL
@@ -164,24 +187,54 @@ ORDER BY p.Name ASC;
""" """
def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0): def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "Отчет"):
from openpyxl import load_workbook workbook = xlsxwriter.Workbook(file_path, {'constant_memory': False})
worksheet = workbook.add_worksheet(sheet_name)
wb = load_workbook(file_path) fmt_header = workbook.add_format({
ws = wb[sheet_name] 'bold': True,
'bg_color': '#D9E1F2',
'border': 1,
'border_color': '#D3D3D3',
'align': 'center',
'valign': 'vcenter',
'font_name': 'Calibri',
'font_size': 11
})
for col_cells in ws.columns: fmt_cell = workbook.add_format({
max_len = 0 'border': 1,
col_letter = get_column_letter(col_cells[0].column) 'border_color': '#D3D3D3',
for cell in col_cells: 'valign': 'vcenter',
if cell.value is not None: 'align': 'left',
cell_len = len(str(cell.value)) 'font_name': 'Calibri',
if cell_len > max_len: 'font_size': 11
max_len = cell_len })
width = max(min_width, min(max_len + padding, max_width))
ws.column_dimensions[col_letter].width = width
wb.save(file_path) headers = list(df.columns)
col_widths = [len(str(h)) for h in headers]
for col_idx, header in enumerate(headers):
worksheet.write(0, col_idx, str(header), fmt_header)
for row_idx, row_values in enumerate(df.values, start=1):
for col_idx, val in enumerate(row_values):
if pd.isna(val) or val is None:
val_str = ""
elif isinstance(val, bool):
val_str = "Да" if val else "Нет"
else:
val_str = str(val)
worksheet.write(row_idx, col_idx, val_str, fmt_cell)
if len(val_str) > col_widths[col_idx]:
col_widths[col_idx] = len(val_str)
for col_idx, width in enumerate(col_widths):
worksheet.set_column(col_idx, col_idx, min(max(width + 3, 10), 45))
workbook.close()
def get_targets(input_date: str | None): def get_targets(input_date: str | None):
@@ -230,8 +283,6 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
period_label = target["name"] period_label = target["name"]
is_yesterday = (period_label == "Вчера") is_yesterday = (period_label == "Вчера")
# ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE:
# Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL
if is_yesterday and has_yesterday_final_snapshot(processing_date_str): if is_yesterday and has_yesterday_final_snapshot(processing_date_str):
log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.") log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.")
continue continue
@@ -273,8 +324,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
except OSError as e: except OSError as e:
log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR")
df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") save_df_to_clean_excel(df, file_path, sheet_name="Отчет")
auto_fit_columns(file_path, sheet_name="Отчет")
log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS") log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS")
else: else:
log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING") log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING")
@@ -291,22 +341,10 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
if __name__ == "__main__": if __name__ == "__main__":
help_text = """ parser = argparse.ArgumentParser()
Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel. parser.add_argument("--date", dest="input_date", default=None)
parser.add_argument("-d", "--debug", action="store_true")
ПРИМЕРЫ ЗАПУСКА: parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True)
python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера
python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату
python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX
python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)")
parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL")
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/")
args = parser.parse_args() args = parser.parse_args()
run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug) run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug)
+10 -1
View File
@@ -1,3 +1,10 @@
"""
===============================================================================
FILE: services/share_copier.py
ROLE: Синхронизация файлов 1С (Штат и Отсутствия) с сетевой шары в data/1c/.
===============================================================================
"""
import os import os
import shutil import shutil
from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR
@@ -52,7 +59,9 @@ def copy_1c_files_from_share():
local_target_path = os.path.join(ZUP_1C_DIR, filename) local_target_path = os.path.join(ZUP_1C_DIR, filename)
try: try:
shutil.copy2(remote_file, local_target_path) # shutil.copyfile копирует только содержимое потока байтов
# без попыток изменить POSIX-права/атрибуты (chmod) на CIFS/SMB шаре
shutil.copyfile(remote_file, local_target_path)
print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/") print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/")
copied_count += 1 copied_count += 1
except Exception as e: except Exception as e:
+112
View File
@@ -0,0 +1,112 @@
"""
===============================================================================
FILE: services/snapshots/retention.py
ROLE: Политика ротации и очистки промежуточных почасовых срезов в SQLite.
===============================================================================
"""
import logging
from datetime import datetime, timedelta
from typing import List, Tuple
from core.connection import get_connection
logger = logging.getLogger("SNAPSHOT_RETENTION")
def cleanup_old_intermediate_snapshots(days_to_keep_all: int = 2) -> int:
"""
Очищает промежуточные дневные срезы старше days_to_keep_all дней.
Правило сохранения для архивных дней:
- Сохраняется срез Y (23:59:59).
- Сохраняется полуденный срез (ближайший к 13:00).
- Все остальные промежуточные снапшоты удаляются из scud_logs.
Возвращает количество удаленных записей.
"""
cutoff_date = datetime.now() - timedelta(days=days_to_keep_all)
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
deleted_rows_count = 0
with get_connection() as conn:
cursor = conn.cursor()
# 1. Получаем список всех уникальных дат старше cutoff_date
cursor.execute("""
SELECT DISTINCT log_date
FROM scud_logs
WHERE snapshot_time IS NOT NULL
""")
all_dates = [row[0] for row in cursor.fetchall()]
for raw_date in all_dates:
try:
# Преобразуем формат ДД.ММ.ГГГГ в объект даты
d_clean = raw_date.replace('_', '.')
dt_obj = datetime.strptime(d_clean, "%d.%m.%Y")
if dt_obj >= cutoff_date:
continue # Свежие дни не трогаем — там хранятся все срезы
# 2. Для архивной даты ищем все снапшоты
cursor.execute("""
SELECT DISTINCT snapshot_id, snapshot_time
FROM scud_logs
WHERE log_date = ?
""", (raw_date,))
snapshots = cursor.fetchall()
if len(snapshots) <= 2:
continue # Если и так 1-2 среза, ничего чистить не нужно
keep_ids = set()
# Ищем финальный срез Y / 23:59:59 / 22:00:00
for s_id, s_time in snapshots:
s_id_str = str(s_id)
s_time_str = str(s_time)
if s_id_str.startswith('Y') or '23:59:59' in s_time_str or '22:00:00' in s_time_str:
keep_ids.add(s_id)
break
# Ищем полуденный срез (ближайший к 13:00)
noon_target = datetime.strptime(f"{d_clean} 13:00:00", "%d.%m.%Y %H:%M:%S")
best_noon_id = None
min_noon_diff = float('inf')
for s_id, s_time in snapshots:
if s_id in keep_ids:
continue
try:
raw_t = str(s_time).strip()
if '.' in raw_t.split()[0]:
dt_s = datetime.strptime(raw_t, "%d.%m.%Y %H:%M:%S")
else:
dt_s = datetime.strptime(raw_t, "%Y-%m-%d %H:%M:%S")
diff = abs((dt_s - noon_target).total_seconds())
if diff < min_noon_diff:
min_noon_diff = diff
best_noon_id = s_id
except Exception:
continue
if best_noon_id:
keep_ids.add(best_noon_id)
# 3. Все снапшоты, не попавшие в keep_ids, удаляем
to_delete_ids = [s_id for s_id, _ in snapshots if s_id not in keep_ids]
for del_id in to_delete_ids:
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (del_id,))
deleted_rows_count += cursor.rowcount
logger.info(f"Удален промежуточный архивный срез {del_id} за {raw_date}")
except Exception as e:
logger.warning(f"Ошибка при обработке ротации за дату {raw_date}: {e}")
conn.commit()
if deleted_rows_count > 0:
logger.info(f"Ротация завершена: удалено {deleted_rows_count} строк промежуточных срезов.")
return deleted_rows_count
+46 -75
View File
@@ -3,99 +3,70 @@
FILE: services/tasks/exporter.py FILE: services/tasks/exporter.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / tasks MODULE: services / tasks
ROLE: Экспорт бэклога задач в форматированный Markdown файл (ROADMAP). ROLE: Экспорт задач в чистый Markdown и генерация прямой ссылки на скачивание.
AI-CONTEXT-ANCHORS:
- ANCHOR[TASK_EXPORT_MARKDOWN]: Построение структуры Markdown с чекбоксами.
=============================================================================== ===============================================================================
""" """
import os import os
import uuid import uuid
import logging from typing import Dict, Any, Optional
from datetime import datetime from config import BASE_DIR
from typing import Dict, Any, Optional, List
from config import OUTPUT_DIR
from .repository import repo_get_tasks from .repository import repo_get_tasks
logger = logging.getLogger("TASK_EXPORTER") WEB_OUTPUT_DIR = os.path.join(BASE_DIR, "output", "web", "db_export_tasks_markdown")
# ANCHOR[TASK_EXPORT_MARKDOWN] def export_tasks_to_markdown(
def export_tasks_to_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]: user_id: int,
"""Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/.""" filename: Optional[str] = "ROADMAP.md",
tasks = repo_get_tasks(user_id) status_filter: Optional[str] = None
if not tasks: ) -> Dict[str, Any]:
return {"status": "error", "message": "Список задач пуст, экспорт отменен"} tasks = repo_get_tasks(user_id=user_id, status=status_filter)
safe_filename = os.path.basename(filename or "ROADMAP.md")
if not safe_filename.endswith(".md"):
safe_filename += ".md"
# 1. Фильтрация задач по статусу session_uuid = str(uuid.uuid4())[:8]
if status_filter and status_filter.upper() != "ALL": target_dir = os.path.join(WEB_OUTPUT_DIR, session_uuid)
tgt = status_filter.upper() os.makedirs(target_dir, exist_ok=True)
if tgt in ["COMPLETED", "DONE", "ВЫПОЛНЕННЫЕ"]: target_filepath = os.path.join(target_dir, safe_filename)
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: lines = [
return {"status": "error", "message": f"Нет задач с фильтром '{status_filter}' для экспорта"} f"# 📋 Реестр задач проекта ({safe_filename})",
f"**Всего задач:** {len(tasks)} ",
target_filename = filename.strip() if (filename and filename.strip()) else "ROADMAP.md" f"**Пользователь ID:** {user_id} ",
if not target_filename.endswith(".md"): "",
target_filename = f"{target_filename}.md" "| ID | Статус | Приоритет | Модуль | Срок | Задача |",
"| :--- | :--- | :--- | :--- | :--- | :--- |"
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
# 2. Группировка по модулям
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()): status_icons = {
md_lines.append(f"## Модуль `{mod_name}`\n") "IN_PROGRESS": "⚙️ В работе",
for t in sorted(mod_tasks, key=lambda x: x.get("id", 0)): "COMPLETED": "✓ Завершено",
status = str(t.get("status", "BACKLOG")).upper() "BACKLOG": "📋 Бэклог"
is_done = status in ["COMPLETED", "DONE"] }
is_progress = status in ["IN_PROGRESS", "PROGRESS"]
check_box = "[x]" if is_done else "[ ]" for t in tasks:
t_id = t.get("id") t_id = t.get("task_id") or f"#{t.get('id')}"
title = t.get("title", "Без названия") t_status = status_icons.get(t.get("status"), t.get("status", "BACKLOG"))
prio = t.get("priority", "MEDIUM") t_prio = t.get("priority", "MEDIUM")
due = f" *(срок: {t['due_date']})*" if t.get("due_date") else "" t_mod = t.get("module", "general")
status_tag = " `[В РАБОТЕ]`" if is_progress else (" `[ЗАВЕРШЕНО]`" if is_done else "") t_due = t.get("due_date") or "—"
t_title = str(t.get("title", "")).replace("|", "\\|").strip()
lines.append(f"| `{t_id}` | {t_status} | {t_prio} | `{t_mod}` | {t_due} | {t_title} |")
md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}") lines.append("")
content = "\n".join(lines)
md_lines.append("\n---\n") with open(target_filepath, "w", encoding="utf-8") as f:
content = "\n".join(md_lines)
# 3. Сохранение файла в изолированную сессионную папку
tool_dir = os.path.join(OUTPUT_DIR, "web", "tasks_export")
os.makedirs(tool_dir, exist_ok=True)
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) f.write(content)
download_url = f"/api/v1/files/download/db_export_tasks_markdown/{session_uuid}/{safe_filename}"
return { return {
"status": "success", "status": "success",
"filename": target_filename, "message": f"Отчет успешно сформирован в файл `{safe_filename}` (всего задач: {len(tasks)}).",
"filepath": filepath, "filename": safe_filename,
"download_url": f"/api/v1/files/download/tasks_export/{session_token}/{target_filename}", "download_url": download_url,
"tasks_count": len(tasks), "tasks_count": len(tasks)
"message": f"Отчет успешно сформирован в файл `{target_filename}` (всего задач: {len(tasks)})."
} }
File diff suppressed because it is too large Load Diff