From fa386b3b79b6240b3061f0e0409d7a4d8c6d82b7 Mon Sep 17 00:00:00 2001 From: manoraga Date: Thu, 27 Aug 2026 22:43:45 +0300 Subject: [PATCH] docs: update CHANGELOG and ROADMAP with section 10 (svodka/otchet split, Y-23:59:59) --- core/schema.py | 46 ++++--- data/scud_orion_ai.db | Bin 9269248 -> 9379840 bytes docs/CHANGELOG.md | 84 +++++++++---- docs/in_progress/ROADMAP.md | 171 +++++++++++++++----------- main_etl.py | 17 ++- scripts/db_cli.py | 38 ++++++ services/excel_exporter.py | 113 ++++++++--------- services/scud_etl/anomaly_detector.py | 43 ++++--- services/scud_etl/merger.py | 143 ++++++++++++++++++--- services/text_reporter.py | 84 ++++++------- 10 files changed, 483 insertions(+), 256 deletions(-) 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 af9d01ea099cdcffe86fd921c27c9e589fbc6545..a4511ea93d3170aae914b60b66fe19f9f802bb53 100644 GIT binary patch delta 49444 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 delta 20055 zcmZX62UrwW_x{{FWoP?>sJN6}D~Ob(cUX|3*Z@1I0qh3NqOl{cL`4zIl8DC?4aAaS zOwcGd#h687jEN@rrI^G-G40EjXoBhGOY%Q6OUm#2@AC}9-krHKbIUpJdCz;t?L8^D zdrkh z<>&K5=v8_@6x-K_LWE=H2$<7B{3&%XR|ol;fU`|VhqiJ}PQ#UBgk3pCM@GrBITq^cku1(ft-u42u5GTF!cghBUH<@R&gh*_GeXM{rTUmQfY%^n6>u2MVHbrkAgnq3Zs zRd#s@;o(+^J{|0S;Uc?imt!cjbgYd6XE8NH_Zh1QPdMacRyS1MYLGIyM>z66SS(@1 zQ09$@U?C9bF58apa1>zNWBuW93NG#!}A9$@?|w8k2NgIxaOxWm1&H ziEoOB#I@ph(I%WVA2P2sk2jq)?J>V8d?W19v}zW~!J4n-Rq|NPY29jFkuF5rt39LL ztzB%o-E1>`Biv5zB!$Gv-Q>KcRFlT|ma*Nq%$RPJ4R0GBHr!#zFlhDf==bYg`jMG> zo$j*kfHq0X%iThXAoBm>AL6U|R9>QQ(l)x3rcn)fi?p}o1c29~9Sm)Qv^qE+sO^O7 z7HuqBt8xhiZX9%dUVZxa5>a!5dDw4Auo} zXTqK!JkYNEcQ!~{#&ex(z%M|1pIQ~0W7S%S?*dl>v|T*JTCwi35N#Q}GYJ1257BO; z-(T%4mWfwy+{%sP4s(X*3{yaMtcimR$C{;ZGhpwL2R2)2%e%K5uDq#(QIg=_PWStkRH%@ zw8J6Vqn%=+{mBPpKUqRzxu3Wf;W}tz;YE-3L+JKs^OUisfs`g~8XJ6%_G2TT#6Jg` zHf$!@PI4(fm91*i>Um{ar9$zhC=s^q*T%71`?dXM3EAXD4xC?fi#gz`=@A2fE|P>8hazE>_=0L8~w<*-A8b#p=mCwGi%wWM1m?wI+! z;Rr-Hbrm#;XN^wX_d52kiNSU+CpU>}Xw+2S>5Ig;rbj#=8)ZzNzB2Ho-v z*_~G`DeT0<>&F>8JvzZ6K@*2)4kl;p%Wa!XzmsV6<*sAaJXy%kkzA;VN8#1EI9?99^D9^rNKu{Wf9kP zmqUZ_p2l)dadM$-plRd}@)FrjCd&1u2Ge9%;w-m9oM*$ld;Kf=`}%9aLmQ&mN1hEW zgbw@yDj(Vq&mO&J!#okL^ZFE+X4bD}&+pk#plKi$J#GfvjEq=Jyj{-bIyw4*`8+r) z21Y}g!(@eN4pRlp>6)xz&pS-zl&pm=k9MW6-LS;Ly5dcvBeAUysXdm0r_nm$`dP zDSbVTr|7|O(-h4jKQg^Lo6jXRpmCbZVV=_*LFrW3=`=t7*C5h>JJ;-BkLH+XWeHiN z11+38)()ZWY#BStEVDGXIY+HI+1FO?P3Gu^k2jgw$D1sJC;B_N3!L^n?fu$vZ7_Gi z+@*g70=q58;q^0C4X8svd&%OjB^}BGJwg64G{g@qzx9*8;&QkX99Vk80+y0d`0qF5 zAgShy`GM$K4$^8GPku2r8Yde43~w30P-Y0x_n;kGpdYEbp?gZVT$iZ*N&CFEPMf0@ z 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 E2k@WrzyJUM 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