265 lines
12 KiB
Python
265 lines
12 KiB
Python
import json
|
||
import re
|
||
import requests
|
||
import pandas as pd
|
||
from config import OLLAMA_URL, OLLAMA_MODEL
|
||
from core.database import get_all_rules_from_db
|
||
from core.database import get_department_synonyms_dict, add_department_synonym_to_db
|
||
|
||
def resolve_department_exception_ai(dept_1c, dept_scud, exception_departments):
|
||
"""
|
||
Универсальный сопоставитель отделов.
|
||
Проверяет 1С, СКУД, локальную базу синонимов SQLite и задействует ИИ для сложных случайных аббревиатур.
|
||
"""
|
||
if not exception_departments:
|
||
return False
|
||
|
||
d_1c = str(dept_1c).strip().lower() if dept_1c else ""
|
||
d_scud = str(dept_scud).strip().lower() if dept_scud else ""
|
||
exc_list = [d.strip().lower() for d in exception_departments]
|
||
|
||
# 1. Прямая проверка: если точное имя или подстрока уже совпали в 1С или СКУД
|
||
for exc in exc_list:
|
||
if exc and (exc in d_1c or exc in d_scud or d_1c in exc or d_scud in exc):
|
||
return True
|
||
|
||
# 2. Проверка по сохраненной Базе Знаний синонимов из SQLite
|
||
synonyms = get_department_synonyms_dict()
|
||
for exc in exc_list:
|
||
# Если в БД зафиксировано: 'овк' -> 'отдел внутреннего контроля'
|
||
full_from_db = synonyms.get(exc, "")
|
||
if full_from_db and (full_from_db in d_1c or full_from_db in d_scud):
|
||
return True
|
||
|
||
# 3. Умный ИИ-арбитраж (если отдел спорный и еще не сохранен в БД)
|
||
if d_1c or d_scud:
|
||
dept_to_check = d_1c if d_1c else d_scud
|
||
prompt = f"""
|
||
Ты — кадровый аналитик.
|
||
Проверь, является ли отдел сотрудника "{dept_to_check}" тем же самым подразделением, что и один из отделов-исключений: {exception_departments}?
|
||
|
||
Примеры:
|
||
- "Отдел внутреннего контроля" — это "ОВК" (Да)
|
||
- "Отдел технического обеспечения" — это "ОТО" (Да)
|
||
|
||
Ответь СТРОГО в формате JSON:
|
||
{{
|
||
"is_match": true/false,
|
||
"matched_exception": "Название из списка исключений",
|
||
"explanation": "краткое объяснение"
|
||
}}
|
||
"""
|
||
try:
|
||
raw_res = ask_ollama(prompt, system_prompt="Отвечай только валидным JSON.")
|
||
match = re.search(r'\{.*\}', raw_res, re.DOTALL)
|
||
if match:
|
||
data = json.loads(match.group(0))
|
||
if data.get("is_match"):
|
||
matched_exc = data.get("matched_exception", "").lower()
|
||
# Запоминаем открытую ИИ связь в SQLite навсегда!
|
||
add_department_synonym_to_db(matched_exc, dept_to_check)
|
||
return True
|
||
except Exception as e:
|
||
print(f"[⚠️] Ошибка ИИ-арбитража отделов: {e}")
|
||
|
||
return False
|
||
|
||
def ask_ollama(prompt, system_prompt=None):
|
||
"""
|
||
Универсальная функция отправки запросов к локальной модели Ollama (Qwen 2.5).
|
||
"""
|
||
payload = {
|
||
"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=120)
|
||
if response.status_code == 200:
|
||
data = response.json()
|
||
if "response" in data:
|
||
return data["response"].strip()
|
||
elif "message" in data and "content" in data["message"]:
|
||
return data["message"]["content"].strip()
|
||
else:
|
||
return f"⚠️ Неизвестная структура ответа Ollama: {data}"
|
||
else:
|
||
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}"
|
||
|
||
|
||
def get_system_rules_context():
|
||
"""
|
||
Загружает динамические системные правила компании напрямую из Базы Данных SQLite.
|
||
"""
|
||
rules = get_all_rules_from_db()
|
||
if not rules:
|
||
return ""
|
||
rules_text = "\n".join([f"- {r}" for r in rules])
|
||
return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ (ИЗ SQLITE БД):\n{rules_text}\n"
|
||
|
||
|
||
def analyze_scud_mass_failure_ai(df_scud):
|
||
"""
|
||
Оценивает процент аномалий 'ANOMALY_NO_IN_HAS_ACTIVITY' в выгрузке.
|
||
Если процент аномалий превышает 5% от смены или 10 человек, вызывает ИИ для генерации критического алерта.
|
||
"""
|
||
if df_scud is None or df_scud.empty:
|
||
return None
|
||
|
||
total_records = len(df_scud)
|
||
anomaly_rows = df_scud[df_scud.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY']
|
||
anomaly_count = len(anomaly_rows)
|
||
|
||
if total_records == 0:
|
||
return None
|
||
|
||
anomaly_percent = round((anomaly_count / total_records) * 100, 1)
|
||
|
||
if anomaly_percent > 5.0 or anomaly_count >= 10:
|
||
rules_context = get_system_rules_context()
|
||
prompt = f"""
|
||
{rules_context}
|
||
ВНИМАНИЕ! Проведён анализ смены СКУД:
|
||
- Всего записей за смену: {total_records}
|
||
- Выявлено сотрудников без утреннего входа, но с зафиксированной дневной активностью: {anomaly_count} ({anomaly_percent}% от смены)
|
||
|
||
Сформируй понятное предупреждение для Администратора СКУД и Руководителя.
|
||
Объясни, что это критический массовый сбой турникетов/контроллеров входа на КПП, и порекомендуй действия.
|
||
"""
|
||
alert_text = ask_ollama(prompt, system_prompt="Ты — ИИ-аналитик контроллинга СКУД. Отвечай кратко и строго по делу.")
|
||
return {
|
||
"is_mass_failure": True,
|
||
"anomaly_count": anomaly_count,
|
||
"anomaly_percent": anomaly_percent,
|
||
"alert_text": alert_text
|
||
}
|
||
|
||
return {
|
||
"is_mass_failure": False,
|
||
"anomaly_count": anomaly_count,
|
||
"anomaly_percent": anomaly_percent,
|
||
"alert_text": "Массовых сбоев оборудования не зафиксировано."
|
||
}
|
||
|
||
|
||
def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
||
if not unrecognized_scud_fios or not staff_fios:
|
||
return {}
|
||
|
||
# Исключаем точные совпадения
|
||
staff_fios_set = set(staff_fios)
|
||
real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set]
|
||
|
||
if not real_unrecognized:
|
||
return {}
|
||
|
||
rules_context = get_system_rules_context()
|
||
|
||
prompt = f"""
|
||
Ты — кадровый аудитор безопасности СКУД.
|
||
{rules_context}
|
||
В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)}
|
||
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
|
||
|
||
СТРОГИЕ ПРАВИЛА:
|
||
1. Запись из Штат 1С — это 100% ПРАВИЛЬНЫЙ эталон.
|
||
2. В СКУД допущена опечатка.
|
||
3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией.
|
||
4. В поле "warning" опиши обнаруженную опечатку.
|
||
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
|
||
|
||
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON:
|
||
{{
|
||
"verified_matches": [
|
||
{{
|
||
"scud_fio": "ФИО из СКУД",
|
||
"staff_fio": "эталон ФИО из Штат 1С",
|
||
"warning": "Описание опечатки в СКУД"
|
||
}}
|
||
]
|
||
}}
|
||
"""
|
||
raw_response = ask_ollama(
|
||
prompt,
|
||
system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста."
|
||
)
|
||
|
||
mapping = {}
|
||
if not raw_response:
|
||
return mapping
|
||
|
||
# Фаза 1: Попытка прямого разбора с санитарной очисткой
|
||
try:
|
||
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
|
||
if match:
|
||
json_str = match.group(0)
|
||
# Убираем висячие запятые: {"a": 1,} -> {"a": 1}
|
||
json_str = re.sub(r',\s*([\}\]])', r'\1', json_str)
|
||
# Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости
|
||
json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str)
|
||
|
||
data = json.loads(json_str)
|
||
for item in data.get("verified_matches", []):
|
||
scud_f = item.get("scud_fio")
|
||
staff_f = item.get("staff_fio")
|
||
warn = item.get("warning", "Точное совпадение (без опечаток)")
|
||
if scud_f and staff_f and staff_f in staff_fios_set:
|
||
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
|
||
return mapping
|
||
except Exception:
|
||
pass
|
||
|
||
# Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть)
|
||
try:
|
||
pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']'
|
||
matches = re.findall(pattern, raw_response, re.DOTALL)
|
||
for scud_f, staff_f in matches:
|
||
scud_clean = scud_f.strip()
|
||
staff_clean = staff_f.strip()
|
||
if staff_clean in staff_fios_set:
|
||
mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"}
|
||
except Exception as e:
|
||
print(f"[!] Ошибка резервного парсинга опечаток: {e}")
|
||
|
||
return mapping
|
||
|
||
|
||
def ai_verify_department_exceptions(unexplained_df, exception_departments):
|
||
"""
|
||
Локальный ИИ проверяет, не являются ли неотмеченные отделы синонимами отделов-исключений.
|
||
"""
|
||
if unexplained_df.empty or not exception_departments:
|
||
return []
|
||
|
||
dept_list = unexplained_df['Подразделение'].dropna().unique().tolist()
|
||
rules_context = get_system_rules_context()
|
||
|
||
prompt = f"""
|
||
{rules_context}
|
||
Ты — кадровый аудитор.
|
||
Список отделов-исключений компании: {json.dumps(exception_departments, ensure_ascii=False)}
|
||
Список отделов сотрудников, попавших в неизвестные: {json.dumps(dept_list, ensure_ascii=False)}
|
||
|
||
Определи, какие из отделов сотрудников являются ПОЛНЫМИ НАЗВАНИЯМИ или СИНУНИМАМИ отделов-исключений (например: 'Отдел внутреннего контроля' — это 'ОВК').
|
||
|
||
Выдай ответ строго в формате JSON:
|
||
{{
|
||
"matched_departments": ["Название отдела 1", "Название отдела 2"]
|
||
}}
|
||
"""
|
||
raw_response = ask_ollama(prompt, system_prompt="Выдавай только валидный JSON.")
|
||
|
||
try:
|
||
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
|
||
if match:
|
||
data = json.loads(match.group(0))
|
||
return data.get("matched_departments", [])
|
||
except Exception as e:
|
||
print(f"[⚠️] Ошибка ИИ-арбитража отделов: {e}")
|
||
|
||
return [] |