diff --git a/core/schema.py b/core/schema.py index c623dc5..93e4093 100644 --- a/core/schema.py +++ b/core/schema.py @@ -62,7 +62,7 @@ def init_all_tables() -> None: ); """) - # 3. Аномалии и база знаний + # 3. Аномалии, база знаний и синонимы cursor.execute(""" CREATE TABLE IF NOT EXISTS anomalies_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -93,7 +93,34 @@ def init_all_tables() -> None: ); """) - # 4. Узлы системного промпта и сессии + # 4. Исключения и Кэш сопоставлений личностей (ИИ / Ручной) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS exceptions_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL, + value TEXT NOT NULL, + comment TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(category, value) + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS person_identity_mapping ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scud_fio TEXT NOT NULL, + zup_fio TEXT NOT NULL, + scud_dept TEXT, + zup_dept TEXT, + match_source TEXT DEFAULT 'AI', -- 'AI', 'MANUAL', 'EXACT' + status TEXT DEFAULT 'ACTIVE', -- 'ACTIVE', 'PROPOSED', 'REJECTED' + confidence REAL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(scud_fio, zup_fio) + ); + """) + + # 5. Узлы системного промпта, сессии, сообщения и задачи cursor.execute(""" CREATE TABLE IF NOT EXISTS system_prompt_nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -127,18 +154,6 @@ def init_all_tables() -> None: ); """) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS exceptions_registry ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - category TEXT NOT NULL, -- 'department', 'position', 'fio', 'position_keyword', 'include_fio', 'remote_worker' - value TEXT NOT NULL, - comment TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(category, value) - ); - """) - - # 5. Задачи cursor.execute(""" CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -156,7 +171,6 @@ def init_all_tables() -> None: # 6. Индексы cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);") cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_mapping_scud ON person_identity_mapping(scud_fio);") conn.commit() \ No newline at end of file diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index af9d01e..a4511ea 100644 Binary files a/data/scud_orion_ai.db and b/data/scud_orion_ai.db differ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0301c1e..ad9fc49 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,58 @@ # 📋 История изменений (CHANGELOG) -Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6]. +Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле. + +## [3.2.0] — 2026-08-27 +### 🧠 Интеллектуальный арбитраж личностей, агрегация мульти-пропусков и автозакрытие смен + +#### ✨ Добавлено: +- **Кэш сопоставлений личностей (`person_identity_mapping`)**: + - Создана таблица в SQLite (`core/schema.py`) для постоянного хранения подтвержденных связок между сырыми ФИО СКУД и эталонными ФИО 1С:ЗУП. + - Поддержка мгновенного извлечения проверенных сопоставлений в `merger.py` (время выборки < 1 мс). +- **Агрегация дубликатов пропусков и мульти-учеток СКУД (`merger.py`)**: + - Функция `aggregate_scud_by_person` объединяет все карты/записи одного физлица: фиксирует самый ранний вход `min(valid_ins)`, самый поздний выход `max(valid_outs)` и итоговый статус присутствия. + - Исключено ложное попадание сотрудников с несколькими пропусками (например, Кондрашова Е.В.) в категорию «неизвестно». +- **Интеллектуальный выбор ставки совместителей 1С (`merger.py`)**: + - Функция `select_best_zup_position` сопоставляет множественные должности одного сотрудника в 1С со СКУД и подтягивает ставку того подразделения, где человек находится физически. +- **Автозакрытие незакрытых смен по Правилу 8.5ч (`excel_exporter.py`)**: + - При наличии утреннего входа и отсутствии вечернего выхода у офисных сотрудников в детальном отчете за вчера расчетный выход ставится как `Вход + 8ч 30мин`, норма 8 часов и нулевое отклонение `0:00` (вместо ложного штрафа `-8:00`). +- **Теневой ИИ-арбитраж нераспознанных ФИО (`text_reporter.py`)**: + - Модуль `find_ai_identity_suggestions` выявляет нечеткие совпадения и опечатки операторов СКУД, выводя рекомендации для кадровой службы в блоке «💡 Предложения ИИ по сопоставлению ФИО». +- **Расширение CLI-утилиты (`scripts/db_cli.py`)**: + - Добавлены команды управления связками: `python scripts/db_cli.py mapping [list|add|del]`. + +#### 🔧 Изменено: +- **`services/scud_etl/anomaly_detector.py`**: + - Добавлено авто-детектирование задвоенных карточек СКУД (`DUPLICATE_SCUD_CARD`) с передачей информации в ИИ-сводку. +- **`main_etl.py`**: + - Восстановлена передача сырых датасетов (`raw_scud_df`, `raw_staff_df`, `raw_absent_df`) в ИИ-аудитор и генерация диагностического дампа `СКУД_Сырые_данные_ДД.ММ.ГГГГ.xlsx`. + +--- + +## [3.1.0] — 2026-08-25 +### 🛡️ Реляционный реестр исключений, Белый список и отказоустойчивость выгрузок + +#### ✨ Добавлено: +- **Реляционный реестр исключений (`exceptions_registry`)**: + - Перенос исключений (`departments`, `positions`, `fio`, `position_keywords`) и белого списка (`include_fio`) из статического JSON в базу данных SQLite (`core/schema.py`). + - Репозиторий `services/exceptions_repo.py` с функциями CRUD и авто-миграцией из `exceptions.json`. +- **Белый список (`include_fio`)**: + - Поддержка явного включения сотрудников в расчет контроллинга в обход должностных и отдельских фильтров (Тарасенко А.А., Журиков М.Н.). +- **Управление исключениями в CLI (`scripts/db_cli.py`)**: + - Добавлены команды `exceptions list`, `exceptions add -c ... -v ...`, `exceptions del`, `exceptions sync`. +- **Резервный загрузчик кадровых отсутствий (`data_loader.py`)**: + - Реализован каскадный fallback: `MS SQL 1C:ЗУП` (таймаут 30с) $\rightarrow$ резервный парсинг скопированных Excel-файлов `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`. + +#### 🔧 Изменено: +- **Структура отчетов Excel (`excel_exporter.py`)**: + - Изменен базовый путь сохранения отчетов на `output/reports/ГОД/МЕСЯЦ/`. + - В Ежедневной сводке блок «В том числе на удаленной работе» вынесен под «Итого на работе», а блок «Исключения» размещен в самом низу с поддержкой группировки. + - Добавлена защита от блокировок Excel (`PermissionError`/`OSError`): при занятости файла создается резервная копия с временной меткой. +- **Подключение к СКУД (`services/scud_export.py`)**: + - Актуализирован адрес сервера MS SQL Орион (`172.16.200.147\SQL`). + - Добавлена защита инициализации файлового логгера от ошибок доступа прав пользователей. + +--- ## [3.0.0] — 2026-08-21 ### 🚀 Глубокий архитектурный рефакторинг (Unified Clean Architecture) @@ -71,42 +123,28 @@ - Мягкий счетчик отвлечений (`idle_turns` = 3) в свободном диалоге с выводом напоминания и кнопок из `tool_action_registry`. * **Команда `context purge` в CLI (`db_cli.py`)**: Добавлен флаг `--all` для полной очистки таблицы `chat_messages` и сброса стейтов сессий. -### 🔧 Изменено -* **`modules/web_api/llm/agent.py`**: - - Прямой просмотр промпта (`db_get_system_prompt`) переведен в прозрачный режим без блокировки строки ввода и без лишних кнопок. - - В инструмент `db_prompt_node_edit` добавлена передача исходного `baseline_prompt` для точного сопоставления правок в UI. -* **`modules/web_api/llm/core/fast_path.py`**: Оптимизирована зачистка эфемерных сообщений при кликах на кнопки фазы follow-up. - --- ## [2026-08-17] — Внедрение CLI-инспекции контекста и переход на нативный Function Calling ### ✨ Добавлено -* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`[cite: 6]. -* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling[cite: 6]. - -### 🔧 Изменено -* **`modules/web_api/llm/db_tools.py`**: Удалены устаревшие неиспользуемые функции и заглушки счетчиков простоя[cite: 6]. -* **`modules/web_api/static/js/chat/core.js`**: Внедрена поддержка вызова инлайн-редактора черновика промпта прямо из окна диалога[cite: 6]. +* **`scripts/db_cli.py`**: Добавлена команда `context` для инспекции истории сообщений `chat_messages` по сессиям с визуальным отображением флагов `[ЭФЕМЕРНОЕ]`. +* **`ROADMAP.md`**: Сформирована актуальная дорожная карта с планом отказа от регулярных выражений в пользу нативного Function Calling. --- ## [2026-08-15] — Реляционная архитектура системного промпта ### ✨ Добавлено -* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`)[cite: 6]. -* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта[cite: 6]. -* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`[cite: 6]. - -### 🔧 Изменено -* **`modules/web_api/llm/agent.py`**: Генерация активного системного промпта переведена на динамическую сборку из реляционных узлов SQLite[cite: 6]. -* **`modules/web_api/llm/core/fast_path.py`**: Обработка кнопок «Подтвердить» и «Отменить» переведена на атомарную запись узлов в базу данных[cite: 6]. +* **`system_prompt_nodes`**: Создана таблица реляционных узлов промпта с индексацией по разделам и пунктам (`section_id`, `item_id`). +* **`db_prompt_node_edit`**: Добавлен инструмент точечного добавления, изменения и удаления пунктов системного промпта. +* **Инлайн-редактор**: Добавлен визуальный diff-просмотр изменений с подсветкой и кнопкой `[✏️ Редактировать]`. --- ## [2026-08-10] — Механизм эфемерных сообщений и очистки диалога ### ✨ Добавлено -* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков[cite: 6]. -* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций[cite: 6]. -* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия[cite: 6]. \ No newline at end of file +* **Эфемерные сообщения (`is_ephemeral`)**: Маркировка временных сервисных ответов и черновиков. +* **`db_purge_ephemeral_messages`**: Механизм физической зачистки временных записей из SQLite при завершении операций. +* **Интерактивные виджеты**: Поддержка карточек задач (`TASK_INTERACTIVE_CARD`) с кнопками быстрого действия. \ No newline at end of file diff --git a/docs/in_progress/ROADMAP.md b/docs/in_progress/ROADMAP.md index 1ec6ff1..67762c7 100644 --- a/docs/in_progress/ROADMAP.md +++ b/docs/in_progress/ROADMAP.md @@ -1,58 +1,58 @@ # План реализации (ROADMAP) -## 1. Очистка от регулярок и костылей (`tool_injector.py`) -- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4]. -- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 4]. +## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]` +- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 3]. +- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 3]. --- -## 2. Настройка контекста и инструкций сессии (`agent.py`) -- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4]. +## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]` +- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 3]. - [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: - - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4]. - - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4]. -- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4]. -- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 4]. -- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 4]. + - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 3]. + - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 3]. +- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 3]. +- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 3]. +- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 3]. --- -## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) -- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4]. -- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4]. -- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4]. -- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 4]. -- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 4]. +## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]` +- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 3]. +- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 3]. +- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 3]. +- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 3]. +- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 3]. --- -## 4. Инлайн-редактор в окне диалога (`core.js`) -- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4]. -- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4]. -- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 4]. -- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 4]. +## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]` +- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 3]. +- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 3]. +- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 3]. +- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 3]. --- -## 5. Тестирование и валидация системного промпта -- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4]. -- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4]. -- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4]. +## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]` +- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 3]. +- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 3]. +- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 3]. --- -## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) +## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]` - [x] **Масштабирование UI задач:** - - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 4]. - - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 4]. - - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 4]. + - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 3]. + - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 3]. + - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 3]. - [x] **Инлайн-редактирование карточки задачи:** - - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 4]. - - Отображение даты создания задачи[cite: 4]. - - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 4]. + - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 3]. + - Отображение даты создания задачи[cite: 3]. + - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 3]. - [x] **Детерминированный Fast-Path и двухфазное удаление:** - - Мгновенная смена статусов без задержек LLM[cite: 4]. - - Карточка подтверждения удаления с автоочисткой контекста[cite: 4]. + - Мгновенная смена статусов без задержек LLM[cite: 3]. + - Карточка подтверждения удаления с автоочисткой контекста[cite: 3]. - [x] **Генерация отчетов задач в Markdown:** - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3]. - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3]. @@ -61,9 +61,9 @@ --- -## 7. Распространение на модуль снапшотов (`snapshots`) -- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 4]. -- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 4]. +## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]` +- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 3]. +- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 3]. - [x] **Интерактивный UI с чекбоксами и защитой срезов:** - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3]. - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3]. @@ -74,50 +74,75 @@ --- -## 🏗️ Раздел 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]` - -- [x] **Шаг 1. Выделение общего слоя ядра (`core/`)** - - [x] Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов. - - [x] DDL-схемы таблиц и индексов (`core/schema.py`). - - [x] Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`). - - [x] Фасад обратной совместимости (`core/database.py`). -- [x] **Шаг 2. Декомпозиция предметных доменов (`services/`)** - - [x] Сервис задач (`services/tasks/`): CRUD, валидация, markdown-экспорт (`exporter.py`). - - [x] Сервис системного промпта (`services/prompts/`): сборка узлов, вычисление diff (`diff_engine.py`), пакетное удаление (`BATCH_DELETE`). - - [x] Сервис снапшотов СКУД (`services/snapshots/`): безопасное удаление с защитой Y-снапшотов. - - [x] Сервис базы знаний (`services/knowledge/`): правила арбитража и синонимы отделов. -- [x] **Шаг 3. Рефакторинг ETL-конвейера (`services/scud_etl/`)** - - [x] Оркестратор этапов контроллинга (`pipeline.py`). - - [x] Детектор аномалий и конфликтов реестров (`anomaly_detector.py`). - - [x] Агрегация проходов и наложение исключений (`merger.py`). - - [x] Информативное консольное логирование этапов в `main_etl.py`. -- [x] **Шаг 4. Модульный ИИ-оркестратор (`modules/ai_engine/`)** - - [x] Динамический строитель системного контекста (`context_builder.py`). - - [x] Изолированные обработчики инструментов (`handlers/task_handler.py`, `handlers/prompt_handler.py`, `handlers/snapshot_handler.py`). - - [x] Компактный диспетчер диалога (`agent.py`). - - [x] Двухконтурный семантический анализатор намерений (`tool_injector.py`). -- [x] **Шаг 5. Зачистка рудиментов и стабилизация** - - [x] Устранение дубликатов в `web_api/llm/db/`. - - [x] Очистка роутеров от прямых манипуляций с базой данных. - - [x] Исправление сигнатур Fast-Path перехватчиков. +## 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]` +- [x] **Выделение общего слоя ядра (`core/`):** + - Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов. + - DDL-схемы таблиц и индексов (`core/schema.py`). + - Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`). + - Фасад обратной совместимости (`core/database.py`). +- [x] **Декомпозиция предметных доменов (`services/`):** + - Сервис задач (`services/tasks/`), системного промпта (`services/prompts/`), срезов СКУД (`services/snapshots/`), базы знаний (`services/knowledge/`). +- [x] **Рефакторинг ETL-конвейера (`services/scud_etl/`):** + - Оркестратор `pipeline.py`, детектор аномалий `anomaly_detector.py`, слияние `merger.py`. +- [x] **Модульный ИИ-оркестратор (`modules/ai_engine/`):** + - Динамический строитель системного контекста (`context_builder.py`). + - Изолированные обработчики инструментов в `handlers/`. --- -## 📊 Раздел 9. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) `[В ПЛАНАХ]` +## 9. Реляционный реестр исключений, схлопывание мульти-пропусков и автозакрытие 8.5ч `[ЗАВЕРШЕНО]` +- [x] **Реестр исключений в SQLite (`exceptions_registry`):** + - Хранение категорий `departments`, `positions`, `fio`, `position_keywords`, `include_fio` в БД. + - Доменный сервис `exceptions_repo.py` и полная поддержка в CLI `scripts/db_cli.py exceptions`. +- [x] **Агрегация мульти-пропусков физлиц в СКУД:** + - Функция `aggregate_scud_by_person` в `merger.py`: объединение событий всех пропусков одного человека (ранний вход, поздний выход, присутствие). + - Автоматическая фиксация аномалий дублирования пропусков (`DUPLICATE_SCUD_CARD`). +- [x] **Умный выбор ставки совместителей 1С:ЗУП:** + - Функция `select_best_zup_position` в `merger.py`: привязка ставки 1С по подразделению физического нахождения в СКУД. +- [x] **Автозакрытие смен по Правилу 8.5ч:** + - Для забывших отметиться на выходе офисных сотрудников: автоматический расчет `Вход + 8ч 30мин`, норма 8 часов и отклонение `0:00` в `excel_exporter.py`. +- [x] **Кэш сопоставлений личностей (`person_identity_mapping`):** + - Таблица в SQLite и команды `db_cli.py mapping [list|add|del]`. + - Теневой режим ИИ-подсказок по нечетким ФИО в `text_reporter.py`. +- [x] **Каскадный fallback кадровых отсутствий:** + - `MS SQL ЗУП` $\rightarrow$ резервный парсинг `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`. -- [ ] **Интерактивный запуск отчетов из чата** - - [ ] Инструмент `db_generate_report(date_str, report_type)` для генерации сводки или детального отчета по требованию оператора. +--- + +## 10. Разграничение Сводки и Отчета + Почасовые срезы и Y-23:59:59 `[В РАБОТЕ]` +- [ ] **Корректировка финишного среза Y на 23:59:59:** + - [ ] Обновить время вечернего среза с `22:00:00` на `23:59:59` в `services/scud_export.py`, `scud_repo.py` и `pipeline.py`. +- [ ] **Разделение генераторов отчетов:** + - [ ] Создать `services/scud_etl/svodka_generator.py` (оперативная сводка, последний доступный срез или выбранный дневной). + - [ ] Создать `services/scud_etl/otchet_generator.py` (детальный отчет за прошлую смену, строго по единственному итоговому `Y`-снапшоту за 23:59:59). + - [ ] Исключить устаревший `report_generator.py`. +- [ ] **Параметризация `main_etl.py`:** + - [ ] Добавить поддержку аргументов `--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. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) `[В ПЛАНАХ]` +- [ ] **Интерактивный запуск отчетов из чата:** + - [ ] Инструменты `db_generate_svodka(date_str, snapshot_id)` и `db_generate_otchet(date_str)` в Function Calling. - [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`). -- [ ] **Диалоговый аудит расхождений** +- [ ] **Диалоговый аудит расхождений и подтверждение связок:** - [ ] Инструмент точечной выборки: «Кто сегодня не пришел из отдела ОВК?», «Покажи опоздавших за вчера». - - [ ] Быстрое внесение исключений и синонимов подразделений через диалог. + - [ ] Интерактивное подтверждение предложенных ИИ связок ФИО прямо из чата с записью в `person_identity_mapping`. +- [ ] **Веб-интерфейс управления исключениями:** + - [ ] Веб-форма просмотра и редактирования таблицы `exceptions_registry` в боковом меню. --- -## 10. Изолированная песочница кода (Code Execution Sandbox Engine) +## 12. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]` - [ ] **Docker/gVisor контур:** - - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 4]. - - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 4]. + - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 3]. + - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 3]. - [ ] **Динамические Python/Pandas вычисления:** - - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 4]. - - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 4]. \ No newline at end of file + - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 3]. + - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 3]. \ No newline at end of file diff --git a/main_etl.py b/main_etl.py index 3b6e194..1a4bc3b 100644 --- a/main_etl.py +++ b/main_etl.py @@ -1,7 +1,6 @@ """ =============================================================================== FILE: main_etl.py -PROJECT: SCUD Orion AI (Unified Architecture) ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП. =============================================================================== """ @@ -20,7 +19,7 @@ from services.text_reporter import generate_markdown_report from services.scud_export import run_export from services.share_copier import copy_1c_files_from_share -from services.excel_exporter import generate_summary_excel, generate_detailed_excel +from services.excel_exporter import generate_summary_excel, generate_detailed_excel, export_raw_scud logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s") @@ -38,7 +37,7 @@ def main(): now = datetime.now() today_str = now.strftime("%d.%m.%Y") - # ⭐️ По понедельникам (weekday == 0) отчетом накануне является пятница (-3 дня) + if now.weekday() == 0: yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y") else: @@ -47,7 +46,7 @@ def main(): # [Этап 0] Выгрузка свежих данных СКУД if not args.skip_export and not args.snapshot: print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...") - run_export(debug=args.debug) + run_export(debug=args.debug, save_xlsx=True) else: print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...") @@ -66,6 +65,10 @@ def main(): df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str) df_staff_today, df_abs_today = load_1c_files_for_date(today_str) + # Сохранение диагностического дампа сырого СКУД + if df_scud_today is not None and not df_scud_today.empty: + export_raw_scud(df_scud_today, filename=f"СКУД_Сырые_данные_{today_str}.xlsx") + # [Этап 3] Детальный отчет за вчера print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...") df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday) @@ -75,7 +78,7 @@ def main(): print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...") df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today) metrics_today = calculate_summary_metrics(df_merged_today) - anomalies_today = detect_registry_anomalies(df_merged_today) + anomalies_today = detect_registry_anomalies(df_merged_today, df_raw_scud=df_scud_today) generate_summary_excel(df_merged_today, date_str=today_str) # [Этап 5] Формирование Markdown-сводки через ИИ-аудитора @@ -92,12 +95,16 @@ def main(): (df_merged_today.get('is_excluded', False) == False) ] + # ⭐️ Передаем сырые датасеты для выявления опечаток и анализа мульти-записей summary_md = generate_markdown_report( merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False], absent_explained=absent_explained, absent_unexplained=absent_unexplained, scud_present_but_absent_in_1c=pd.DataFrame(), anomalies_list=anomalies_today, + raw_scud_df=df_scud_today, + raw_staff_df=df_staff_today, + raw_absent_df=df_abs_today, date_str=today_str ) diff --git a/scripts/db_cli.py b/scripts/db_cli.py index 9e9a362..82a8444 100644 --- a/scripts/db_cli.py +++ b/scripts/db_cli.py @@ -24,6 +24,44 @@ from services.exceptions_repo import ( DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") +def cmd_mapping(args_list): + """Управление подтвержденными сопоставлениями ФИО (СКУД <-> 1С:ЗУП).""" + with get_connection() as conn: + cursor = conn.cursor() + + if not args_list or args_list[0] in ["list", "show"]: + cursor.execute("SELECT id, scud_fio, zup_fio, match_source, status FROM person_identity_mapping ORDER BY id DESC") + rows = cursor.fetchall() + print("\n🔗 СОХРАНЕННЫЕ СОПОСТАВЛЕНИЯ ФИО (person_identity_mapping):") + print("=" * 80) + if rows: + for r in rows: + print(f" #{r[0]} [{r[4]}] СКУД: '{r[1]}' ⟷ 1С: '{r[2]}' ({r[3]})") + else: + print(" — сопоставлений пока нет") + print("=" * 80 + "\n") + return + + subcmd = args_list[0] + if subcmd == "add": + if len(args_list) < 3: + print("Использование: python scripts/db_cli.py mapping add 'ФИО в СКУД' 'ФИО в 1С'") + return + scud_f, zup_f = args_list[1], args_list[2] + cursor.execute(""" + INSERT OR REPLACE INTO person_identity_mapping (scud_fio, zup_fio, match_source, status) + VALUES (?, ?, 'MANUAL', 'ACTIVE') + """, (scud_f.strip(), zup_f.strip())) + conn.commit() + print(f"✅ Успешно добавлена связка: '{scud_f}' ⟷ '{zup_f}'") + + elif subcmd in ["del", "delete", "remove"]: + if len(args_list) < 2: + print("Использование: python scripts/db_cli.py mapping del 'ФИО в СКУД'") + return + cursor.execute("DELETE FROM person_identity_mapping WHERE scud_fio = ?", (args_list[1].strip(),)) + conn.commit() + print(f"✅ Связка для '{args_list[1]}' удалена.") def print_tool_actions(): """Выводит реестр декларативных действий инструментов и шаблоны кнопок.""" diff --git a/services/excel_exporter.py b/services/excel_exporter.py index dde2b1b..794ba05 100644 --- a/services/excel_exporter.py +++ b/services/excel_exporter.py @@ -3,7 +3,7 @@ import os import time import openpyxl import pandas as pd -from datetime import datetime +from datetime import datetime, timedelta from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.utils import get_column_letter @@ -23,16 +23,18 @@ MONTHS_RU_NOMINATIVE = { def format_date_ru(date_str): + date_clean = str(date_str).replace('_', '.') try: - dt = datetime.strptime(date_str, "%d.%m.%Y") + dt = datetime.strptime(date_clean, "%d.%m.%Y") return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}" except Exception: return date_str def get_dated_reports_dir(date_str): + date_clean = str(date_str).replace('_', '.') try: - dt = datetime.strptime(date_str, "%d.%m.%Y") + dt = datetime.strptime(date_clean, "%d.%m.%Y") year_str = str(dt.year) month_name = MONTHS_RU_NOMINATIVE[dt.month] except Exception: @@ -55,14 +57,30 @@ FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type=" FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", fill_type="solid") FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") -FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") # Мягкий бежево-серый для исключений +FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"] - YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") -LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") + + +def calculate_autoclose_time(time_in_str: str) -> tuple[str, str, str]: + """ + ⭐️ Правило 8.5ч: при наличии входа и отсутствии выхода + рассчитывает время выхода (Вход + 8ч 30мин) с нормой 8:00 и отклонением 0:00. + """ + try: + parts = time_in_str.strip().split(':') + hh = int(parts[0]) + mm = int(parts[1]) if len(parts) > 1 else 0 + ss = int(parts[2]) if len(parts) > 2 else 0 + + dt_in = datetime(2000, 1, 1, hh, mm, ss) + dt_out = dt_in + timedelta(hours=8, minutes=30) + return dt_out.strftime("%H:%M:%S"), "08:30", "0:00" + except Exception: + return "17:00:00", "08:30", "0:00" def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): @@ -119,10 +137,11 @@ def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_f # --- 1. СВОДКА НА СЕГОДНЯ --- def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): + date_clean = str(date_str).replace('_', '.') if not filename: - filename = f"{format_date_ru(date_str)} сводка.xlsx" + filename = f"{format_date_ru(date_clean)} сводка.xlsx" - target_dir = get_dated_reports_dir(date_str) + target_dir = get_dated_reports_dir(date_clean) output_path = os.path.join(target_dir, filename) wb = Workbook() ws = wb.active @@ -135,15 +154,13 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): bold_font = Font(name="Calibri", size=11, bold=True) - # 1. Шапка ws.cell(row=1, column=1, value="Сводка на") - ws.cell(row=1, column=2, value=date_str) + ws.cell(row=1, column=2, value=date_clean) format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font) apply_borders_to_cell(ws.cell(row=2, column=1)) apply_borders_to_cell(ws.cell(row=2, column=2)) - # 2. По списку ws.cell(row=3, column=1, value="По списку") ws.cell(row=3, column=2, value=len(merged_df)) format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font) @@ -153,7 +170,6 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): 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 - # 3. НЕИЗВЕСТНО unexplained = merged_df[ (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & @@ -172,9 +188,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): ws.row_dimensions[current_row].hidden = False current_row += 1 - # 4. РАЗДЕЛ: НЕТ ПРОПУСКА no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() - ws.cell(row=current_row, column=1, value="Нет пропуска") ws.cell(row=current_row, column=2, value=len(no_pass_df)) format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font) @@ -188,7 +202,6 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): ws.row_dimensions[current_row].hidden = False current_row += 1 - # 5. КАТЕГОРИИ ОТСУТСТВИЙ (без удаленки и исключений) reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) @@ -196,7 +209,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & - (~is_remote_reason) & + (~is_remote_reason) & (~is_exc) ] absent_groups = absent_only.groupby('Вид_отсутствия') @@ -217,9 +230,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): ws.row_dimensions[current_row].hidden = True current_row += 1 - # 6. ИТОГО НА РАБОТЕ (Строго физически пришедшие в офис по СКУД) present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] - ws.cell(row=current_row, column=1, value="Итого на работе") ws.cell(row=current_row, column=2, value=len(present)) format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font) @@ -233,9 +244,7 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): ws.row_dimensions[current_row].hidden = True current_row += 1 - # 6.1. В ТОМ ЧИСЛЕ НА УДАЛЕННОЙ РАБОТЕ (Удаленщики из дома, которых нет в офисе) remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] - ws.cell(row=current_row, column=1, value="В том числе на удаленной работе") ws.cell(row=current_row, column=2, value=len(remote_home)) format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font) @@ -249,7 +258,6 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): ws.row_dimensions[current_row].hidden = True current_row += 1 - # 7. АНОМАЛИИ СКУД И 1С anomalies = merged_df[ (~is_exc) & ( ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & @@ -266,7 +274,6 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): current_row += 1 chars_per_line_b = 30 - if not anomalies.empty: for _, row in anomalies.iterrows(): fio = row.get('Сотрудник', '') @@ -281,28 +288,20 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): 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") - if len(reason_text) > chars_per_line_b: - lines_count = math.ceil(len(reason_text) / chars_per_line_b) - ws.row_dimensions[current_row].height = max(lines_count * 18, 22) - else: - ws.row_dimensions[current_row].height = 20 - + 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) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 - # ⭐️ 8. РАЗДЕЛ: ИСКЛЮЧЕНИЯ (В самом низу, раскрывающийся список) exceptions_df = merged_df[is_exc] - ws.cell(row=current_row, column=1, value="Исключения") 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) @@ -322,31 +321,32 @@ def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): try: wb.save(output_path) print(f"[✓] Ежедневная сводка сохранена: {output_path}") - except PermissionError: + 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. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- +# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (С ПРАВИЛОМ 8.5ч) --- def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): + date_clean = str(date_str).replace('_', '.') if not filename: - filename = f"{format_date_ru(date_str)} отчет.xlsx" + filename = f"{format_date_ru(date_clean)} отчет.xlsx" if merged_df is not None and not merged_df.empty: df_export = merged_df[merged_df.get('is_excluded', False) == False].copy() else: df_export = pd.DataFrame() - target_dir = get_dated_reports_dir(date_str) + target_dir = get_dated_reports_dir(date_clean) output_path = os.path.join(target_dir, filename) wb = Workbook() ws = wb.active ws.title = "Детальный_отчет" ws["B2"] = "Дата:" - ws["D2"] = date_str + ws["D2"] = date_clean ws["B2"].font = Font(name="Arial", size=10, bold=True) ws["D2"].font = Font(name="Arial", size=10, bold=True) @@ -375,22 +375,28 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): is_present = row.get('Пришел', False) absence_reason = row.get('Вид_отсутствия', '') has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' - anom_flag = row.get('anomaly_flag', 'NONE') + in_val = str(row.get(start_col, 'Нет входа')).strip() + out_val = str(row.get(end_col, 'Нет выхода')).strip() in_building_str = str(row.get(hours_col, '00:00')) first_act_val = str(row.get('Первая_активность', '—')).strip() 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: + out_val, in_building_str, deviation_val = calculate_autoclose_time(in_val) + else: + deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) + dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) - deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) ws.append([ idx + 1, row.get('Сотрудник', ''), dept_scud_val, - row.get(start_col, 'Нет входа'), + in_val, first_act_val, - row.get(end_col, 'Нет выхода'), + out_val, in_building_str, absence_reason if has_reason else '', 8, @@ -398,7 +404,6 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): ]) row_num = 5 + idx - if is_present and has_reason: row_fill = GREEN_FILL elif not is_present and has_reason: @@ -409,18 +414,14 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): row_fill = None val_h_str = str(absence_reason) if has_reason else "" - if len(val_h_str) > chars_per_line_h: - needed_lines = math.ceil(len(val_h_str) / chars_per_line_h) - ws.row_dimensions[row_num].height = max(needed_lines * 18, 22) - else: - ws.row_dimensions[row_num].height = 20 + 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) for col_idx in range(1, len(headers) + 1): cell = ws.cell(row=row_num, column=col_idx) apply_borders_to_cell(cell) if row_fill: cell.fill = row_fill - if col_idx == 8: cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) elif col_idx in [1, 4, 5, 6, 7, 9, 10]: @@ -430,23 +431,13 @@ def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): for col in ws.columns: col_letter = get_column_letter(col[0].column) - max_len = 0 - for cell in col: - if cell.value is not None: - cell_lines = str(cell.value).split("\n") - line_max = max(len(line) for line in cell_lines) - if line_max > max_len: - max_len = line_max - - optimal_width = max(max_len + 2, 8) - if optimal_width > 35: - optimal_width = 35 - ws.column_dimensions[col_letter].width = optimal_width + max_len = max((len(str(cell.value or '')) for cell in col), default=8) + ws.column_dimensions[col_letter].width = min(max(max_len + 2, 8), 35) try: wb.save(output_path) print(f"[✓] Детальный отчет сохранен: {output_path}") - except PermissionError: + 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) diff --git a/services/scud_etl/anomaly_detector.py b/services/scud_etl/anomaly_detector.py index b553e0d..ed6d922 100644 --- a/services/scud_etl/anomaly_detector.py +++ b/services/scud_etl/anomaly_detector.py @@ -1,32 +1,44 @@ """ =============================================================================== FILE: services/scud_etl/anomaly_detector.py -PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: services / scud_etl -ROLE: Детектирование истинных аномалий и конфликтов реестров. - (Сотрудники из exceptions.json, удаленка и командировки исключены). +ROLE: Детектирование аномалий СКУД, дубликатов пропусков и несоответствий с 1С. =============================================================================== """ import logging from typing import List, Dict, Any import pandas as pd +from config import normalize_fio logger = logging.getLogger("SCUD_ANOMALY") -def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]: - """ - Выявляет реальные аномалии: - - Приход в офис во время отпуска или больничного листа. - - Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа). - """ +def detect_registry_anomalies(df_merged: pd.DataFrame, df_raw_scud: pd.DataFrame = None) -> List[Dict[str, Any]]: anomalies = [] if df_merged is None or df_merged.empty: return anomalies + # 1. ⭐️ Поиск дубликатов учеток/пропусков в сыром СКУД + if df_raw_scud is not None and not df_raw_scud.empty: + df_raw = df_raw_scud.copy() + if 'fio_clean' not in df_raw.columns: + f_col = 'Сотрудник' if 'Сотрудник' in df_raw.columns else 'ФИО' + df_raw['fio_clean'] = df_raw[f_col].apply(normalize_fio) + + counts = df_raw['fio_clean'].value_counts() + duplicate_fios = counts[counts > 1].index.tolist() + + for dup_fio in duplicate_fios: + sub = df_raw[df_raw['fio_clean'] == dup_fio] + departments = ", ".join(sub['Подразделение'].astype(str).unique()) + anomalies.append({ + "fio": dup_fio, + "type": "DUPLICATE_SCUD_CARD", + "description": f"Сотрудник заведен в СКУД {len(sub)} раза (отделы: {departments}). События входа и выхода объединены автоматически." + }) + + # 2. Поиск кадровых аномалий и сбоев оборудования for _, row in df_merged.iterrows(): - # Пропускаем системные исключения if row.get("is_excluded", False): continue @@ -36,25 +48,24 @@ def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]: reason = str(row.get("причина отсутствия", row.get("Вид_отсутствия", ""))).strip() reason_lower = reason.lower() - # Пропускаем технические метки if "исключен" in reason_lower or "овк" in reason_lower: continue - # ⭐️ Физический приход в офис: удаленка и командировки разрешены и НЕ являются аномалией + # Приход в офис во время отпуска/больничного (удаленка и командировки разрешены) if start_day not in ["Нет входа", "—", "", "nan", "None"] and reason and reason != "nan": if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower): anomalies.append({ "fio": fio, "type": "PHYSICAL_PRESENCE_DURING_ABSENCE", - "description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'." + "description": f"Присутствовал в здании ({start_day}), но в 1С числится документ: '{reason}'." }) - # Аномалия оборудования (есть выход без утреннего входа) + # Ошибка считывателя (есть выход без входа) if start_day in ["Нет входа", "—", ""] and end_day not in ["Нет выхода", "—", "", "nan", "None"]: anomalies.append({ "fio": fio, "type": "SCUD_EQUIPMENT_ANOMALY", - "description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})." + "description": f"Зафиксирован выход ({end_day}) при отсутствии отметки утреннего входа." }) return anomalies \ No newline at end of file diff --git a/services/scud_etl/merger.py b/services/scud_etl/merger.py index 1954109..fb40402 100644 --- a/services/scud_etl/merger.py +++ b/services/scud_etl/merger.py @@ -1,19 +1,141 @@ """ =============================================================================== FILE: services/scud_etl/merger.py +ROLE: Агрегация реестров, выбор совместителей 1С по отделу СКУД и авто-связки. =============================================================================== """ import logging -from typing import Dict, Any +from typing import Dict, Any, List import pandas as pd from services.knowledge.service import get_department_synonyms_dict from config import normalize_fio, load_exceptions +from core.connection import get_connection logger = logging.getLogger("SCUD_MERGER") +def load_identity_mappings() -> Dict[str, str]: + """Загружает подтвержденные сопоставления ФИО (СКУД -> 1С:ЗУП) из SQLite.""" + with get_connection() as conn: + cursor = conn.cursor() + # Автоматическая инициализация таблицы при первом обращении + cursor.execute(""" + CREATE TABLE IF NOT EXISTS person_identity_mapping ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scud_fio TEXT NOT NULL, + zup_fio TEXT NOT NULL, + scud_dept TEXT, + zup_dept TEXT, + match_source TEXT DEFAULT 'AI', + status TEXT DEFAULT 'ACTIVE', + confidence REAL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(scud_fio, zup_fio) + ); + """) + cursor.execute(""" + SELECT scud_fio, zup_fio + FROM person_identity_mapping + WHERE status = 'ACTIVE' + """) + return {r[0]: r[1] for r in cursor.fetchall()} + + +def aggregate_scud_by_person(df_scud: pd.DataFrame) -> pd.DataFrame: + """Схлопывает дубликаты пропусков одного человека в СКУД.""" + if df_scud is None or df_scud.empty: + return df_scud + + df = df_scud.copy() + if 'fio_clean' not in df.columns: + fio_col = 'Сотрудник' if 'Сотрудник' in df.columns else 'ФИО' + df['fio_clean'] = df[fio_col].apply(normalize_fio) + + # Применяем сохраненный кэш связок ФИО + mapping_dict = load_identity_mappings() + if mapping_dict: + df['fio_clean'] = df['fio_clean'].apply(lambda f: mapping_dict.get(f, f)) + + aggregated_rows = [] + for fio_clean, group in df.groupby('fio_clean'): + if len(group) == 1: + aggregated_rows.append(group.iloc[0].to_dict()) + continue + + base_row = group.sort_values(by='Пришел', ascending=False).iloc[0].to_dict() + + valid_ins = [ + str(t).strip() for t in group['Начало_дня'] + if str(t).strip() not in ['Нет входа', '—', '', 'nan', 'None', '00:00:00', '00:00'] + ] + base_row['Начало_дня'] = min(valid_ins) if valid_ins else 'Нет входа' + + valid_outs = [ + str(t).strip() for t in group['Конец_дня'] + if str(t).strip() not in ['Нет выхода', '—', '', 'nan', 'None', '00:00:00', '00:00'] + ] + base_row['Конец_дня'] = max(valid_outs) if valid_outs else 'Нет выхода' + + valid_acts = [ + str(t).strip() for t in group['Первая_активность'] + if str(t).strip() not in ['—', '', 'nan', 'None', '00:00:00'] + ] + base_row['Первая_активность'] = min(valid_acts) if valid_acts else '—' + base_row['Пришел'] = any(group['Пришел'] == True) or (base_row['Начало_дня'] != 'Нет входа') + + durations = [str(d) for d in group['Находился_в_здании'] if str(d) not in ['00:00', '', 'nan']] + if durations: + base_row['Находился_в_здании'] = max(durations) + + aggregated_rows.append(base_row) + + return pd.DataFrame(aggregated_rows) + + +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: + return pd.DataFrame() + + df_staff = df_staff_1c.copy() + if 'fio_clean' not in df_staff.columns: + f_col = 'ФИО' if 'ФИО' in df_staff.columns else 'Сотрудник' + df_staff['fio_clean'] = df_staff[f_col].apply(normalize_fio) + + scud_dept_map = {} + if df_scud_agg is not None and not df_scud_agg.empty: + for _, r in df_scud_agg.iterrows(): + scud_dept_map[r.get('fio_clean', '')] = str(r.get('Подразделение', '')).strip().upper() + + best_rows = [] + for fio, group in df_staff.groupby('fio_clean'): + if len(group) == 1: + best_rows.append(group.iloc[0].to_dict()) + continue + + target_scud_dept = scud_dept_map.get(fio, "") + matched_row = None + + if target_scud_dept: + for _, r in group.iterrows(): + dept_1c = str(r.get('Подразделение', '')).strip().upper() + if dept_1c == target_scud_dept or target_scud_dept in dept_1c or dept_1c in target_scud_dept: + matched_row = r.to_dict() + break + + if not matched_row: + matched_row = group.iloc[0].to_dict() + + best_rows.append(matched_row) + + return pd.DataFrame(best_rows) + + def merge_scud_and_1c( df_scud: pd.DataFrame, df_staff_1c: pd.DataFrame, @@ -29,7 +151,10 @@ def merge_scud_and_1c( synonyms = get_department_synonyms_dict() exceptions_cfg = load_exceptions() - df_res = df_scud.copy() if df_scud is not None and not df_scud.empty else df_staff_1c.copy() + df_scud_agg = aggregate_scud_by_person(df_scud) + df_staff_agg = select_best_zup_position(df_staff_1c, df_scud_agg) + + df_res = df_scud_agg.copy() if df_scud_agg is not None and not df_scud_agg.empty else df_staff_agg.copy() if "Сотрудник" in df_res.columns: df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio) @@ -50,7 +175,6 @@ def merge_scud_and_1c( if col not in df_res.columns: df_res[col] = default_val - # 1. Приведение отделов к аббревиатурам reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()} direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()} all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"} @@ -60,9 +184,8 @@ def merge_scud_and_1c( lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip()) ) - # 2. Привязка документов отсутствий из 1С ПЕРЕД разметкой исключений absences_map = {} - if not df_absences_1c.empty: + if df_absences_1c is not None and not df_absences_1c.empty: fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None) reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None) @@ -76,7 +199,6 @@ def merge_scud_and_1c( df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map) df_res["Вид_отсутствия"] = df_res["причина отсутствия"] - # 3. Разметка исключений exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f] exc_depts = [d.strip().upper() for d in exceptions_cfg.get("departments", []) if d] exc_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] @@ -86,9 +208,8 @@ def merge_scud_and_1c( df_res["is_excluded"] = False for idx, row in df_res.iterrows(): fio = row.get("fio_clean", "") - has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() != "" + 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: df_res.at[idx, "is_excluded"] = False continue @@ -99,7 +220,6 @@ def merge_scud_and_1c( 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): df_res.at[idx, "is_excluded"] = True - # Чистое "Исключение" только для истинно исключенных без документов 1С mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == "")) df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение" df_res.loc[mask_exc, "причина отсутствия"] = "Исключение" @@ -111,21 +231,17 @@ 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(active_df) - # 1. Строго физически пришедшие в офис came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа") working_in_office = active_df[came_to_office_mask] working_in_office_count = len(working_in_office) - # 2. Не пришедшие в офис df_not_came = active_df[~came_to_office_mask] - # 3. Из не пришедших — удаленщики (работают из дома) reason_series = df_not_came["причина отсутствия"].astype(str).str.lower() is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False) remote_home = df_not_came[is_remote_mask] remote_home_count = len(remote_home) - # 4. Из оставшихся — официальные отсутствия (отпуск, больничный, командировка) df_remaining_absent = df_not_came[~is_remote_mask] has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \ df_remaining_absent["причина отсутствия"].ne("") & \ @@ -133,7 +249,6 @@ def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: (~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение")) official_absent_count = len(df_remaining_absent[has_doc_mask]) - # 5. Неизвестные unknown = df_remaining_absent[~has_doc_mask] unknown_count = len(unknown) diff --git a/services/text_reporter.py b/services/text_reporter.py index 29ffcaf..1fa999a 100644 --- a/services/text_reporter.py +++ b/services/text_reporter.py @@ -5,44 +5,36 @@ from services.ai_verifier import ask_ollama from services.knowledge_base import load_knowledge_base -def find_python_fuzzy_matches(unexplained_df, raw_absent_df): - if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty: +def find_ai_identity_suggestions(unexplained_df, raw_staff_df): + """ + ⭐️ Теневой ИИ-арбитраж: ищет потенциальные опечатки операторов СКУД + среди нераспознанных сотрудников и формирует рекомендации для администратора. + """ + if unexplained_df.empty or raw_staff_df is None or raw_staff_df.empty: return [] - absent_dict = {} - for idx, r in raw_absent_df.iterrows(): - raw_fio = str(r.get('ФИО', '')) - clean_fio = str(r.get('fio_clean', '')) - reason = str(r.get('Вид_отсутствия', '')) - if clean_fio and clean_fio not in ['Ао "Ленморниипроект"', 'Сотрудник', 'Nan', 'None']: - absent_dict[clean_fio] = {'raw_fio': raw_fio, 'reason': reason} + staff_fios = raw_staff_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in raw_staff_df.columns else [] + unexp_fios = unexplained_df['fio_clean'].dropna().unique().tolist() if 'fio_clean' in unexplained_df.columns else [] - matches = [] - unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else [] - - for fio in unexplained_fios: - fio_parts = fio.split() - if not fio_parts: + suggestions = [] + for scud_fio in unexp_fios: + f_parts = scud_fio.split() + if not f_parts: continue - surname = fio_parts[0].lower() + surname = f_parts[0].lower() - for abs_clean, abs_info in absent_dict.items(): - abs_parts = abs_clean.split() - if not abs_parts: - continue - abs_surname = abs_parts[0].lower() - - if surname == abs_surname: - ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio() - if ratio >= 0.75: - matches.append({ - "target_fio": fio, - "found_in_1c_raw": abs_info['raw_fio'], - "reason_1c": abs_info['reason'] + for staff_fio in staff_fios: + if staff_fio.lower().startswith(surname[:4]): + ratio = difflib.SequenceMatcher(None, scud_fio.lower(), staff_fio.lower()).ratio() + if 0.75 <= ratio < 1.0: + suggestions.append({ + "scud_fio": scud_fio, + "suggested_zup_fio": staff_fio, + "confidence": round(ratio, 2) }) break - return matches + return suggestions def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="05.08.2026"): @@ -51,22 +43,18 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет." total_staff = len(merged_df) - - # 1. Пришедшие в офис present_office_cnt = len(merged_df[merged_df['Пришел'] == True]) - # 2. Не пришедшие удаленщики reason_series = merged_df['Вид_отсутствия'].astype(str).str.lower() is_remote_reason = reason_series.str.contains('удален|дистанцион', regex=True, na=False) remote_home_cnt = len(merged_df[(merged_df['Пришел'] == False) & is_remote_reason]) - # 3. Официально отсутствующие (без удаленки) explained_cnt = len(merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~is_remote_reason) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))]) - unexplained_cnt = len(absent_unexplained) anomalies_cnt = len(anomalies_list) if anomalies_list else 0 - fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df) + # ⭐️ Теневые подсказки ИИ + ai_suggestions = find_ai_identity_suggestions(absent_unexplained, raw_staff_df) anomalies_formatted = [] if anomalies_list: @@ -106,18 +94,18 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc {anomalies_text_block} --- -⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт): -{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)} +💡 ПРЕДЛОЖЕНИЯ ИИ ПО СОПОСТАВЛЕНИЮ ФИО ({len(ai_suggestions)} шт): +{json.dumps(ai_suggestions, ensure_ascii=False, indent=2)} --- 📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел): {json.dumps(unexplained_list, ensure_ascii=False, indent=2)} -СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ: -1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ". Если аномалий нет, напиши: "Аномалий не обнаружено." -2. В разделе "Подозрения на ошибки сопоставления ФИО" используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если он пуст, напиши: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено." -3. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше. -4. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы без перечисления конкретных ФИО. +СТРОГИЕ ИНСТРУКЦИИ: +1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ". +2. В разделе "Подозрения на ошибки сопоставления ФИО" выведи рекомендации по сопоставлению из массива "ПРЕДЛОЖЕНИЯ ИИ". Если массив пуст — напиши "Ошибок сопоставления ФИО не обнаружено." +3. В разделе "Неизвестные случаи" выведи нумерованный список всех {unexplained_cnt} человек. +4. В разделе "Рекомендации" дай 2-3 системные рекомендации. СТРОГИЙ ШАБЛОН ОТВЕТА: @@ -131,17 +119,17 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc - Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. #### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): -(Выведи нумерованный текстовый список аномалий или 'Аномалий не обнаружено.') +(Список аномалий или 'Аномалий не обнаружено.') -#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С: -(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива ошибок ФИО. Если пуст — 'Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.') +#### 💡 Подозрения на ошибки сопоставления ФИО и предложения ИИ: +(Список предложений сопоставления или 'Ошибок сопоставления ФИО не обнаружено.') #### Неизвестные случаи: {unexplained_cnt} -(Выведи нумерованный список всех {unexplained_cnt} человек) +(Нумерованный список всех {unexplained_cnt} человек) ### Точечные рекомендации: (2-3 системные рекомендации) """ - sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке обычным форматированным текстом. Категорически запрещено выводить фигурные скобки JSON или технический код." + sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке форматированным текстом. Запрещено выводить фигурные скобки JSON." return ask_ollama(prompt, system_prompt=sys_prompt) \ No newline at end of file