diff --git a/config.py b/config.py index 56935a0..f5fd21d 100644 --- a/config.py +++ b/config.py @@ -1,78 +1,64 @@ import os -import re -import json -import pandas as pd from datetime import datetime, timedelta -# 📁 Пути к директориям BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, "data") + +# 📂 ПОДПАПКИ DАТА +SCUD_DIR = os.path.join(DATA_DIR, "scud") +ZUP_1C_DIR = os.path.join(DATA_DIR, "1c") OUTPUT_DIR = os.path.join(BASE_DIR, "output") -os.makedirs(DATA_DIR, exist_ok=True) -os.makedirs(OUTPUT_DIR, exist_ok=True) +# Создаем директории при их отсутствии +for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]: + os.makedirs(folder, exist_ok=True) -# 📅 Динамический расчет дат (СЕГОДНЯ и ВЧЕРА) -def get_controlling_dates(): - now = datetime.now() - if now.weekday() == 0: # Если Понедельник -> Вчера = Пятница - yesterday_dt = now - timedelta(days=3) - else: - yesterday_dt = now - timedelta(days=1) - - date_today_str = now.strftime("%d.%m.%Y") - date_yesterday_str = yesterday_dt.strftime("%d.%m.%Y") - return date_today_str, date_yesterday_str +# 📅 Даты +NOW = datetime.now() +DATE_TODAY = NOW.strftime("%d.%m.%Y") -DATE_TODAY, DATE_YESTERDAY = get_controlling_dates() +if NOW.weekday() == 0: # Понедельник -> Вчера = Пятница + DATE_YESTERDAY = (NOW - timedelta(days=3)).strftime("%d.%m.%Y") +else: + DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y") # 📄 Пути к файлам -SCUD_FILE_TODAY = os.path.join(DATA_DIR, f"Сотрудники_{DATE_TODAY}.xlsx") -SCUD_FILE_YESTERDAY = os.path.join(DATA_DIR, f"Сотрудники_{DATE_YESTERDAY}.xlsx") -SCUD_FILE = SCUD_FILE_TODAY if os.path.exists(SCUD_FILE_TODAY) else SCUD_FILE_YESTERDAY +SCUD_FILE_TODAY = os.path.join(SCUD_DIR, f"Сотрудники_{DATE_TODAY}.xlsx") +SCUD_FILE_YESTERDAY = os.path.join(SCUD_DIR, f"Сотрудники_{DATE_YESTERDAY}.xlsx") +SCUD_FILE = os.path.join(SCUD_DIR, "Сотрудники.xlsx") -STAFF_FILE = os.path.join(DATA_DIR, "Штатные сотрудники - ОУП.xlsx") -ABSENT_FILE = os.path.join(DATA_DIR, "Отсутствия сотрудников - выгрузка.xlsx") -EXCEPTIONS_FILE = os.path.join(BASE_DIR, "exceptions.json") +STAFF_FILE_1C = os.path.join(ZUP_1C_DIR, "Штатные сотрудники - ОУП.xlsx") +ABSENT_FILE_1C = os.path.join(ZUP_1C_DIR, "Отсутствия сотрудников - выгрузка.xlsx") -# 🤖 Настройки Ollama -OLLAMA_URL = "http://10.121.18.227:11434/api/chat" +# Настройки Ollama / RAG (объявляем оба имени для совместимости) +OLLAMA_URL = "http://10.121.18.227:11434/api/generate" OLLAMA_MODEL = "qwen2.5:14b" +MODEL_NAME = OLLAMA_MODEL + +KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json") +EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json") -# 🛠 Очистка и нормализация данных - -def normalize_fio(val): - """Строгая нормализация ФИО для точности сопоставления 1С и СКУД""" - if pd.isna(val): +def normalize_fio(fio): + if not fio or not isinstance(fio, str): return "" - val = str(val).strip().lower() - val = re.sub(r'\(.*?\)', '', val) - trans = str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ') - val = val.translate(trans).replace('ё', 'е') - return re.sub(r'\s+', ' ', val).strip() + parts = fio.strip().split() + return " ".join(parts).title() -def clean_scud_fio_light(val): - """Легкая очистка ФИО для СКУД""" - if pd.isna(val): +def clean_scud_fio_light(fio_str): + if not isinstance(fio_str, str): return "" - val = str(val).strip().lower() - val = re.sub(r'\(.*?\)', '', val).replace('ё', 'е') - return re.sub(r'\s+', ' ', val).strip() + fio_clean = fio_str.replace("(сводная)", "").strip() + return normalize_fio(fio_clean) -def load_exceptions(file_path=EXCEPTIONS_FILE): - """Загрузка списка исключений из JSON""" - if not os.path.exists(file_path): - return {"departments": [], "positions": [], "fio": [], "position_keywords": []} - with open(file_path, "r", encoding="utf-8") as f: - data = json.load(f) - return { - "departments": [str(d).strip().upper() for d in data.get("departments", [])], - "positions": [str(p).strip().lower() for p in data.get("positions", [])], - "fio": [normalize_fio(f) for f in data.get("fio", [])], - "position_keywords": [str(k).strip().lower() for k in data.get("position_keywords", [])] - } - -# тестовый комментарий \ No newline at end of file +def load_exceptions(): + import json + if os.path.exists(EXCEPTIONS_PATH): + try: + with open(EXCEPTIONS_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {"fio": [], "departments": [], "positions": [], "position_keywords": []} \ No newline at end of file diff --git a/data/1c/Отсутствия сотрудников - выгрузка.xlsx b/data/1c/Отсутствия сотрудников - выгрузка.xlsx new file mode 100644 index 0000000..a4ef5d0 Binary files /dev/null and b/data/1c/Отсутствия сотрудников - выгрузка.xlsx differ diff --git a/data/1c/Штатные сотрудники - ОУП.xlsx b/data/1c/Штатные сотрудники - ОУП.xlsx new file mode 100644 index 0000000..4994621 Binary files /dev/null and b/data/1c/Штатные сотрудники - ОУП.xlsx differ diff --git a/data/knowledge_base.json b/data/knowledge_base.json index c4fa635..6403666 100644 --- a/data/knowledge_base.json +++ b/data/knowledge_base.json @@ -1,7 +1,8 @@ { "rules": [ "Кондрашова Елена Владимировна имеет 2 пропуска в СКУД. Если один 'Отсутствовал', а второй с временем входа — считать ПРИШЕДШЕЙ.", - "Сотрудники подразделения ОА с должностью 'Инженер-программист' часто являются внутренними совместителями." + "Сотрудники подразделения ОА с должностью 'Инженер-программист' часто являются внутренними совместителями.", + "Сотрудник Незнанова Валерия Игоревна: Незнанова иногда присутствует в офисе, не считать аномалией. Когда в офисе, из удаленщиков в отчете исключать!" ], "fio_corrections": { "кондрашава елена владимировна": "Кондрашова Елена Владимировна" diff --git a/data/scud/Сотрудники_27.07.2026.xlsx b/data/scud/Сотрудники_27.07.2026.xlsx new file mode 100644 index 0000000..403a440 Binary files /dev/null and b/data/scud/Сотрудники_27.07.2026.xlsx differ diff --git a/data/scud/Сотрудники_28.07.2026.xlsx b/data/scud/Сотрудники_28.07.2026.xlsx new file mode 100644 index 0000000..7680998 Binary files /dev/null and b/data/scud/Сотрудники_28.07.2026.xlsx differ diff --git a/logs/export_28.07.2026.log b/logs/export_28.07.2026.log new file mode 100644 index 0000000..de17c64 --- /dev/null +++ b/logs/export_28.07.2026.log @@ -0,0 +1,27 @@ +[2026-07-28 15:35:20] [INFO] === [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион === +[2026-07-28 15:35:20] [INFO] --- Обработка периода: Вчера (27.07.2026) --- +[2026-07-28 15:35:45] [INFO] База вернула 272 строк за 27.07.2026 +[2026-07-28 15:35:45] [INFO] [SUCCESS] [✓] Успешно экспортирован файл: data/scud/Сотрудники_27.07.2026.xlsx +[2026-07-28 15:35:45] [INFO] --- Обработка периода: Сегодня (28.07.2026) --- +[2026-07-28 15:36:07] [INFO] База вернула 272 строк за 28.07.2026 +[2026-07-28 15:36:07] [INFO] [SUCCESS] [✓] Успешно экспортирован файл: data/scud/Сотрудники_28.07.2026.xlsx +[2026-07-28 15:36:07] [INFO] === Выгрузка СКУД завершена === + +[2026-07-28 15:38:34] [INFO] === [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион === +[2026-07-28 15:38:34] [INFO] --- Обработка периода: Вчера (27.07.2026) --- +[2026-07-28 15:38:54] [INFO] База вернула 272 строк за 27.07.2026 +[2026-07-28 15:38:54] [INFO] [SUCCESS] [✓] Успешно экспортирован файл: data/scud/Сотрудники_27.07.2026.xlsx +[2026-07-28 15:38:54] [INFO] --- Обработка периода: Сегодня (28.07.2026) --- +[2026-07-28 15:39:21] [INFO] База вернула 272 строк за 28.07.2026 +[2026-07-28 15:39:21] [INFO] [SUCCESS] [✓] Успешно экспортирован файл: data/scud/Сотрудники_28.07.2026.xlsx +[2026-07-28 15:39:21] [INFO] === Выгрузка СКУД завершена === + +[2026-07-28 15:41:14] [INFO] === [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион === +[2026-07-28 15:41:14] [INFO] --- Обработка периода: Вчера (27.07.2026) --- +[2026-07-28 15:41:33] [INFO] База вернула 272 строк за 27.07.2026 +[2026-07-28 15:41:33] [INFO] [SUCCESS] [✓] Успешно экспортирован файл: data/scud/Сотрудники_27.07.2026.xlsx +[2026-07-28 15:41:33] [INFO] --- Обработка периода: Сегодня (28.07.2026) --- +[2026-07-28 15:41:58] [INFO] База вернула 272 строк за 28.07.2026 +[2026-07-28 15:41:58] [INFO] [SUCCESS] [✓] Успешно экспортирован файл: data/scud/Сотрудники_28.07.2026.xlsx +[2026-07-28 15:41:58] [INFO] === Выгрузка СКУД завершена === + diff --git a/main.py b/main.py index 0c1c9e9..2104bdb 100644 --- a/main.py +++ b/main.py @@ -6,20 +6,17 @@ import pandas as pd sys.path.append(os.path.dirname(os.path.abspath(__file__))) from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio +from modules.scud_export import run_export from modules.data_validator import check_file_freshness from modules.data_loader import load_all_data from modules.excel_exporter import generate_summary_excel, generate_detailed_excel from modules.ai_verifier import ai_verify_scud_against_staff from modules.text_reporter import generate_markdown_report from modules.feedback_loop import review_ai_decisions +from modules.knowledge_base import load_knowledge_base def load_static_reason_workers(): - """ - Загружает реестр сотрудников со статическими/особыми причинами - из static_reason_workers.csv. - Возвращает словарь: {fio_clean: reason} - """ static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") if not os.path.exists(static_path): return {} @@ -33,12 +30,60 @@ def load_static_reason_workers(): return {} +def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules): + """ + Автоматически находит логические конфликты между СКУД, 1С, статической удалёнкой и исключениями + с учётом динамических правил из базы знаний (knowledge_base.json). + """ + anomalies = [] + kb_rules_text = " ".join(kb_rules).lower() if kb_rules else "" + + for idx, row in merged_df.iterrows(): + fio = row.get('Сотрудник', row.get('fio_clean', '')) + fio_clean = row.get('fio_clean', '') + is_present = row.get('Пришел', False) + reason_1c = str(row.get('Вид_отсутствия', '')).strip() + has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' + is_excluded = row.get('is_excluded', False) + has_static_reason = fio_clean in static_reasons_dict + + is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text + + # 🚨 АНОМАЛИЯ 1: Пришел по СКУД, но в 1С числится отсутствие + if is_present and has_1c_reason: + if not is_fio_whitelisted_in_kb: + anomalies.append({ + "type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ", + "fio": fio, + "details": f"Сотрудник пришел по СКУД, но в 1С оформлено: '{reason_1c}'" + }) + + # 🚨 АНОМАЛИЯ 2: Удаленщик физически пришел в офис + if is_present and has_static_reason: + static_val = static_reasons_dict[fio_clean] + if 'удален' in str(static_val).lower(): + if not is_fio_whitelisted_in_kb: + anomalies.append({ + "type": "УДАЛЁНЩИК В ОФИСЕ", + "fio": fio, + "details": f"Сотрудник числится как '{static_val}' в реестре, но сегодня зафиксирован вход по СКУД." + }) + + # 🚨 АНОМАЛИЯ 3: Конфликт 1С и Статического реестра + if has_static_reason and has_1c_reason: + static_val = static_reasons_dict[fio_clean] + if reason_1c.lower() != static_val.lower() and not is_present: + if not is_fio_whitelisted_in_kb: + anomalies.append({ + "type": "КОНФЛИКТ СПРАВОЧНИКОВ (1С vs РЕЕСТР)", + "fio": fio, + "details": f"В стат. реестре указано '{static_val}', а в 1С оформлен документ '{reason_1c}'" + }) + + return anomalies + + def aggregate_scud_by_employee(df, debug=False): - """ - Агрегирует записи СКУД по сотруднику (fio_clean). - Устраняет дубликаты карт/табельных номеров (например, Кондрашова Е.В.): - Если человек пришел хотя бы по одной карте — Пришел = True. - """ if df.empty or 'fio_clean' not in df.columns: return df @@ -53,10 +98,6 @@ def aggregate_scud_by_employee(df, debug=False): best_row = group.iloc[0].to_dict() best_row['Пришел'] = is_present - - if debug and len(group) > 1: - print(f" [DEBUG] Найдено дубликатов карт/записей для {fio_clean}: {len(group)} шт. Статус присутствия: {is_present}") - aggregated.append(best_row) return pd.DataFrame(aggregated) @@ -64,7 +105,8 @@ def aggregate_scud_by_employee(df, debug=False): def main(): parser = argparse.ArgumentParser(description="Система модульного контроллинга СКУД ⟷ 1С") - parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки (подробный вывод)") + parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки") + parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL") args = parser.parse_args() DEBUG = args.debug @@ -73,8 +115,22 @@ def main(): print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") print("=" * 60) - # 0. Валидация файлов - print("[0/5] Проверка актуальности и свежести входных файлов...") + # Загружаем существующие правила из Базы Знаний + kb_data = load_knowledge_base() + kb_rules = kb_data.get("rules", []) + + # 0. Выгрузка из БД + if not args.skip_export: + print("[0/5] Экспорт свежих данных из базы СКУД 'Орион Pro'...") + try: + run_export(debug=DEBUG) + except Exception as e: + print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Используем файлы в data/scud.") + else: + print("[0/5] Пропуск прямого экспорта из БД (--skip-export)...") + + # 1. Валидация + print("[1/5] Проверка актуальности и свежести входных файлов...") is_valid, warnings, errors = check_file_freshness() if warnings: @@ -93,40 +149,43 @@ def main(): print("[✓] Проверка актуальности успешно пройдена!\n") - # 1. Загрузка данных - print("[1/5] Загрузка данных СКУД, 1С, реестра статических причин и исключений...") + # 2. Загрузка данных + print("[2/5] Загрузка данных из data/scud, data/1c, реестра причин и исключений...") raw_scud_df, staff_fios_clean, df_absent = load_all_data() static_reasons_dict = load_static_reason_workers() - if DEBUG: - print(f" [DEBUG] Загружено записей СКУД: {len(raw_scud_df)}") - print(f" [DEBUG] Загружено сотрудников из 1С: {len(staff_fios_clean)}") - print(f" [DEBUG] Загружено докум. отсутствий 1С: {len(df_absent)}") - print(f" [DEBUG] Загружено статических причин: {len(static_reasons_dict)}") - - # 2. ИИ-сверка ФИО - print("[2/5] Проверка опечаток и сопоставление ФИО через ИИ...") + # 3. ИИ-сверка ФИО (СКУД + 1С) + print("[3/5] Проверка опечаток и сопоставление ФИО через ИИ...") scud_unrecognized = raw_scud_df[~raw_scud_df['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() - fio_mapping = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_clean) + fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_clean) - if fio_mapping: - print(f" [ИИ] Найдено и исправлено совпадений/опечаток: {len(fio_mapping)}") + if fio_mapping_scud: + print(f" [ИИ] Найдено совпадений в СКУД: {len(fio_mapping_scud)}") raw_scud_df['fio_clean'] = raw_scud_df['fio_clean'].apply( - lambda x: fio_mapping[x]['staff_fio'] if x in fio_mapping else x + lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x ) - # 2.1 Агрегация нескольких карт/дубликатов + absent_unrecognized = df_absent[~df_absent['fio_clean'].isin(staff_fios_clean)]['fio_clean'].tolist() + if absent_unrecognized: + fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized, staff_fios_clean) + if fio_mapping_absent: + print(f" [ИИ] Найдено совпадений в 1С Отсутствиях: {len(fio_mapping_absent)}") + df_absent['fio_clean'] = df_absent['fio_clean'].apply( + lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x + ) + raw_scud_df = aggregate_scud_by_employee(raw_scud_df, debug=DEBUG) - # 3. Объединение данных и иерархия приоритетов - print("[3/5] Объединение реестров 1С, СКУД, статических причин и исключений...") + # 4. Объединение и детекция + print("[4/5] Объединение реестров 1С, СКУД, статических причин и исключений...") merged = raw_scud_df[raw_scud_df['fio_clean'].isin(staff_fios_clean)].copy() - # 🎯 ПРИОРИТЕТ 1: Выгрузка докум. отсутствий 1С:ЗУП (Отсутствия сотрудников - выгрузка.xlsx) merged = merged.merge(df_absent, on='fio_clean', how='left') - # 🎯 ПРИОРИТЕТ 2: Статические причины из static_reason_workers.csv - # Применяются ТОЛЬКО если в 1С:ЗУП документа нет и сотрудник отсутствует в СКУД + # Детекция аномалий С УЧЁТОМ НАКОПЛЕННОЙ БАЗЫ ЗНАНИЙ + anomalies_list = detect_all_anomalies(merged, static_reasons_dict, kb_rules) + + # Применение статических причин (ТОЛЬКО ДЛЯ ТЕХ, КТО НЕ ПРИШЕЛ В ОФИС) if static_reasons_dict: for fio_clean, reason_val in static_reasons_dict.items(): mask_apply_static = ( @@ -136,9 +195,7 @@ def main(): ) merged.loc[mask_apply_static, 'Вид_отсутствия'] = reason_val - # 🎯 ПРИОРИТЕТ 3: Исключения по должности/департаменту/ФИО (exceptions.json) - # Если сотрудник попал в исключения (is_excluded = True), НЕ пришел в СКУД и НЕ имеет документа в 1С: - # Присваиваем ему статус "Исключение (ОВК/Подрядчики)", чтобы он НЕ ПОПАДАЛ в неизвестные! + # Применение исключений mask_apply_exceptions = ( (merged['Пришел'] == False) & (merged['Вид_отсутствия'].isna() | (merged['Вид_отсутствия'].astype(str).str.strip() == '')) & @@ -153,43 +210,42 @@ def main(): if DEBUG: print("\n--- [DEBUG STATS] ---") print(f" Всего по списку: {len(merged)}") - print(f" Присутствуют на рабочем месте: {len(merged[merged['Пришел'] == True])}") - print(f" Официально отсутствуют (1С + стат. причины + исключения): {len(absent_explained)}") + is_working_mask = (merged['Пришел'] == True) | ( + merged['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) + ) + print(f" Работают (офис / удаленка / командировки): {len(merged[is_working_mask])}") + print(f" Официально отсутствуют: {len(absent_explained)}") print(f" Истинно неизвестные случаи: {len(absent_unexplained)}") - if not absent_unexplained.empty: - print(" Список неизвестных:") - for fio in absent_unexplained['Сотрудник'].dropna().tolist(): - print(f" - {fio}") + print(f" Обнаружено комплексных аномалий: {len(anomalies_list)}") print("---------------------\n") - # 4. ИИ-анализ и интерактивная проверка (HITL) - print("[4/5] Запуск ИИ-аудитора и проверка аномалий...") + # 5. ИИ-аудитор + print("[5/5] Запуск ИИ-аудитора и глубокий анализ аномалий...") report_text = generate_markdown_report( merged_df=merged, absent_explained=absent_explained, absent_unexplained=absent_unexplained, scud_present_but_absent_in_1c=scud_present_but_absent_in_1c, + anomalies_list=anomalies_list, raw_scud_df=raw_scud_df, date_str=DATE_TODAY ) suspicious_cases = [] - if not scud_present_but_absent_in_1c.empty: - for _, row in scud_present_but_absent_in_1c.iterrows(): + if anomalies_list: + for a in anomalies_list: suspicious_cases.append({ - 'fio_target': row.get('Сотрудник', ''), - 'reason': f"Пришел по СКУД, но в 1С зафиксировано: {row.get('Вид_отсутствия', '')}" + 'fio_target': a['fio'], + 'reason': f"{a['type']}: {a['details']}" }) review_ai_decisions(report_text, suspicious_cases) - # 5. Формирование Excel и Markdown отчетов - print("[5/5] Формирование итоговых Excel и Markdown отчетов...") generate_summary_excel(merged_df=merged, date_str=DATE_TODAY) generate_detailed_excel(merged_df=merged, date_str=DATE_YESTERDAY) print("\n" + "=" * 60) - print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ:") + print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИС-АУДИТОРА:") print("=" * 60) print(report_text) diff --git a/modules/ai_verifier.py b/modules/ai_verifier.py index 4dec35a..2f70819 100644 --- a/modules/ai_verifier.py +++ b/modules/ai_verifier.py @@ -3,27 +3,34 @@ import re import requests from config import OLLAMA_URL, OLLAMA_MODEL -def ask_ollama(prompt, model=OLLAMA_MODEL, system_prompt=None): - """Базовая функция отправки запросов к Ollama""" - if system_prompt is None: - system_prompt = "Ты — офисный аудитор безопасности и кадрового учета." - +def ask_ollama(prompt, system_prompt=None): + """ + Универсальная функция отправки запросов к Ollama (поддерживает /api/generate и /api/chat) + """ payload = { - "model": model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": prompt} - ], + "model": OLLAMA_MODEL, + "prompt": f"{system_prompt}\n\n{prompt}" if system_prompt else prompt, "stream": False } + try: - response = requests.post(OLLAMA_URL, json=payload, timeout=60) + response = requests.post(OLLAMA_URL, json=payload, timeout=120) if response.status_code == 200: - return response.json()['message']['content'] + data = response.json() + # Проверяем формат /api/generate + if "response" in data: + return data["response"].strip() + # Проверяем формат /api/chat + elif "message" in data and "content" in data["message"]: + return data["message"]["content"].strip() + else: + return f"⚠️ Неизвестная структура ответа Ollama: {data}" else: - return f"Ошибка API Ollama: {response.status_code}" + return f"⚠️ Ошибка Ollama (Код {response.status_code}): {response.text}" + except requests.exceptions.ConnectionError: + return "⚠️ Не удалось подключиться к Ollama. Проверьте, запущен ли сервис (ollama run qwen2.5:14b)." except Exception as e: - return f"Ошибка подключения к Ollama: {e}" + return f"⚠️ Ошибка при обращении к Ollama: {e}" def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): """ИИ-сверка опечаток и несовпадений ФИО между СКУД и 1С:ЗУП""" diff --git a/modules/data_loader.py b/modules/data_loader.py index 24cc1c4..dd50cea 100644 --- a/modules/data_loader.py +++ b/modules/data_loader.py @@ -3,7 +3,8 @@ import pandas as pd import warnings from config import ( normalize_fio, clean_scud_fio_light, load_exceptions, - DATA_DIR, SCUD_FILE, SCUD_FILE_TODAY, SCUD_FILE_YESTERDAY + DATA_DIR, SCUD_FILE, SCUD_FILE_TODAY, SCUD_FILE_YESTERDAY, + STAFF_FILE_1C, ABSENT_FILE_1C ) warnings.filterwarnings('ignore', category=UserWarning, module='pandas') @@ -20,19 +21,17 @@ def is_excluded(fio, dept, pos, exceptions): return False def load_staff_data(): - staff_path = os.path.join(DATA_DIR, "Штатные сотрудники - ОУП.xlsx") - if not os.path.exists(staff_path): - raise FileNotFoundError(f"Файл штатного расписания не найден: {staff_path}") - df_staff = pd.read_excel(staff_path, skiprows=7) + if not os.path.exists(STAFF_FILE_1C): + raise FileNotFoundError(f"Файл штатного расписания не найден по пути: {STAFF_FILE_1C}") + df_staff = pd.read_excel(STAFF_FILE_1C, skiprows=7) df_staff = df_staff.rename(columns={df_staff.columns[1]: 'ФИО'}) df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio) return df_staff def load_absent_data(): - absent_path = os.path.join(DATA_DIR, "Отсутствия сотрудников - выгрузка.xlsx") - if not os.path.exists(absent_path): - raise FileNotFoundError(f"Файл отсутствий не найден: {absent_path}") - df_absent_raw = pd.read_excel(absent_path, skiprows=3) + if not os.path.exists(ABSENT_FILE_1C): + raise FileNotFoundError(f"Файл отсутствий не найден по пути: {ABSENT_FILE_1C}") + df_absent_raw = pd.read_excel(ABSENT_FILE_1C, skiprows=3) df_absent = df_absent_raw[1:].copy().reset_index(drop=True) df_absent = df_absent.rename(columns={df_absent.columns[0]: 'ФИО', df_absent.columns[2]: 'Вид_отсутствия'}) df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio) @@ -41,7 +40,7 @@ def load_absent_data(): def load_scud_data(): scud_path = SCUD_FILE_TODAY if os.path.exists(SCUD_FILE_TODAY) else (SCUD_FILE_YESTERDAY if os.path.exists(SCUD_FILE_YESTERDAY) else SCUD_FILE) if not os.path.exists(scud_path): - raise FileNotFoundError(f"Файл СКУД не найден в папке {DATA_DIR}") + raise FileNotFoundError(f"Файл СКУД не найден в папке data/scud: {scud_path}") df_scud = pd.read_excel(scud_path) exceptions = load_exceptions() @@ -63,7 +62,6 @@ def load_scud_data(): df_scud['Пришел'] = df_scud.apply(is_person_present, axis=1) - # Отмечаем флаг исключений, но НЕ удаляем сразу df_scud['is_excluded'] = df_scud.apply( lambda r: is_excluded(r.get('Сотрудник', ''), r.get('Подразделение', ''), r.get('Должность', ''), exceptions), axis=1 diff --git a/modules/data_validator.py b/modules/data_validator.py index ce005d3..da812a2 100644 --- a/modules/data_validator.py +++ b/modules/data_validator.py @@ -1,46 +1,34 @@ import os -import pandas as pd -from datetime import datetime, timedelta -from config import DATA_DIR, DATE_TODAY, DATE_YESTERDAY +import time +from datetime import datetime +from config import SCUD_FILE_TODAY, STAFF_FILE_1C, ABSENT_FILE_1C -def check_file_freshness(): + +def check_file_freshness(max_age_hours=24): """ - Проверяет наличие и актуальность файлов СКУД и 1С. - Возвращает (is_valid, warnings_list, errors_list) + Проверяет существование и свежесть входных файлов. """ - errors = [] warnings = [] + errors = [] + now = time.time() + max_age_sec = max_age_hours * 3600 - # Ожидаемые имена файлов - scud_today_file = os.path.join(DATA_DIR, f"Сотрудники_{DATE_TODAY}.xlsx") - scud_yesterday_file = os.path.join(DATA_DIR, f"Сотрудники_{DATE_YESTERDAY}.xlsx") - staff_file = os.path.join(DATA_DIR, "Штатные сотрудники - ОУП.xlsx") - absent_file = os.path.join(DATA_DIR, "Отсутствия сотрудников - выгрузка.xlsx") - - now = datetime.now() - - # 1. Проверка файла СКУД за СЕГОДНЯ - if not os.path.exists(scud_today_file): - errors.append(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Отсутствует актуальный файл СКУД за сегодня: 'Сотрудники_{DATE_TODAY}.xlsx'!") - else: - # Проверяем дату изменения файла (mtime) - file_mtime = datetime.fromtimestamp(os.path.getmtime(scud_today_file)) - if file_mtime.date() < now.date(): - errors.append(f"⚠️ ОШИБКА АКТУАЛЬНОСТИ: Файл 'Сотрудники_{DATE_TODAY}.xlsx' существует, но изменялся прошлым днем ({file_mtime.strftime('%d.%m.%Y %H:%M')})!") - - # 2. Проверка файла СКУД за ВЧЕРА - if not os.path.exists(scud_yesterday_file): - warnings.append(f"⚠️ ПРЕДУПРЕЖДЕНИЕ: Файл СКУД за вчера ({DATE_YESTERDAY}) не найден. Детальный отчет за вчера может быть неполным.") - - # 3. Проверка файлов 1С (Штат и Отсутствия) - for f_path, f_name in [(staff_file, "Штатные сотрудники"), (absent_file, "Выгрузка отсутствий")]: - if not os.path.exists(f_path): - errors.append(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Файл 1С '{f_name}' не найден в папке data/!") + # 1. Проверка файлов 1С в data/1c + for file_path, label in [(STAFF_FILE_1C, "Штатное расписание 1С"), (ABSENT_FILE_1C, "Отсутствия 1С")]: + if not os.path.exists(file_path): + errors.append(f"Файл '{label}' не найден по пути: {file_path}") else: - file_mtime = datetime.fromtimestamp(os.path.getmtime(f_path)) - days_old = (now - file_mtime).days - if days_old >= 2: - warnings.append(f"⚠️ УСТАРЕВШИЕ ДАННЫЕ 1С: Файл '{os.path.basename(f_path)}' не обновлялся {days_old} дн. (Дата: {file_mtime.strftime('%d.%m.%Y')})") + mtime = os.path.getmtime(file_path) + age_hours = (now - mtime) / 3600 + if age_hours > max_age_hours: + warnings.append( + f"Файл '{label}' обновлялся давно ({age_hours:.1f} ч. назад). " + f"Дата изменения: {datetime.fromtimestamp(mtime).strftime('%d.%m.%Y %H:%M')}" + ) + + # 2. Проверка файла СКУД в data/scud + if not os.path.exists(SCUD_FILE_TODAY): + warnings.append(f"Сегодняшний файл СКУД еще не сформирован: {SCUD_FILE_TODAY}") is_valid = len(errors) == 0 return is_valid, warnings, errors \ No newline at end of file diff --git a/modules/scud_export.py b/modules/scud_export.py index 4d1842e..2d47d9f 100644 --- a/modules/scud_export.py +++ b/modules/scud_export.py @@ -1,11 +1,5 @@ """ -Скрипт экспорта данных СКУД (Orion) в Excel. -Оптимизированная супер-быстрая версия (использует ROW_NUMBER() вместо подзапросов). - -Запуск: - python scud_export.py - python scud_export.py -d - python scud_export.py --date 28.07.2026 -d +Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в Excel. """ import argparse @@ -19,13 +13,12 @@ import pandas as pd import pyodbc from openpyxl.utils import get_column_letter +from config import SCUD_DIR + warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable") -# ============================================================================== -# 1. Настройки путей и логов -# ============================================================================== SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -LOG_DIR = os.path.join(SCRIPT_DIR, "logs") +LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs") os.makedirs(LOG_DIR, exist_ok=True) TODAY_DATE_STR = datetime.now().strftime("%d.%m.%Y") @@ -34,13 +27,14 @@ LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log") logger = logging.getLogger("scud_export") logger.setLevel(logging.INFO) -_file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") -_console_handler = logging.StreamHandler(sys.stdout) -_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") -_file_handler.setFormatter(_formatter) -_console_handler.setFormatter(_formatter) -logger.addHandler(_file_handler) -logger.addHandler(_console_handler) +if not logger.handlers: + _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + _console_handler = logging.StreamHandler(sys.stdout) + _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + _file_handler.setFormatter(_formatter) + _console_handler.setFormatter(_formatter) + logger.addHandler(_file_handler) + logger.addHandler(_console_handler) def log(message: str, level: str = "INFO"): @@ -56,21 +50,12 @@ def log(message: str, level: str = "INFO"): logger.log(level_map.get(level, logging.INFO), message) -# ============================================================================== -# 2. Настройки базы данных и папки -# ============================================================================== SERVER_NAME = r"192.168.1.253\SQL" DATABASE_NAME = "Orion-14.01.21-1" SQL_USER = "sa" SQL_PASSWORD = "123456" - -FOLDER_PATH = r"C:\scud_exchsnge" ODBC_DRIVER = "SQL Server Native Client 10.0" - -# ============================================================================== -# 3. СУПЕР-ОПТИМИЗИРОВАННЫЙ SQL-ЗАПРОС (Мгновенное выполнение) -# ============================================================================== SQL_QUERY_TEMPLATE = r""" DECLARE @InputDate DATE = '{target_date}'; DECLARE @TargetDate DATE = @InputDate; @@ -78,7 +63,6 @@ DECLARE @TargetDate DATE = @InputDate; DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME); DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate)); --- 1. Фильтруем логи только за нужные 24 часа за один проход WITH DailyLogs AS ( SELECT log.HozOrgan AS EmployeeID, @@ -95,7 +79,6 @@ WITH DailyLogs AS ( AND log.HozOrgan > 0 AND (log.Event IN (28, 29, 26, 27, 32, 33) OR log.Mode IN (1, 2)) ), --- 2. Агрегируем времена входа/выхода и определяем последнее событие Passages AS ( SELECT EmployeeID, @@ -167,9 +150,6 @@ ORDER BY p.Name ASC; """ -# ============================================================================== -# 4. Вспомогательные функции -# ============================================================================== def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0): from openpyxl import load_workbook @@ -196,7 +176,6 @@ def get_targets(input_date: str | None): try: parsed = datetime.strptime(input_date, "%d.%m.%Y").date() targets.append({"name": "Указанная дата", "date": parsed}) - log(f"Запущен ручной режим для даты: {input_date}") except ValueError: log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR") sys.exit(1) @@ -211,16 +190,13 @@ def get_targets(input_date: str | None): return targets -# ============================================================================== -# 5. Основная функция выгрузки -# ============================================================================== -def run_export(input_date: str | None, debug: bool = False): +def run_export(input_date: str | None = None, debug: bool = False): if debug: logger.setLevel(logging.DEBUG) log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING") - log("=== Запуск скрипта экспорта СКУД ===") - os.makedirs(FOLDER_PATH, exist_ok=True) + log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===") + os.makedirs(SCUD_DIR, exist_ok=True) targets = get_targets(input_date) @@ -234,6 +210,7 @@ def run_export(input_date: str | None, debug: bool = False): f"Encrypt=no;" ) + success = True for target in targets: processing_date = target["date"] processing_date_str = processing_date.strftime("%d.%m.%Y") @@ -242,15 +219,13 @@ def run_export(input_date: str | None, debug: bool = False): log(f"--- Обработка периода: {period_label} ({processing_date_str}) ---") file_name = f"Сотрудники_{processing_date_str}.xlsx" - file_path = os.path.join(FOLDER_PATH, file_name) + file_path = os.path.join(SCUD_DIR, file_name) if os.path.exists(file_path): - log(f"Файл {file_name} существует. Перезаписываем...") try: os.remove(file_path) except OSError as e: - log(f"ОШИБКА при удалении файла {file_name}: {e}", "ERROR") - continue + log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d")) @@ -261,51 +236,28 @@ def run_export(input_date: str | None, debug: bool = False): log(f"База вернула {len(df)} строк за {processing_date_str}") - if debug: - present_df = df[df["Начало_дня"] != "Нет входа"] - still_inside_df = df[(df["Начало_дня"] != "Нет входа") & (df["Конец_дня"] == "Нет выхода")] - left_df = df[(df["Начало_дня"] != "Нет входа") & (df["Конец_дня"] != "Нет выхода")] - - print("\n" + "=" * 50) - print(f"[DEBUG STATS] Дата: {processing_date_str}") - print(f" Всего активных сотрудников в БД: {len(df)}") - print(f" Присутствуют / были сегодня: {len(present_df)}") - print(f" Физически находятся в здании: {len(still_inside_df)} (Конец_дня = 'Нет выхода')") - print(f" Завершили рабочий день (Ушли): {len(left_df)}") - - if not still_inside_df.empty: - print("\n Первые 5 сотрудников, находящихся сейчас в здании:") - for _, r in still_inside_df.head(5).iterrows(): - print(f" • {r['Сотрудник']} | Вход: {r['Начало_дня']} | В здании: {r['Находился_в_здании']}") - print("=" * 50 + "\n") - if len(df) > 0: df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") auto_fit_columns(file_path, sheet_name="Отчет") - log(f"Успешно создан файл {file_path}", "SUCCESS") + log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS") + else: + log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING") except Exception as e: - log(f"ОШИБКА при обработке даты {processing_date_str}: {e}", "ERROR") + log(f"🛑 ОШИБКА выгрузки СКУД за {processing_date_str}: {e}", "ERROR") + success = False finally: if connection is not None: connection.close() - log("=== Завершение работы скрипта ===") + log("=== Выгрузка СКУД завершена ===\n") + return success if __name__ == "__main__": parser = argparse.ArgumentParser(description="Экспорт данных СКУД в Excel") - parser.add_argument( - "--date", - dest="input_date", - default=None, - help='Дата в формате ДД.ММ.ГГГГ (например, "28.07.2026")', - ) - parser.add_argument( - "-d", "--debug", - action="store_true", - help="Запуск в режиме отладки (вывод детальной статистики)", - ) + parser.add_argument("--date", dest="input_date", default=None) + parser.add_argument("-d", "--debug", action="store_true") args = parser.parse_args() run_export(args.input_date, debug=args.debug) \ No newline at end of file diff --git a/modules/text_reporter.py b/modules/text_reporter.py index cf3e13b..6c5e2f8 100644 --- a/modules/text_reporter.py +++ b/modules/text_reporter.py @@ -2,7 +2,8 @@ import json from modules.ai_verifier import ask_ollama from modules.knowledge_base import load_knowledge_base -def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, raw_scud_df=None, raw_staff_df=None, date_str="27.07.2026"): + +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, date_str="28.07.2026"): kb = load_knowledge_base() custom_rules = kb.get("rules", []) custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет." @@ -25,67 +26,65 @@ def generate_markdown_report(merged_df, absent_explained, absent_unexplained, sc detailed_cases_for_ai.append({ "fio_clean": fio_clean, - "staff_records_count": len(staff_matches), "staff_records_1C": staff_matches, "scud_records_LOGS": scud_matches }) - cols_crit = [c for c in ['Сотрудник', 'Подразделение', 'Должность', 'Вид_отсутствия'] if c in scud_present_but_absent_in_1c.columns] - critical_list = scud_present_but_absent_in_1c[cols_crit].to_dict(orient='records') - - # Исходные точные цифры Python total_staff = len(merged_df) - present_cnt = len(merged_df[merged_df['Пришел'] == True]) + is_working_mask = (merged_df['Пришел'] == True) | ( + merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) + ) + present_cnt = len(merged_df[is_working_mask]) explained_cnt = len(absent_explained) unexplained_cnt = len(absent_unexplained) + anomalies_cnt = len(anomalies_list) if anomalies_list else 0 prompt = f""" -Ты — старший аудитор кадровой безопасности и систем СКУД. Проанализируй данные за {date_str} и составь итоговый отчет. +Ты — старший аудитор кадровой безопасности и контроллинга СКУД. +Проанализируй аномалии и пересечения данных за {date_str}. -📌 ДИНАМИЧЕСКИЕ ПРАВИЛА И НАКОПЛЕННЫЙ ОПЫТ ПРЕДПРИЯТИЯ: +📌 НАКОПЛЕННЫЕ ПРАВИЛА ПРЕДПРИЯТИЯ И ИСКЛЮЧЕНИЯ: {custom_rules_str} -🚨 СТРОГИЕ ПРАВИЛА МАТЕМАТИКИ И КАТЕГОРИЙ: -1. Цифры сводки ДОЛЖНЫ СТРОГО СОВПАДАТЬ с первично посчитанным массивом: - - Всего офисных сотрудников: {total_staff} - - Присутствуют на рабочем месте: {present_cnt} - - Официально отсутствуют: {explained_cnt} - - Неизвестно (истинно неотмеченные): {unexplained_cnt} чел. -2. Заголовок "#### Неизвестные случаи: {unexplained_cnt}" должен иметь ровно ту же цифру {unexplained_cnt}. -3. Нумерованный список ниже ДОЛЖЕН содержать ровно {unexplained_cnt} сотрудников из массива `detailed_cases_for_ai` без пропусков. +🚨 ВХОДНЫЕ МЕТРИКИ: +- Всего сотрудников: {total_staff} +- На работе / Удаленно / Командировка: {present_cnt} +- Официально отсутствуют (1С/Реестры): {explained_cnt} +- Неизвестно (требуют выяснения): {unexplained_cnt} +- Выявлено аномалий и конфликтов реестров: {anomalies_cnt} --- -📊 ВХОДНЫЕ МИКРО-ДАННЫЕ НЕИЗВЕСТНЫХ ({unexplained_cnt} чел): -{json.dumps(detailed_cases_for_ai, ensure_ascii=False, indent=2)} +🚨 ОБНАРУЖЕННЫЕ АНОМАЛИИ И КОНФЛИКТЫ МЕЖДУ СКУД, 1С, СТАТИЧЕСКИМИ РЕЕСТРАМИ И ИСКЛЮЧЕНИЯМИ ({anomalies_cnt} шт.): +{json.dumps(anomalies_list, ensure_ascii=False, indent=2)} -🚨 СПИСОК КРИТИЧЕСКИХ АНОМАЛИЙ: -{json.dumps(critical_list, ensure_ascii=False, indent=2)} +--- +📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел): +{json.dumps(detailed_cases_for_ai, ensure_ascii=False, indent=2)} СТРОГИЙ ШАБЛОН ОТВЕТА: -**Сводка контроллинга СКУД и 1С:ЗУП на {date_str} в 12:00** +**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}** - Всего офисных сотрудников: **{total_staff}** -- Присутствуют на рабочем месте: **{present_cnt}** +- Работают (офис / удаленка / командировки): **{present_cnt}** - Официально отсутствуют: **{explained_cnt}** -- Неизвестно (истинно неотмеченные после проверки ИИ): **{unexplained_cnt}** чел. +- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел. +- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. -#### ⚠️ Подозрения на ложные срабатывания СКУД / Совмещения: -(Укажи сотрудников с аномалиями/вторыми проходами, например Кондрашову Е.В., если у нее дубликат карты) +#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): +(Подробно разбери каждый конфликт из `anomalies_list`. Опиши суть нарушения. Если аномалий нет — напиши "Аномалий и конфликтов не обнаружено.") -#### Критические аномалии (Работа в отпуске / на больничном): -(Детально опиши сотрудников из списка КРИТИЧЕСКИХ АНОМАЛИЙ. Если их нет — напиши "Критических нарушений не выявлено.") +#### ⚠️ Подозрения на ложные срабатывания / Совмещения: +(Укажи сотрудников с повторными проходами или несколькими картами, если такие есть) #### Неизвестные случаи: {unexplained_cnt} -(Выведи НУМЕРОВАННЫЙ СПИСОК ВСЕХ {unexplained_cnt} человек строго в формате: -1. ФИО — [Аббревиатура], Должность -2. ФИО — [Аббревиатура], Должность -... -{unexplained_cnt}. ФИО — [Аббревиатура], Должность) +(Выведи НУМЕРОВАННЫЙ СПИСОК ВСЕХ {unexplained_cnt} человек в формате: +1. ФИО — Подразделение, Должность +2. ФИО — Подразделение, Должность) ### Рекомендации: -... +(Сформулируй 2-3 общие системные рекомендации для кадровой службы. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО перечислять конкретные ФИО сотрудников в скобках или тексте рекомендаций — они уже перечислены выше.) """ - sys_prompt = "Ты — кадровый аудитор. Выводи ровно то количество людей в списке, которое указано в переменной unexplained_cnt." + sys_prompt = "Ты — эксперт кадрового аудита. Пиши сжато, лаконично, без дублирования ФИО сотрудников в блоке рекомендаций." return ask_ollama(prompt, system_prompt=sys_prompt) \ No newline at end of file