380 KiB
380 KiB
📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2
File: ./config.py
import os
import re
from datetime import datetime, timedelta
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
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")
# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат)
SHARE_1C_DIR = "/mnt/scud_share"
for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]:
os.makedirs(folder, exist_ok=True)
NOW = datetime.now()
DATE_TODAY = NOW.strftime("%d.%m.%Y")
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")
# --- Настройки LLM / Ollama ---
OLLAMA_URL = "http://192.168.11.3: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")
# --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) ---
ZUP_SQL_CONFIG = {
"driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}"
"server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"),
"database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01
"user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server
"password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server
"trust_server_certificate": "yes", # Доверять сертификату сервера
"encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование
}
def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]):
"""
Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026).
"""
date_dots = date_str
date_underscores = date_str.replace('.', '_')
for d in search_dirs:
if not os.path.exists(d):
continue
for f in os.listdir(d):
if f.endswith('.xlsx') or f.endswith('.csv'):
if f.lower().startswith(prefix.lower()):
if date_dots in f or date_underscores in f:
return os.path.join(d, f)
return None
def normalize_fio(fio):
if not fio or not isinstance(fio, str):
return ""
fio_clean = re.sub(r'\(.*?\)', '', fio)
fio_clean = fio_clean.replace('\xa0', ' ')
parts = fio_clean.strip().split()
return " ".join(parts).title()
def clean_scud_fio_light(fio_str):
return normalize_fio(fio_str)
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": []}
File: ./exceptions.json
{
"departments": [
"ОВК"
],
"positions": [
"Уборщик производственных помещений",
"Уборщик служебных помещений"
],
"fio": [
"Таткало Валерий Валерьевич",
"Петренюк Андрей Германович",
"Чуркина Елена Геннадьевна",
"Михалев Сергей Геннадьевич"
],
"position_keywords": [
"уборщик",
"клинер",
"дворник",
"гардероб",
"рабочий по обслуживанию"
]
}
File: ./main_etl.py
import os
import sys
import json
import argparse
import pandas as pd
from datetime import datetime, timedelta
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio
from services.scud_export import run_export
from services.share_copier import copy_1c_files_from_share
from services.data_validator import check_file_freshness
from services.data_loader import load_1c_data_smart, load_scud_data
from services.excel_exporter import generate_summary_excel, generate_detailed_excel
from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai
from services.text_reporter import generate_markdown_report
from services.feedback_loop import review_ai_decisions
from services.knowledge_base import load_knowledge_base
from core.database import (
init_db,
save_scud_to_db,
save_staff_to_db,
save_absences_to_db,
save_anomalies_to_db,
load_scud_from_db_by_snapshot,
load_staff_from_db,
load_absences_from_db,
get_latest_snapshot_time,
has_scud_logs_for_date
)
def load_exceptions_config():
"""Загружает файл exceptions.json из корня проекта."""
root_dir = os.path.dirname(os.path.abspath(__file__))
json_path = os.path.join(root_dir, "exceptions.json")
if not os.path.exists(json_path):
return {}
try:
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[⚠️] Ошибка чтения exceptions.json: {e}")
return {}
def apply_exceptions_from_json(df, exceptions_cfg):
"""
Быстрая и эффективная разметка флага is_excluded=True на основе exceptions.json.
Выполняется мгновенно без цикличных HTTP-запросов к ИИ.
"""
if df is None or df.empty or not exceptions_cfg:
if df is not None:
df['is_excluded'] = False
return df
deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d]
exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
df['is_excluded'] = False
for idx, row in df.iterrows():
fio_clean = row.get('fio_clean', '')
dep_1c = str(row.get('Подразделение', '')).lower()
dep_scud = str(row.get('department_scud', row.get('department', ''))).lower()
pos = str(row.get('Должность', '')).lower()
is_fio_exc = fio_clean in exc_fios
is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False
# Быстрая проверка отделов по подстрокам
is_dep_exc = False
if deps:
is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps)
if is_fio_exc or is_dep_exc or is_pos_exc:
df.at[idx, 'is_excluded'] = True
return df
def filter_report_dataframe(merged_df):
"""
Исключает сотрудников из списка исключений и сотрудников без пропуска из детального отчета,
ЕСЛИ у них нет официального документа отсутствия из 1С:ЗУП.
"""
if merged_df is None or merged_df.empty:
return merged_df
has_1c_reason = (
merged_df['Вид_отсутствия'].notna() &
(merged_df['Вид_отсутствия'].astype(str).str.strip() != '') &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
)
is_not_excluded = merged_df.get('is_excluded', False) == False
is_not_no_pass = merged_df.get('no_scud_pass', False) == False
filtered_df = merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy()
return filtered_df
def load_static_reason_workers():
"""Загружает реестр удалёнщиков и статических причин из CSV."""
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if not os.path.exists(static_path):
return {}
try:
df_static = pd.read_csv(static_path, encoding='utf-8')
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
return dict(zip(df_static['fio_clean'], df_static['reason']))
except Exception as e:
print(f"[⚠️] Ошибка чтения static_reason_workers.csv: {e}")
return {}
def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set=None):
"""
Автоматически выявляет истинные аномалии СКУД ⟷ 1С.
"""
anomalies = []
kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк']
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)
is_exc = row.get('is_excluded', False)
reason_1c = str(row.get('Вид_отсутствия', '')).strip()
has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение')
anom_flag = row.get('anomaly_flag', 'NONE')
is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
if is_present and has_1c_reason:
reason_lower = reason_1c.lower()
is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS)
if not is_allowed_trip and not is_fio_whitelisted_in_kb:
anomalies.append({
"type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ",
"fio": fio,
"details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'"
})
if is_exc and not has_1c_reason:
continue
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
first_act = row.get('Первая_активность', '—')
anomalies.append({
"type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА",
"fio": fio,
"details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}"
})
if scud_fios_set is not None:
if fio_clean not in scud_fios_set and not has_1c_reason:
anomalies.append({
"type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO",
"fio": fio,
"details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но полностью отсутствует в базе СКУД Орион Pro (профиль не создан или карта не выдана)"
})
return anomalies
def aggregate_scud_by_employee(df, debug=False):
"""Агрегирует проходы СКУД по уникальным сотрудникам."""
if df is None or df.empty or 'fio_clean' not in df.columns:
return df
aggregated = []
for fio_clean, group in df.groupby('fio_clean', sort=False):
is_present = group['Пришел'].any() if 'Пришел' in group.columns else False
if is_present and 'Пришел' in group.columns:
present_rows = group[group['Пришел'] == True]
best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict()
else:
best_row = group.iloc[0].to_dict()
best_row['Пришел'] = is_present
aggregated.append(best_row)
return pd.DataFrame(aggregated)
def main():
help_text = """
Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2)
ПРИМЕРЫ ЗАПУСКА:
python main.py -- Обычный дневной запуск
python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite
python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота
python main.py -d -- Запуск в режиме расширенной отладки (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки с выводом подробных логов")
parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет по последнему снапшоту")
parser.add_argument('--snapshot', type=str, default=None, help="Составной ID снапшота или время создания")
args = parser.parse_args()
DEBUG = args.debug
print("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}")
print("=" * 60)
init_db()
kb_data = load_knowledge_base()
kb_rules = kb_data.get("rules", [])
exceptions_cfg = load_exceptions_config()
if args.snapshot:
snapshot_param = args.snapshot
print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'")
elif args.skip_export:
snapshot_param = get_latest_snapshot_time()
print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')")
else:
snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'")
if not args.skip_export and not args.snapshot:
try:
run_export(save_xlsx=True, debug=DEBUG)
except Exception as e:
print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.")
else:
print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
# ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА
if not args.snapshot and not args.skip_export:
copy_1c_files_from_share()
print("[1/5] Проверка актуальности и свежести входных данных...")
is_valid, warnings, errors, has_today_1c = check_file_freshness()
if warnings:
print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---")
for w in warnings:
print(f" • {w}")
print("-" * 45)
if not is_valid:
print("\n" + "!" * 60)
print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!")
for e in errors:
print(f" {e}")
print("!" * 60)
sys.exit(1)
print("[✓] Проверка доступности данных успешно пройдена!\n")
else:
print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...")
has_today_1c = True
print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
# 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ
if args.snapshot or args.skip_export:
raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param)
if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns:
target_date_str = str(raw_scud_today_df['log_date'].iloc[0])
else:
target_date_str = DATE_TODAY
dt_target = datetime.strptime(target_date_str, "%d.%m.%Y")
if dt_target.weekday() == 0: # Понедельник -> Пятница
dt_yesterday = dt_target - timedelta(days=3)
else:
dt_yesterday = dt_target - timedelta(days=1)
yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y")
print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}")
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
# Гарантированная загрузка кадров 1С за обе даты (БД + локальный фолбэк)
df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True)
else:
target_date_str = DATE_TODAY
yesterday_date_str = DATE_YESTERDAY
raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param)
raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY, use_db=False)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY, use_db=False)
if df_staff_yesterday is not None:
save_staff_to_db(df_staff_yesterday, yesterday_date_str)
if df_absent_yesterday is not None:
save_absences_to_db(df_absent_yesterday, yesterday_date_str)
if df_staff_today is not None:
save_staff_to_db(df_staff_today, target_date_str)
if df_absent_today is not None:
save_absences_to_db(df_absent_today, target_date_str)
staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else []
static_reasons_dict = load_static_reason_workers()
# ============================================================
# 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ)
# ============================================================
print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...")
if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False
scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else []
fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean)
if fio_mapping_scud_y:
raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(
lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x
)
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist()
if absent_unrecognized_yesterday:
fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean)
if fio_mapping_absent_y:
df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply(
lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x
)
raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG)
# ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ
merged_yesterday = df_staff_yesterday.copy() if (df_staff_yesterday is not None and not df_staff_yesterday.empty) else (df_staff_today.copy() if df_staff_today is not None else pd.DataFrame())
if not merged_yesterday.empty:
if not raw_scud_yesterday_df.empty:
if 'Подразделение' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение']
elif 'department' in raw_scud_yesterday_df.columns:
raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department']
else:
raw_scud_yesterday_df['department_scud'] = ''
merged_yesterday = merged_yesterday.merge(
raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
if 'Вид_отсутствия' in merged_yesterday.columns:
merged_yesterday = merged_yesterday.drop(columns=['Вид_отсутствия'])
# Присоединяем отсутствия СТРОГО за дату yesterday_date_str
if df_absent_yesterday is not None and not df_absent_yesterday.empty:
merged_yesterday = merged_yesterday.merge(
df_absent_yesterday[['fio_clean', 'Вид_отсутствия']],
on='fio_clean',
how='left'
)
if 'Пришел' not in merged_yesterday.columns:
merged_yesterday['Пришел'] = False
else:
merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False)
if 'Сотрудник' not in merged_yesterday.columns:
merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean'])
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday['fio_clean'] == fio_clean)
)
merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
mask_exc_yesterday = (
(merged_yesterday['Пришел'] == False) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == True)
)
merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set()
merged_yesterday['no_scud_pass'] = (
(~merged_yesterday['fio_clean'].isin(scud_fios_yesterday_set)) &
(merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_yesterday.get('is_excluded', False) == False)
)
anomalies_yesterday_list = detect_all_anomalies(merged_yesterday, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_yesterday_set)
save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str)
filtered_yesterday = filter_report_dataframe(merged_yesterday)
generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str)
yesterday_dt_obj = datetime.strptime(yesterday_date_str, "%d.%m.%Y")
yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00")
save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True)
print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}")
# ============================================================
# 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА (ЗА ЦЕЛЕВОЙ ДЕНЬ СНАПШОТА)
# ============================================================
if not args.snapshot and not args.skip_export:
save_scud_to_db(raw_scud_today_df, target_date_str, snapshot_time=snapshot_param, is_yesterday=False)
if has_today_1c and df_staff_today is not None and df_absent_today is not None:
print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...")
staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist()
if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns:
raw_scud_today_df['Пришел'] = raw_scud_today_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_today_df.columns else False
scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty else []
fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean)
if fio_mapping_scud:
raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply(
lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x
)
absent_unrecognized_today = df_absent_today[~df_absent_today['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist()
if absent_unrecognized_today:
fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized_today, staff_fios_today_clean)
if fio_mapping_absent:
df_absent_today['fio_clean'] = df_absent_today['fio_clean'].apply(
lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x
)
raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG)
merged_today = df_staff_today.copy()
if not raw_scud_today_df.empty:
if 'Подразделение' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['Подразделение']
elif 'department' in raw_scud_today_df.columns:
raw_scud_today_df['department_scud'] = raw_scud_today_df['department']
else:
raw_scud_today_df['department_scud'] = ''
merged_today = merged_today.merge(
raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
on='fio_clean', how='left'
)
if df_absent_today is not None and not df_absent_today.empty:
merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
if 'Пришел' not in merged_today.columns:
merged_today['Пришел'] = False
else:
merged_today['Пришел'] = merged_today['Пришел'].fillna(False)
if 'Сотрудник' not in merged_today.columns:
merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean'])
if static_reasons_dict:
for fio_clean, reason_val in static_reasons_dict.items():
mask_today = (
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today['fio_clean'] == fio_clean)
)
merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val
merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg)
mask_exc_today = (
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == True)
)
merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
scud_fios_today_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
merged_today['no_scud_pass'] = (
(~merged_today['fio_clean'].isin(scud_fios_today_set)) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(merged_today.get('is_excluded', False) == False)
)
scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_set)
mass_failure_today = analyze_scud_mass_failure_ai(raw_scud_today_df)
if mass_failure_today and mass_failure_today.get("is_mass_failure"):
print("\n" + "!" * 60)
print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)")
print(mass_failure_today["alert_text"])
print("!" * 60 + "\n")
save_anomalies_to_db(anomalies_list, target_date_str)
is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False
is_exc_today = merged_today.get('is_excluded', False) == True
absent_explained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_unexplained = merged_today[
(merged_today['Пришел'] == False) &
(merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
(~is_no_pass_today) &
(~is_exc_today)
]
scud_present_but_absent_in_1c = merged_today[
(~is_exc_today) &
(merged_today['Пришел'] == True) &
(merged_today['Вид_отсутствия'].notna()) &
(~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...")
filtered_anomalies_list = [
a for a in anomalies_list
if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '')
]
report_text = generate_markdown_report(
merged_df=merged_today,
absent_explained=absent_explained,
absent_unexplained=absent_unexplained,
scud_present_but_absent_in_1c=scud_present_but_absent_in_1c,
anomalies_list=filtered_anomalies_list,
raw_scud_df=raw_scud_today_df,
raw_absent_df=df_absent_today,
date_str=target_date_str
)
generate_summary_excel(merged_df=merged_today, date_str=target_date_str)
md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{target_date_str}.md")
with open(md_report_path, "w", encoding="utf-8") as f:
f.write(report_text)
print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}")
suspicious_cases = []
for a in anomalies_list:
if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''):
suspicious_cases.append({
'fio_target': a['fio'],
'reason': f"{a['type']}: {a['details']}"
})
if suspicious_cases:
review_ai_decisions(report_text, suspicious_cases)
print("\n" + "=" * 60)
print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
print("=" * 60)
print(report_text)
else:
print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.")
if __name__ == "__main__":
main()
File: ./scripts/__init__.py
File: ./scripts/add_rule_ai.py
import sys
import os
# Добавляем корень проекта в путь поиска модулей Python
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import get_connection
rule_text = (
"Сотрудники, присутствующие в 1С:ЗУП, но отсутствующие в СКУД Орион Pro, "
"являются аномалией синхронизации профилей. ИИ должен запрашивать у СБ статус выдачи пропуска."
)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text) VALUES (?)", (rule_text,))
conn.commit()
print("✓ Правило успешно внесено в SQLite БД!")
File: ./scripts/db_cli.py
import os
import sys
import argparse
import sqlite3
import pandas as pd
from datetime import datetime
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR
from core.database import (
get_connection,
get_available_snapshots,
get_all_rules_from_db,
load_scud_from_db_by_snapshot,
get_latest_snapshot_time
)
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def print_tool_actions():
"""Выводит реестр декларативных действий инструментов и шаблоны кнопок."""
print("\n" + "=" * 110)
print("🧰 ДЕКЛАРАТИВНЫЙ РЕЕСТР ДЕЙСТВИЙ ИНСТРУМЕНТОВ (tool_action_registry):")
print("=" * 110)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json
FROM tool_action_registry
WHERE is_active = 1
ORDER BY id ASC
""")
rows = cursor.fetchall()
if not rows:
print("Таблица tool_action_registry пуста.")
else:
for r in rows:
print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}")
print(f" • Сообщение: {r[4]}")
if r[5]:
print(f" • Вопрос: {r[5]}")
print(f" • Кнопки: {r[6]}")
print("-" * 110)
print("=" * 110 + "\n")
def print_stats():
"""Выводит общую статистику по записям в таблицах БД."""
print("\n" + "=" * 60)
print("📊 СТАТИСТИКА БАЗЫ ДАННЫХ SQLITE (scud_orion_ai.db):")
print("=" * 60)
with get_connection() as conn:
cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']
for t in tables:
cursor.execute(f"SELECT COUNT(*) FROM {t}")
cnt = cursor.fetchone()[0]
print(f" • Таблица [{t:<18}]: {cnt:>6} записей")
print("=" * 60 + "\n")
def print_snapshots_list(date_str=None):
"""Выводит реестр снапшотов с отображением даты и точного времени среза."""
rows = get_available_snapshots(date_str)
print("\n" + "=" * 105)
print(f"📸 РЕЕСТР СОХРАНЕННЫХ СНАПШОТОВ (СВЕРХУ СВЕЖИЕ) {'ЗА ЛОГИ ' + date_str if date_str else ''}:")
print("=" * 105)
header = f"{'ID снапшота':<16} | {'Дата снапшота (создания)':<24} | {'Дата и время среза':<20} | {'Записей':<8}"
print(header)
print("-" * 105)
if not rows:
print("Снапшотов пока нет.")
print("=" * 105 + "\n")
return
def snapshot_sort_key(row):
snap_id = row[0] or ""
snap_time = row[2] or ""
seq_num = 0
if "-" in snap_id:
parts = snap_id.replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit():
seq_num = int(parts[1])
return (snap_time, seq_num)
sorted_rows = sorted(rows, key=snapshot_sort_key, reverse=True)
for r in sorted_rows:
snap_id = r[0] if r[0] else '----------'
log_date = r[1] if r[1] else '—'
snap_time = r[2] if r[2] else '—'
count = r[3]
time_part = "—"
if snap_time and " " in snap_time:
time_part = snap_time.split(" ")[1]
slice_datetime_str = f"{log_date} {time_part}" if time_part != "—" else log_date
if not snap_id.startswith("Y"):
formatted_snap_id = f" {snap_id}"
else:
formatted_snap_id = snap_id
print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}")
print("=" * 105 + "\n")
def delete_snapshot_by_id(snapshot_id: str):
"""Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
deleted_count = cursor.rowcount
conn.commit()
print(f"\n[✓] Успешно удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}\n")
return deleted_count
def delete_snapshots_by_date(date_str: str):
"""Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
deleted_count = cursor.rowcount
conn.commit()
print(f"\n[✓] Успешно удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}\n")
return deleted_count
def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
"""Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX."""
target_date = date_str if date_str else DATE_TODAY
print("\n" + "=" * 90)
if snapshot_id:
print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата: {target_date}):")
else:
print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):")
print("=" * 90)
df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id)
if df.empty:
print(f"Записи СКУД не найдены.")
print("=" * 90 + "\n")
return
total = len(df)
present_cnt = len(df[df['Пришел'] == True]) if 'Пришел' in df.columns else 0
absent_cnt = total - present_cnt
print(f"Всего записей: {total} | Пришли: {present_cnt} | Не пришли: {absent_cnt}")
print("-" * 90)
cols_to_show = ['fio', 'department', 'position', 'time_in', 'first_activity', 'time_out', 'is_present', 'anomaly_flag', 'snapshot_id']
existing_cols = [c for c in cols_to_show if c in df.columns]
print(df[existing_cols].head(30).to_string(index=False))
if len(df) > 30:
print(f"\n... и ещё {len(df) - 30} строк.")
if export_xlsx:
out_path = export_xlsx if export_xlsx.endswith('.xlsx') else f"{export_xlsx}.xlsx"
if not os.path.isabs(out_path):
out_path = os.path.join(OUTPUT_DIR, out_path)
df.to_excel(out_path, index=False)
print("\n" + "*" * 90)
print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}")
print("*" * 90)
print("=" * 90 + "\n")
def print_absences(date_str=None):
"""Выводит список официально отсутствующих сотрудников из 1С:ЗУП за выбранный день."""
target_date = date_str if date_str else DATE_TODAY
print("\n" + "=" * 90)
print(f"📋 ОФИЦИАЛЬНЫЕ ОТСУТСТВИЯ ИЗ 1С:ЗУП ЗА ДАТУ [{target_date}]:")
print("=" * 90)
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', absence_type as 'Причина отсутствия 1С' FROM zup_absences WHERE absence_date = ? ORDER BY absence_type, fio",
conn,
params=(target_date,)
)
if df.empty:
print(f"Записи об отсутствиях 1С за {target_date} в базе не найдены.")
else:
print(f"Всего зафиксировано документов 1С: {len(df)}")
print("-" * 90)
print(df.to_string(index=False))
print("=" * 90 + "\n")
def print_anomalies():
"""Выводит список аномалий СКУД из БД."""
print("\n" + "=" * 80)
print("🚨 ИСТОРИЯ НАЙДЕННЫХ АНОМАЛИЙ СКУД ⟷ 1С:")
print("=" * 80)
with get_connection() as conn:
df = pd.read_sql_query("SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history ORDER BY id DESC LIMIT 50", conn)
if df.empty:
print("Аномалии не найдены.")
else:
print(df.to_string(index=False))
print("=" * 80 + "\n")
def print_rules():
"""Выводит правила базы знаний ИИ."""
rules = get_all_rules_from_db()
print("\n" + "=" * 80)
print("🧠 ПРАВИЛА БАЗЫ ЗНАНИЙ ИИ:")
print("=" * 80)
if not rules:
print("База знаний пуста.")
else:
for idx, r in enumerate(rules, 1):
print(f" {idx}. {r}")
print("=" * 80 + "\n")
def dump_all_to_excel(out_filename="db_dump_full.xlsx"):
"""Дампит всю базу SQLite во многостраничный Excel."""
out_path = os.path.join(OUTPUT_DIR, out_filename)
print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...")
with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base']:
df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
df.to_excel(writer, sheet_name=table[:31], index=False)
print(f"[✓] Дамп успешно сохранен: {out_path}\n")
def print_system_prompts():
"""Выводит все системные промпты из базы данных."""
print("\n" + "=" * 80)
print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):")
print("=" * 80)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC")
rows = cursor.fetchall()
if not rows:
print("Таблица system_prompts пуста.")
else:
for r in rows:
print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}")
print("-" * 80)
print(f"{r[4]}\n")
print("=" * 80 + "\n")
def print_session_states():
"""Выводит текущие активные сессии и превью (session_states)."""
print("\n" + "=" * 80)
print("🔄 АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states):")
print("=" * 80)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT session_id, state_type, updated_at, pending_data FROM session_states")
rows = cursor.fetchall()
if not rows:
print("Таблица session_states пуста (нет активных превью).")
else:
for r in rows:
print(f"Session: {r[0]} | Type: {r[1]} | Updated: {r[2]}")
print("-" * 80)
print(f"Pending Data:\n{r[3]}\n")
print("=" * 80 + "\n")
HELP_TEXT = """
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
ДОСТУПНЫЕ КОМАНДЫ:
stats -- Общая статистика строк по всем таблицам БД
snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату)
scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel
absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
rules -- Посмотреть правила Базы Знаний ИИ из SQLite
prompts -- Посмотреть системные промпты (system_prompts)
tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry)
sessions -- Посмотреть активные сессии и превью (session_states)
dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день
ПРИМЕРЫ ЗАПУСКА:
python scripts/db_cli.py stats
python scripts/db_cli.py snapshots 06.08.2026
python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг
python scripts/db_cli.py scud --snapshot Y20260805-007
python scripts/db_cli.py absences 07.08.2026
python scripts/db_cli.py prompts
python scripts/db_cli.py tools
python scripts/db_cli.py sessions
python scripts/db_cli.py snapshot del Y20260805-007
python scripts/db_cli.py snapshot del --day 04.08.2026
python scripts/db_cli.py dump my_dump.xlsx
"""
def main():
if any(arg in sys.argv for arg in ['-h', '--help']):
print(HELP_TEXT)
sys.exit(0)
parser = argparse.ArgumentParser(
description=HELP_TEXT,
formatter_class=argparse.RawDescriptionHelpFormatter,
add_help=False
)
parser.add_argument('command', nargs='?', default=None, choices=['stats', 'snapshots', 'scud', 'absences', 'anomalies', 'rules', 'prompts', 'sessions', 'dump', 'snapshot', 'tools'], help="Основная команда")
parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')")
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)")
parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл")
parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
if len(sys.argv) == 1:
print_stats()
return
args = parser.parse_args()
if args.command == 'stats':
print_stats()
elif args.command == 'snapshots':
date_val = args.param or args.action
print_snapshots_list(date_str=date_val)
elif args.command == 'scud':
inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx)
elif args.command == 'absences':
date_val = args.param or args.action
print_absences(date_str=date_val)
elif args.command == 'anomalies':
print_anomalies()
elif args.command == 'rules':
print_rules()
elif args.command == 'prompts':
print_system_prompts()
elif args.command == 'sessions':
print_session_states()
elif args.command == 'tools':
print_tool_actions()
elif args.command == 'dump':
filename = args.param if args.param else "db_dump_full.xlsx"
dump_all_to_excel(filename)
elif args.command == 'snapshot':
if args.action == 'del':
if args.day:
delete_snapshots_by_date(args.day)
elif args.param:
delete_snapshot_by_id(args.param)
else:
print("\n[❌] Ошибка: Не указан ID снапшота или параметр --day для удаления.")
print("Пример: python scripts/db_cli.py snapshot del Y20260805-007\n")
else:
print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.")
print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n")
else:
print(f"\n[❌] Ошибка: Неизвестная команда.")
print(HELP_TEXT)
if __name__ == "__main__":
main()
File: ./scripts/fix_snapshots.py
import sqlite3
import os
from datetime import datetime
DB_PATH = 'data/scud_orion_ai.db'
def parse_to_date(date_str):
"""Надежное преобразование даты логов и времени снапшота в объект date."""
if not date_str:
return None
date_str = str(date_str).split(" ")[0].strip()
if "." in date_str:
try:
return datetime.strptime(date_str, "%d.%m.%Y").date()
except ValueError:
return None
elif "-" in date_str:
try:
return datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
return None
return None
def fix_snapshots():
"""
Переиндексирует снапшоты в таблице scud_logs.
Нумерация веников (001, 002, 003...) сквозная внутри каждого ДНЯ СОЗДАНИЯ.
Префикс Y выставляется только если дата логов < даты создания.
"""
if not os.path.exists(DB_PATH):
print(f"База данных {DB_PATH} не найдена.")
return
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Группируем по уникальным выгрузкам
cursor.execute("""
SELECT snapshot_time, log_date
FROM scud_logs
WHERE snapshot_time IS NOT NULL
GROUP BY snapshot_time, log_date
ORDER BY snapshot_time ASC, log_date ASC
""")
rows = cursor.fetchall()
if not rows:
print("Снапшоты не найдены.")
conn.close()
return
print("=== ИСПРАВЛЕНИЕ СКВОЗНОЙ НУМЕРАЦИИ ВНУТРИ ДНЯ СОЗДАНИЯ ===")
# Счетчик текущего порядкового номера strictly за ДЕНЬ СОЗДАНИЯ (YYYYMMDD)
seq_counters = {}
for snap_time_raw, log_date_raw in rows:
d_snap = parse_to_date(snap_time_raw)
d_log = parse_to_date(log_date_raw)
if not d_snap or not d_log:
continue
date_prefix = d_snap.strftime("%Y%m%d")
# Если дата логов строго раньше даты создания снапшота — ставим Y
is_yesterday = (d_log < d_snap)
prefix = "Y" if is_yesterday else ""
# Приращиваем сквозной счетчик за ЭТОТ ДЕНЬ СОЗДАНИЯ
seq_counters[date_prefix] = seq_counters.get(date_prefix, 0) + 1
seq_num = seq_counters[date_prefix]
new_id = f"{prefix}{date_prefix}-{seq_num:03d}"
cursor.execute(
"UPDATE scud_logs SET snapshot_id = ? WHERE snapshot_time = ? AND log_date = ?",
(new_id, snap_time_raw, log_date_raw)
)
print(f" • Срез создания: {snap_time_raw} | Логи за: {log_date_raw} ===> Назначен ID: [{new_id}]")
conn.commit()
conn.close()
print("\n[✓] Переиндексация внутри дней создания успешно выполнена!")
if __name__ == "__main__":
fix_snapshots()
File: ./scripts/init_tool_registry.py
"""
Скрипт инициализации таблицы tool_action_registry в SQLite БД.
"""
import os
import sys
import json
import sqlite3
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db")
def init_tool_action_registry():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 1. Создаем таблицу
cursor.execute("""
CREATE TABLE IF NOT EXISTS tool_action_registry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool_name TEXT UNIQUE NOT NULL,
category TEXT DEFAULT 'general',
bypass_llm INTEGER DEFAULT 1,
success_template TEXT NOT NULL,
follow_up_question TEXT,
action_type TEXT DEFAULT 'BUTTONS',
buttons_json TEXT,
is_active INTEGER DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# 2. Начальное наполнение базовыми сценариями
seed_tools = [
(
"db_confirm_prompt_preview",
"prompt",
1,
"✅ Системный промпт успешно сохранен и применен в базе данных.",
"Желаете продолжить работу с системным промптом?",
"FOLLOW_UP_ACTION",
json.dumps([
{"label": "Показать промпт", "value": "покажи системный промпт"},
{"label": "Готово", "value": "нет, спасибо"}
], ensure_ascii=False)
),
(
"db_cancel_prompt_preview",
"prompt",
1,
"❌ Изменения системного промпта отменены.",
"Желаете продолжить работу с системным промптом?",
"FOLLOW_UP_ACTION",
json.dumps([
{"label": "Показать промпт", "value": "покажи системный промпт"},
{"label": "Готово", "value": "нет, спасибо"}
], ensure_ascii=False)
),
(
"db_delete_task",
"tasks",
1,
"🗑 Задача успешно удалена из вашего реестра.",
"Хотите посмотреть обновленный список задач?",
"FOLLOW_UP_ACTION",
json.dumps([
{"label": "Показать задачи", "value": "покажи список задач"},
{"label": "Готово", "value": "нет"}
], ensure_ascii=False)
),
(
"db_delete_snapshots",
"snapshots",
1,
"🗑 Выбранные снапшоты успешно удалены из базы данных.",
"Вывести актуальный список снапшотов?",
"FOLLOW_UP_ACTION",
json.dumps([
{"label": "Показать снапшоты", "value": "покажи список снапшотов"},
{"label": "Готово", "value": "нет"}
], ensure_ascii=False)
)
]
for item in seed_tools:
cursor.execute("""
INSERT INTO tool_action_registry (
tool_name, category, bypass_llm, success_template, follow_up_question, action_type, buttons_json
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tool_name) DO UPDATE SET
category = excluded.category,
bypass_llm = excluded.bypass_llm,
success_template = excluded.success_template,
follow_up_question = excluded.follow_up_question,
action_type = excluded.action_type,
buttons_json = excluded.buttons_json,
updated_at = CURRENT_TIMESTAMP
""", item)
conn.commit()
conn.close()
print("[✓] Таблица tool_action_registry успешно создана и наполнена!")
if __name__ == "__main__":
init_tool_action_registry()
File: ./scripts/diagnostics/inspect_db.py
import os
import sqlite3
# Автопоиск файла базы данных в проекте
db_path = 'data/scud_orion_ai.db' if os.path.exists('data/scud_orion_ai.db') else 'scud_orion_ai.db'
print("=" * 80)
print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
print("=" * 80)
if not os.path.exists(db_path):
print(f"❌ Файл базы данных {db_path} не найден!")
exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 1. Список всех таблиц и колонок
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
tables = [t[0] for t in cursor.fetchall()]
print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
print("-" * 80)
for t_name in tables:
cursor.execute(f"PRAGMA table_info({t_name})")
cols = [c[1] for c in cursor.fetchall()]
cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
count = cursor.fetchone()[0]
print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
# 2. Просмотр правил Базы Знаний
if 'ai_knowledge_base' in tables:
print("\n" + "=" * 80)
print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
print("=" * 80)
cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
rules = cursor.fetchall()
if not rules:
print("Таблица ai_knowledge_base пуста.")
else:
for r_id, r_text, r_author in rules:
print(f" {r_id}. [{r_author}] {r_text}\n")
conn.close()
print("=" * 80)
File: ./scripts/diagnostics/inspect_ephemeral.py
import os
import sqlite3
DB_PATH = os.path.join(os.path.dirname(__file__), "../../data/scud_orion_ai.db")
if not os.path.exists(DB_PATH):
DB_PATH = "data/scud_orion_ai.db"
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
print("=" * 90)
print(f"🔍 ИНСПЕКЦИЯ СООБЩЕНИЙ В БАЗЕ: {DB_PATH}")
print("=" * 90)
# 1. Проверяем наличие колонки is_ephemeral
cursor.execute("PRAGMA table_info(chat_messages);")
cols = [c[1] for c in cursor.fetchall()]
if "is_ephemeral" not in cols:
print("❌ Колонка is_ephemeral отсутствует в таблице chat_messages!")
conn.close()
exit(1)
# 2. Считаем общее количество и распределение
cursor.execute("SELECT COUNT(*) FROM chat_messages")
total_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 1")
ephemeral_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 0")
permanent_count = cursor.fetchone()[0]
print(f"Всего записей в истории: {total_count}")
print(f" • Постоянные (is_ephemeral = 0): {permanent_count}")
print(f" • Фантомные / Эфемерные (is_ephemeral = 1): {ephemeral_count}")
print("-" * 90)
# 3. Выводим все фантомные записи
if ephemeral_count == 0:
print("В базе нет фантомных записей (is_ephemeral = 1 пуст).")
else:
print(f"📋 СПИСОК ВСЕХ ФАНТОМНЫХ СООБЩЕНИЙ ({ephemeral_count} шт.):")
cursor.execute("""
SELECT id, session_id, role, created_at, content
FROM chat_messages
WHERE is_ephemeral = 1
ORDER BY id ASC
""")
for row in cursor.fetchall():
msg_id, session_id, role, created_at, content = row
print(f"\n[ID: {msg_id}] | Сессия: {session_id} | Роль: {role} | Время: {created_at}")
print("Содержимое:")
print(content.strip())
print("-" * 70)
conn.close()
print("=" * 90)
File: ./scripts/diagnostics/inspect_files.py
import os
print("=" * 80)
print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_ai)")
print("=" * 80)
total_files = 0
total_size = 0
for root, dirs, files in os.walk('.'):
# Исключаем служебные каталоги
dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
for f in files:
p = os.path.join(root, f)
size = os.path.getsize(p)
total_files += 1
total_size += size
print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
print("-" * 80)
print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
print("=" * 80)
File: ./scripts/diagnostics/make_code_snapshot.py
import os
OUTPUT_SNAPSHOT = "project_code_snapshot.md"
# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css', '.sql'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'}
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2\n\n")
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
filepath = os.path.join(root, file)
out.write(f"## File: `{filepath}`\n")
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
except Exception as e:
out.write(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\n")
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
File: ./scripts/diagnostics/show_tree.py
import os
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
def print_tree(startpath):
print("=" * 60)
print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА")
print("=" * 60)
for root, dirs, files in os.walk(startpath):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
level = root.replace(startpath, '').count(os.sep)
indent = ' ' * 4 * (level)
print(f'{indent}📁 {os.path.basename(root)}/')
subindent = ' ' * 4 * (level + 1)
for f in sorted(files):
if not f.endswith('.pyc'):
print(f'{subindent}📄 {f}')
print("=" * 60)
if __name__ == "__main__":
print_tree('.')
File: ./services/__init__.py
File: ./services/ai_verifier.py
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(unrecognized_scud_fios, 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 без markdown-разметки."
)
mapping = {}
try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
if match:
json_str = match.group(0)
json_str = re.sub(r'\}\s*[^}\]]*$', '}', 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:
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
except Exception as e:
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {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 []
File: ./services/data_loader.py
import os
import pandas as pd
import warnings
from config import (
normalize_fio, clean_scud_fio_light, load_exceptions,
DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
)
# Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite
from services.zup_extractor import fetch_zup_absences_from_sql
from core.database import (
load_scud_from_db_by_snapshot,
load_staff_from_db,
load_absences_from_db
)
warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
def is_excluded(fio, dept, pos, exceptions):
"""Проверяет, входит ли сотрудник или должность в список исключений."""
fio_clean = normalize_fio(fio)
dept_str = str(dept).strip().upper() if pd.notna(dept) else ""
pos_str = str(pos).strip().lower() if pd.notna(pos) else ""
if fio_clean in exceptions["fio"] or dept_str in exceptions["departments"] or pos_str in exceptions["positions"]:
return True
if any(keyword in pos_str for keyword in exceptions["position_keywords"]):
return True
return False
def load_staff_data(date_str):
"""
Загружает файл Штатного расписания 1С за конкретную дату из папки 1c.
"""
filepath = find_dated_file("Штат", date_str)
if not filepath:
fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx")
if os.path.exists(fallback_path):
filepath = fallback_path
else:
return None
try:
df_raw = pd.read_excel(filepath, skiprows=8)
# Индексы столбцов в файле 1С: 1 — ФИО, 5 — Подразделение, 12 — Должность
df_staff = df_raw.iloc[:, [1, 5, 12]].copy()
df_staff.columns = ['ФИО', 'Подразделение', 'Должность']
df_staff = df_staff.dropna(subset=['ФИО']).reset_index(drop=True)
df_staff = df_staff[~df_staff['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО', case=False, na=False)]
df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio)
return df_staff
except Exception as e:
print(f"[❌] Ошибка загрузки штата из {filepath}: {e}")
return None
def load_absent_data(date_str):
"""
Загружает документально подтвержденные отсутствия сотрудников
НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату,
а также автоматически обогащает их удаленщиками из static_reason_workers.csv.
"""
df_absent = None
try:
df_absent = fetch_zup_absences_from_sql(date_str)
if df_absent is not None and not df_absent.empty:
df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия'])
df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)]
df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio)
df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
else:
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
except Exception as e:
print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}")
df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
# ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv
try:
from config import DATA_DIR
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
if os.path.exists(static_path):
df_static = pd.read_csv(static_path, encoding='utf-8')
if 'fio' in df_static.columns and 'reason' in df_static.columns:
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
new_rows = []
for _, s_row in df_static.iterrows():
if s_row['fio_clean'] not in existing_fios:
new_rows.append({
'fio_clean': s_row['fio_clean'],
'Вид_отсутствия': s_row['reason']
})
if new_rows:
df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True)
except Exception as e:
print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}")
return df_absent if not df_absent.empty else None
def load_1c_data_smart(date_str, use_db=False):
"""
Универсальный загрузчик данных 1С (Штат и Отсутствия).
Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД
за целевую дату указанного снапшота.
"""
df_staff = None
df_absent = None
if use_db:
df_staff = load_staff_from_db(date_str)
df_absent = load_absences_from_db(date_str)
# Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL
if df_staff is None:
df_staff = load_staff_data(date_str)
if df_absent is None:
df_absent = load_absent_data(date_str)
return df_staff, df_absent
def process_scud_anomalies(df_scud, exceptions=None):
"""
Вычисляет 'Первую активность' и маркирует аномалии СКУД.
"""
if exceptions is None:
exceptions = load_exceptions()
start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None)
end_col = 'Конец_дня' if 'Конец_дня' in df_scud.columns else ('Конец дня' if 'Конец дня' in df_scud.columns else None)
status_col = 'Статус' if 'Статус' in df_scud.columns else None
first_activities = []
anomaly_flags = []
is_present_flags = []
for _, row in df_scud.iterrows():
fio = str(row.get('Сотрудник', row.get('fio_raw', ''))).strip()
fio_clean = normalize_fio(fio)
dept = str(row.get('Подразделение', '')).strip()
pos = str(row.get('Должность', '')).strip()
val_start = str(row.get(start_col, '')).strip() if start_col else ''
val_end = str(row.get(end_col, '')).strip() if end_col else ''
val_status = str(row.get(status_col, '')).strip().lower() if status_col else ''
raw_first_event = row.get('raw_first_event', None)
if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']:
raw_first_event = None
has_no_in = (val_start in ['нет входа', 'none', 'nan', '', '00:00:00', '00:00'])
has_no_out = (val_end in ['нет выхода', 'none', 'nan', '', '00:00:00', '00:00'])
first_act = '—'
anom_flag = 'NONE'
is_present = not ('отсутствовал' in val_status or has_no_in)
if has_no_in:
if raw_first_event is not None or not has_no_out:
first_act = str(raw_first_event) if raw_first_event else val_end
anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY'
if not is_excluded(fio, dept, pos, exceptions):
is_present = False
else:
first_act = '—'
is_present = False
first_activities.append(first_act)
anomaly_flags.append(anom_flag)
is_present_flags.append(is_present)
df_scud['Первая_активность'] = first_activities
df_scud['anomaly_flag'] = anomaly_flags
df_scud['Пришел'] = is_present_flags
return df_scud
def load_scud_data(target_date_type="today", snapshot_param=None):
"""
Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite.
"""
target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY
df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param)
if df_scud is None or df_scud.empty:
print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.")
return pd.DataFrame()
exceptions = load_exceptions()
if 'fio_clean' not in df_scud.columns:
fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0])
df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str)
df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light)
if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns:
df_scud['Начало_дня'] = df_scud['time_in']
if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns:
df_scud['Конец_дня'] = df_scud['time_out']
if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns:
df_scud['Первая_активность'] = df_scud['first_activity']
if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns:
df_scud['Пришел'] = df_scud['is_present'].astype(bool)
df_scud['is_excluded'] = df_scud.apply(
lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions),
axis=1
)
return df_scud
def load_all_data():
"""
Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С.
"""
df_scud_today = load_scud_data(target_date_type="today")
try:
df_scud_yesterday = load_scud_data(target_date_type="yesterday")
except FileNotFoundError:
df_scud_yesterday = df_scud_today.copy()
df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY)
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY)
return (
df_scud_today,
df_scud_yesterday,
df_staff_today,
df_absent_today,
df_staff_yesterday,
df_absent_yesterday
)
File: ./services/data_validator.py
import os
import time
from datetime import datetime
from config import DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR
def check_file_freshness(max_age_hours=24):
"""
Проверяет существование входных файлов 1С и СКУД.
Возвращает флаги доступности файлов за Сегодня и Вчера.
"""
warnings = []
errors = []
# 1. Проверка файлов 1С за СЕГОДНЯ
staff_today = find_dated_file("Штат", DATE_TODAY)
absent_today = find_dated_file("Отсутствия", DATE_TODAY)
has_today_1c = bool(staff_today and absent_today)
if not has_today_1c:
warnings.append(
f"Файлы 1С за СЕГОДНЯ ({DATE_TODAY}) еще не выложены на сетевую шару. "
f"Формирование Ежедневной сводки за сегодня будет временно пропущено."
)
# 2. Проверка файлов 1С за ВЧЕРА (критически важны для детального отчета)
staff_yesterday = find_dated_file("Штат", DATE_YESTERDAY)
absent_yesterday = find_dated_file("Отсутствия", DATE_YESTERDAY)
if not staff_yesterday or not absent_yesterday:
errors.append(
f"Отсутствуют файлы 1С за ВЧЕРА ({DATE_YESTERDAY}) в папке {ZUP_1C_DIR}. "
f"Невозможно построить детальный отчет за прошлую смену."
)
# 3. Проверка файлов СКУД
scud_today = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR])
if not scud_today:
warnings.append(f"Сегодняшний файл СКУД за {DATE_TODAY} еще не сформирован в {SCUD_DIR}")
is_valid = len(errors) == 0
return is_valid, warnings, errors, has_today_1c
File: ./services/excel_exporter.py
import math
import os
import time
import openpyxl
import pandas as pd
from datetime import datetime
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from config import OUTPUT_DIR
# Словарь месяцев для текстового формата в названиях файлов
MONTHS_RU = {
1: "января", 2: "февраля", 3: "марта", 4: "апреля",
5: "мая", 6: "июня", 7: "июля", 8: "августа",
9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
}
def format_date_ru(date_str):
"""Преобразует дату формата '27.07.2026' в '27 июля 2026'"""
try:
dt = datetime.strptime(date_str, "%d.%m.%Y")
return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}"
except Exception:
return date_str
# Границы ячеек
THIN_SIDE = Side(border_style="thin", color="D3D3D3")
THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE)
# Палитра заливки для Сводки
FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", 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") # Мягкий пастельно-голубой для "Нет пропуска"
CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "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_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
"""Рассчитывает точное отклонение от нормы с учетом обеда 30 мин и уважительных причин"""
if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "":
return "0:00"
if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']:
return f"-{norm_hours}:00"
try:
parts = time_in_building_str.strip().split(':')
hh = int(parts[0])
mm = int(parts[1]) if len(parts) > 1 else 0
total_in_building_minutes = hh * 60 + mm
if total_in_building_minutes == 0:
return f"-{norm_hours}:00"
work_minutes = max(0, total_in_building_minutes - lunch_minutes)
norm_minutes = norm_hours * 60
diff = work_minutes - norm_minutes
if diff == 0:
return "0:00"
sign = "-" if diff < 0 else ""
abs_diff = abs(diff)
res_hh = abs_diff // 60
res_mm = abs_diff % 60
return f"{sign}{res_hh}:{res_mm:02d}"
except Exception:
return f"-{norm_hours}:00"
def apply_borders_to_cell(cell, border=THIN_BORDER):
cell.border = border
def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False):
cell_a = ws.cell(row=r_num, column=1)
cell_b = ws.cell(row=r_num, column=2)
if fill_obj:
cell_a.fill = fill_obj
cell_b.fill = fill_obj
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
if is_bold and bold_font:
cell_a.font = bold_font
cell_b.font = bold_font
if align_b:
cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b)
# --- 1. СВОДКА НА СЕГОДНЯ ---
def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None):
if not filename:
filename = f"{format_date_ru(date_str)} сводка.xlsx"
output_path = os.path.join(OUTPUT_DIR, filename)
wb = Workbook()
ws = wb.active
ws.title = "Лист_1"
ws.sheet_properties.outlinePr.summaryBelow = False
ws.sheet_properties.outlinePr.summaryRight = False
ws.sheet_properties.outlinePr.showOutlineSymbols = True
ws.sheet_view.showOutlineSymbols = True
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)
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)
current_row = 4
# Подготовка флагов для точной фильтрации
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() == '')) &
(~is_no_pass) &
(~is_exc)
]
ws.cell(row=current_row, column=1, value="неизвестно")
ws.cell(row=current_row, column=2, value=len(unexplained))
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font)
current_row += 1
for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
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)
current_row += 1
if not no_pass_df.empty:
for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = False
current_row += 1
# 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True)
absent_only = merged_df[
(merged_df['Пришел'] == False) &
(merged_df['Вид_отсутствия'].notna()) &
(~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
]
absent_groups = absent_only.groupby('Вид_отсутствия')
for idx_cat, (cat_name, group) in enumerate(absent_groups):
hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)]
cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid")
ws.cell(row=current_row, column=1, value=cat_name)
ws.cell(row=current_row, column=2, value=len(group))
format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font)
current_row += 1
for fio in sorted(group['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, cat_fill, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
# 6. ИТОГО НА РАБОТЕ (Свернут hidden=True)
is_working_mask = (merged_df['Пришел'] == True) | (
merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False)
)
present = merged_df[is_working_mask]
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)
current_row += 1
if not present.empty:
for fio in sorted(present['Сотрудник'].dropna().unique()):
ws.cell(row=current_row, column=1, value=fio)
format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False)
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
# 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ)
anomalies = merged_df[
(~is_exc) & (
((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) |
(merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
)
]
ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С")
ws.cell(row=current_row, column=2, value=len(anomalies))
format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font)
current_row += 1
chars_per_line_b = 30
if not anomalies.empty:
for _, row in anomalies.iterrows():
fio = row.get('Сотрудник', '')
reason = row.get('Вид_отсутствия', '')
anom_flag = row.get('anomaly_flag', 'NONE')
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
first_act = row.get('Первая_активность', '—')
reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})"
else:
reason_text = f"В 1С: {reason}"
cell_a = ws.cell(row=current_row, column=1, value=f"{fio}")
cell_b = ws.cell(row=current_row, column=2, value=reason_text)
cell_a.fill = FILL_ANOMALY
cell_b.fill = FILL_ANOMALY
apply_borders_to_cell(cell_a)
apply_borders_to_cell(cell_b)
cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
cell_a.alignment = Alignment(horizontal="left", vertical="center")
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
ws.row_dimensions[current_row].outlineLevel = 1
ws.row_dimensions[current_row].hidden = True
current_row += 1
ws.column_dimensions['A'].width = 45.0
ws.column_dimensions['B'].width = 38.0
try:
wb.save(output_path)
print(f"[✓] Ежедневная сводка сохранена: {output_path}")
except PermissionError:
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА ---
def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None):
if not filename:
filename = f"{format_date_ru(date_str)} отчет.xlsx"
output_path = os.path.join(OUTPUT_DIR, filename)
wb = Workbook()
ws = wb.active
ws.title = "Детальный_отчет"
ws["B2"] = "Дата:"
ws["D2"] = date_str
ws["B2"].font = Font(name="Arial", size=10, bold=True)
ws["D2"].font = Font(name="Arial", size=10, bold=True)
headers = [
"№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
"находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
]
ws.append([])
ws.append(headers)
header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=4, column=col_idx)
cell.fill = header_fill
cell.font = Font(name="Arial", size=10, bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
apply_borders_to_cell(cell)
start_col = 'Начало дня' if 'Начало дня' in merged_df.columns else 'Начало_дня'
end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня'
hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании'
chars_per_line_h = 24
for idx, row in merged_df.reset_index(drop=True).iterrows():
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_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']
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, 'Нет входа'),
first_act_val,
row.get(end_col, 'Нет выхода'),
in_building_str,
absence_reason if has_reason else '',
8,
deviation_val
])
row_num = 5 + idx
# ЗАЛИВКА СТРОК:
if is_present and has_reason:
row_fill = GREEN_FILL
elif not is_present and has_reason:
row_fill = YELLOW_FILL
elif not is_present and not has_reason and not has_first_act:
# Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы)
row_fill = LIGHT_RED_FILL
else:
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
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]:
cell.alignment = Alignment(horizontal="center", vertical="center")
else:
cell.alignment = Alignment(horizontal="left", vertical="center")
# Динамический компактный автоподгон ширины колонок
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
try:
wb.save(output_path)
print(f"[✓] Детальный отчет сохранен: {output_path}")
except PermissionError:
alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
alt_path = os.path.join(OUTPUT_DIR, alt_filename)
wb.save(alt_path)
print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
output_path = os.path.join(OUTPUT_DIR, filename)
df_scud.to_excel(output_path, index=False)
File: ./services/feedback_loop.py
import os
import json
from core.database import add_rule_to_db
try:
from services.knowledge_base import save_rule
except ImportError:
try:
from services.knowledge_base import add_rule as save_rule
except ImportError:
save_rule = None
def review_ai_decisions(report_text, suspicious_cases):
"""
Интерактивный консольный модуль обучения (Human-in-the-Loop).
Запрашивает подтверждение оператора ТОЛЬКО по нетипичным/сложным случаям.
"""
if not suspicious_cases:
print("\n[✓] Все аномалии валидированы автоматически. Спорных вопросов для оператора нет.")
return
print("\n" + "=" * 60)
print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)")
print("=" * 60)
for idx, case in enumerate(suspicious_cases, 1):
fio_target = case.get('fio_target', 'Неизвестно')
reason = case.get('reason', 'Нет описания')
print(f"\n[?] Спорный случай {idx}/{len(suspicious_cases)}: {fio_target}")
print(f" Детали: {reason}")
user_input = input("Подтверждаете аномалию? (д / да / н / нет / или введите текст правила): ").strip()
user_input_clean = user_input.lower()
# Расширенный список вариантов "ДА"
if user_input_clean in ['д', 'да', 'da', 'yes', 'y', 'ок', 'ага', '+']:
print(" [✓] Решение подтверждено оператором.")
elif user_input_clean in ['н', 'нет', 'no', 'n', '-']:
print(" [ℹ️] Решение отклонено оператором.")
elif user_input:
# Если оператор обучает систему синониму отделов
if "отдел" in user_input_clean or "овк" in user_input_clean:
from core.database import add_department_synonym_to_db
add_department_synonym_to_db(user_input.strip(), "отдел внутреннего контроля")
new_rule = f"Правило: {user_input}"
add_rule_to_db(new_rule, added_by="Human_Admin")
print(f" [✓] Новое правило записано в Базу Знаний SQLite: {new_rule}")
else:
print(" [ℹ️] Запись пропущена.")
print("\n[✓] ЭТАП ОБУЧЕНИЯ ЗАВЕРШЕН.")
File: ./services/knowledge_base.py
import os
from core.database import get_all_rules_from_db, add_rule_to_db
def load_knowledge_base():
"""
Загружает актуальные правила Базы Знаний компании напрямую из базы данных SQLite.
"""
rules = get_all_rules_from_db()
return {
"rules": rules if rules else [],
"fio_corrections": {}
}
def add_rule_to_kb(new_rule):
"""
Добавляет новое принятое человеком правило в SQLite таблицу ai_knowledge_base.
"""
if not new_rule or not new_rule.strip():
return
add_rule_to_db(new_rule.strip(), added_by="Human")
print(f"[✓] База знаний SQLite успешно обновлена! Новое правило: {new_rule.strip()}")
File: ./services/scud_export.py
"""
Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel.
Добавлена фиксация Первой Активности (без учета направления) и среза по времени.
"""
import argparse
import logging
import os
import sys
import warnings
from datetime import datetime, timedelta
import pandas as pd
import pyodbc
from openpyxl.utils import get_column_letter
from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot
warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable")
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs")
os.makedirs(LOG_DIR, exist_ok=True)
TODAY_DATE_STR = datetime.now().strftime("%d.%m.%Y")
LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log")
logger = logging.getLogger("scud_export")
logger.setLevel(logging.INFO)
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"):
level_map = {
"INFO": logging.INFO,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"SUCCESS": logging.INFO,
}
if level == "SUCCESS":
logger.info(f"[SUCCESS] {message}")
else:
logger.log(level_map.get(level, logging.INFO), message)
SERVER_NAME = r"172.16.31.221\SQL"
DATABASE_NAME = "Orion-14.01.21-1"
SQL_USER = "sa"
SQL_PASSWORD = "123456"
ODBC_DRIVER = "ODBC Driver 18 for SQL Server"
SQL_QUERY_TEMPLATE = r"""
DECLARE @InputDate DATE = '{target_date}';
DECLARE @TargetDate DATE = @InputDate;
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
WITH DailyLogs AS (
SELECT
log.HozOrgan AS EmployeeID,
log.TimeVal,
log.Event,
log.Mode,
CASE
WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT'
WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN'
ELSE 'OTHER'
END AS Direction,
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
FROM pLogData log WITH (NOLOCK)
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
AND log.HozOrgan IS NOT NULL
AND log.HozOrgan > 0
AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
),
Passages AS (
SELECT
EmployeeID,
MIN(TimeVal) AS FirstRawEvent,
MAX(TimeVal) AS LastRawEvent,
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
FROM DailyLogs
GROUP BY EmployeeID
)
SELECT
N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
LTRIM(RTRIM(
ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
)) AS [Сотрудник],
ISNULL(CAST(post.Name AS NVARCHAR(255)), N'—') AS [Должность],
ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№],
CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата],
ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня],
CASE
WHEN pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL
THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20))
ELSE N'—'
END AS [Первая_активность],
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn)
THEN N'Нет выхода'
WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
ELSE N'Нет выхода'
END AS [Конец_дня],
CASE
WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN
RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
END) / 60 AS VARCHAR), 2) + ':' +
RIGHT('0' + CAST(DATEDIFF(MINUTE,
ISNULL(pass.FirstIn, pass.FirstRawEvent),
CASE
WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
END) % 60 AS VARCHAR), 2)
ELSE N'00:00'
END AS [Находился_в_здании],
CASE
WHEN pass.EmployeeID IS NOT NULL THEN N'Присутствовал'
ELSE N'Отсутствовал (Нет событий)'
END AS [Статус]
FROM pList p WITH (NOLOCK)
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
LEFT JOIN Passages pass ON p.ID = pass.EmployeeID
WHERE
ISNULL(p.StatusRecord, 0) = 0
AND p.DateTimeInArchive IS NULL
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
AND p.Name NOT LIKE N'бр.%'
AND p.Name NOT LIKE N'Гость%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
ORDER BY p.Name ASC;
"""
def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0):
from openpyxl import load_workbook
wb = load_workbook(file_path)
ws = wb[sheet_name]
for col_cells in ws.columns:
max_len = 0
col_letter = get_column_letter(col_cells[0].column)
for cell in col_cells:
if cell.value is not None:
cell_len = len(str(cell.value))
if cell_len > max_len:
max_len = cell_len
width = max(min_width, min(max_len + padding, max_width))
ws.column_dimensions[col_letter].width = width
wb.save(file_path)
def get_targets(input_date: str | None):
targets = []
if input_date:
try:
parsed = datetime.strptime(input_date, "%d.%m.%Y").date()
targets.append({"name": "Указанная дата", "date": parsed})
except ValueError:
log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR")
sys.exit(1)
else:
now = datetime.now()
yesterday = (now - timedelta(days=3 if now.weekday() == 0 else 1)).date()
today = now.date()
targets.append({"name": "Вчера", "date": yesterday})
targets.append({"name": "Сегодня", "date": today})
return targets
def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False):
if debug:
logger.setLevel(logging.DEBUG)
log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING")
log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
os.makedirs(SCUD_DIR, exist_ok=True)
targets = get_targets(input_date)
conn_str = (
f"DRIVER={{{ODBC_DRIVER}}};"
f"SERVER={SERVER_NAME};"
f"DATABASE={DATABASE_NAME};"
f"UID={SQL_USER};"
f"PWD={SQL_PASSWORD};"
f"TrustServerCertificate=yes;"
f"Encrypt=no;"
)
success = True
for target in targets:
processing_date = target["date"]
processing_date_str = processing_date.strftime("%d.%m.%Y")
period_label = target["name"]
is_yesterday = (period_label == "Вчера")
# ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE:
# Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL
if is_yesterday and has_yesterday_final_snapshot(processing_date_str):
log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.")
continue
if is_yesterday:
snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00"
else:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Снапшот: {snapshot_time}]")
sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d"))
connection = None
try:
connection = pyodbc.connect(conn_str, timeout=120)
df = pd.read_sql(sql_query, connection)
log(f"База вернула {len(df)} строк за {processing_date_str}")
if len(df) > 0:
df['fio_clean'] = df['Сотрудник'].apply(clean_scud_fio_light)
df['anomaly_flag'] = 'NONE'
mask_anomaly = (df['Начало_дня'] == 'Нет входа') & (df['Первая_активность'] != '—')
df.loc[mask_anomaly, 'anomaly_flag'] = 'ANOMALY_NO_IN_HAS_ACTIVITY'
df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly)
save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_yesterday)
log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS")
if save_xlsx:
file_name = f"Сотрудники_{processing_date_str}.xlsx"
file_path = os.path.join(SCUD_DIR, file_name)
if os.path.exists(file_path):
try:
os.remove(file_path)
except OSError as e:
log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR")
df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl")
auto_fit_columns(file_path, sheet_name="Отчет")
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")
success = False
finally:
if connection is not None:
connection.close()
log("=== Выгрузка СКУД завершена ===\n")
return success
if __name__ == "__main__":
help_text = """
Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel.
ПРИМЕРЫ ЗАПУСКА:
python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера
python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату
python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX
python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG)
"""
parser = argparse.ArgumentParser(
description=help_text,
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)")
parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL")
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/")
args = parser.parse_args()
run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug)
File: ./services/share_copier.py
import os
import shutil
from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR
def find_file_strictly_on_share(prefix, date_str):
"""
Ищет файл со строгой привязкой ТОЛЬКО к сетевой шаре SHARE_1C_DIR,
не обращаясь к локальным папкам.
"""
if not os.path.exists(SHARE_1C_DIR):
return None
date_dots = date_str
date_underscores = date_str.replace('.', '_')
try:
for f in os.listdir(SHARE_1C_DIR):
if f.endswith('.xlsx') or f.endswith('.csv'):
if f.lower().startswith(prefix.lower()):
if date_dots in f or date_underscores in f:
return os.path.join(SHARE_1C_DIR, f)
except Exception as e:
print(f" [⚠️] Ошибка чтения сетевой шары: {e}")
return None
def copy_1c_files_from_share():
r"""
Копирует свежие файлы 1С (Штат и Отсутствия) за Сегодня и Вчера
из сетевой шары \\storage\SCUD\Обмен\Штат в локальную папку data/1c/
"""
print(f"[0.5/5] Проверка и копирование файлов 1С с шары: {SHARE_1C_DIR}...")
if not os.path.exists(SHARE_1C_DIR):
print(f"[⚠️] Сетевой шар недоступен или путь не найден: {SHARE_1C_DIR}")
print(" Используем ранее сохраненные локальные файлы из data/1c/\n")
return False
dates_to_copy = [DATE_TODAY, DATE_YESTERDAY]
prefixes = ["Штат", "Отсутствия"]
copied_count = 0
os.makedirs(ZUP_1C_DIR, exist_ok=True)
for d_str in dates_to_copy:
for prefix in prefixes:
remote_file = find_file_strictly_on_share(prefix, d_str)
if remote_file and os.path.exists(remote_file):
filename = os.path.basename(remote_file)
local_target_path = os.path.join(ZUP_1C_DIR, filename)
try:
shutil.copy2(remote_file, local_target_path)
print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/")
copied_count += 1
except Exception as e:
print(f" [⚠️] Ошибка копирования {filename}: {e}")
else:
d_fmt = d_str.replace('.', '_')
print(f" [ℹ️] На сетевой шаре отсутствует {prefix} за {d_str} ({prefix}_{d_fmt}.xlsx)")
if copied_count > 0:
print(f"[✓] Успешно скопировано файлов с сетевой шары: {copied_count} шт.\n")
else:
print("[ℹ️] Новых файлов за указанные даты на сетевой шаре не обнаружено.\n")
return True
File: ./services/text_reporter.py
import json
import difflib
import re
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):
"""
Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ).
Сравнивает фамилию и полное имя с порогом сходства >= 0.75.
"""
if unexplained_df.empty or raw_absent_df is None or raw_absent_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}
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:
continue
surname = fio_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']
})
break
return matches
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"):
# 1. Загрузка Базы Знаний
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 "Специфических правил компании пока нет."
# 2. Предварительный расчет ключевых метрик (РЕШАЕТ NameError: total_staff)
total_staff = len(merged_df)
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
# 3. Точный расчет неточных совпадений через Python
fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
# 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД
unexplained_list = []
if not absent_unexplained.empty:
for idx, r in absent_unexplained.iterrows():
fio = r.get('Сотрудник', r.get('fio_clean', ''))
dept_scud = str(r.get('department_scud', '')).strip()
dept_1c = str(r.get('Подразделение', '')).strip()
dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c
if not dept:
dept = '—'
pos = r.get('Должность', '—')
unexplained_list.append(f"{fio} — {dept}, {pos}")
# 5. Сборка промпта для Ollama
prompt = f"""
Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
Сформируй итоговую сводку кадрового контроля на {date_str}.
📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ:
{custom_rules_str}
🚨 ВХОДНЫЕ МЕТРИКИ:
- Всего сотрудников: {total_staff}
- Работают (офис / удаленка / командировки): {present_cnt}
- Официально отсутствуют: {explained_cnt}
- Неизвестно (истинно неотмеченные): {unexplained_cnt}
- Выявлено аномалий/конфликтов реестров: {anomalies_cnt}
---
🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт):
{json.dumps(anomalies_list, ensure_ascii=False, indent=2)}
---
⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт):
{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)}
---
📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел):
{json.dumps(unexplained_list, ensure_ascii=False, indent=2)}
СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ:
1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено."
2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше.
3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций.
СТРОГИЙ ШАБЛОН ОТВЕТА:
**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}**
- Всего офисных сотрудников: **{total_staff}**
- Работают (офис / удаленка / командировки): **{present_cnt}**
- Официально отсутствуют: **{explained_cnt}**
- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел.
- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт.
#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
(Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.")
#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С:
(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.")
#### Неизвестные случаи: {unexplained_cnt}
(Выведи нумерованный список всех {unexplained_cnt} человек)
### Точечные рекомендации:
(2-3 системные рекомендации без указания ФИО сотрудников)
"""
sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке по предоставленному Markdown-шаблону. Категорически запрещено выводить JSON или китайские символы."
return ask_ollama(prompt, system_prompt=sys_prompt)
File: ./services/zup_extractor.py
import os
import logging
from datetime import date, datetime
import pyodbc
import pandas as pd
from config import DATA_DIR, normalize_fio, ZUP_SQL_CONFIG
logger = logging.getLogger(__name__)
def get_zup_connection_string() -> str:
"""Формирует строку подключения pyodbc к MS SQL Server из config.py."""
return (
f"DRIVER={ZUP_SQL_CONFIG['driver']};"
f"SERVER={ZUP_SQL_CONFIG['server']};"
f"DATABASE={ZUP_SQL_CONFIG['database']};"
f"UID={ZUP_SQL_CONFIG['user']};"
f"PWD={ZUP_SQL_CONFIG['password']};"
f"TrustServerCertificate={ZUP_SQL_CONFIG.get('trust_server_certificate', 'yes')};"
f"Encrypt={ZUP_SQL_CONFIG.get('encrypt', 'no')};"
)
def fetch_zup_absences_from_sql(target_date) -> pd.DataFrame:
"""
Извлекает оперативные отсутствия и действующие декреты из MS SQL 1С:ЗУП 3.1
на указанную дату (принимает как datetime.date, так и строку 'DD.MM.YYYY' / 'DD_MM_YYYY').
"""
if isinstance(target_date, str):
clean_date_str = target_date.replace('_', '.')
try:
target_date = datetime.strptime(clean_date_str, "%d.%m.%Y").date()
except ValueError:
logger.error(f"[❌] Неверный формат даты для SQL-запроса: {target_date}. Ожидался DD.MM.YYYY")
return pd.DataFrame()
query = """
DECLARE @TargetDate DATE = ?;
-- 1. Оперативные отсутствия (Отпуска, Командировки, Больничные, Отгулы)
SELECT
LTRIM(RTRIM(ref_emp._Description)) AS [ФИО],
CASE state._Fld16925RRef
WHEN 0x9C10B2452D414FDF4A90E2B2AB81D3F7 THEN N'Отпуск основной'
WHEN 0xBA63FCF94B4AD0664ED369D2E6505D67 THEN N'Командировка'
WHEN 0x8C3B61F23954155A40EB0108FC0932DB THEN N'Болезнь'
WHEN 0x853001C18D0965EE4B2702405C94054A THEN N'Отпуск неоплачиваемый по разрешению работодателя'
WHEN 0xB7335AEFD8708C3E462861FC59489A38 THEN N'Отпуск по беременности и родам'
ELSE N'Другое отсутствие'
END AS [Вид_отсутствия]
FROM dbo._InfoRg16921 state WITH (NOLOCK)
INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK)
ON state._Fld16922RRef = ref_emp._IDRRef
WHERE @TargetDate BETWEEN CAST(CASE WHEN YEAR(state._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16926) ELSE state._Fld16926 END AS DATE)
AND CAST(CASE WHEN YEAR(state._Fld16927) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16927) ELSE state._Fld16927 END AS DATE)
UNION ALL
-- 2. Динамический выбор ДЕЙСТВУЮЩИХ декретниц по уходу за ребенком
SELECT
active_state.fio AS [ФИО],
N'Отпуск по уходу за ребенком' AS [Вид_отсутствия]
FROM (
SELECT
LTRIM(RTRIM(ref_emp._Description)) AS fio,
all_states._Fld16925RRef AS state_guid,
CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) AS date_start,
ROW_NUMBER() OVER (
PARTITION BY all_states._Fld16922RRef
ORDER BY all_states._Fld16926 DESC
) AS rn
FROM dbo._InfoRg16921 all_states WITH (NOLOCK)
INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK)
ON all_states._Fld16922RRef = ref_emp._IDRRef
WHERE CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) <= @TargetDate
) active_state
WHERE active_state.rn = 1
AND active_state.state_guid = 0xA4FBA038663B3C2A48DA151C262855E1
AND active_state.date_start >= DATEADD(YEAR, -3, @TargetDate)
ORDER BY [ФИО] ASC;
"""
try:
conn_str = get_zup_connection_string()
with pyodbc.connect(conn_str, timeout=5) as conn:
df = pd.read_sql(query, conn, params=[target_date])
if not df.empty:
df['fio_clean'] = df['ФИО'].apply(normalize_fio)
return df
except Exception as e:
logger.error(f"[❌] Ошибка SQL-выгрузки отсутствий за {target_date}: {e}")
return pd.DataFrame()
def fetch_zup_staff_from_sql() -> pd.DataFrame:
"""Резервная выгрузка штата из MS SQL."""
query = """
SELECT DISTINCT
LTRIM(RTRIM(ref_emp._Description)) AS [ФИО],
N'Организация' AS [Подразделение],
N'Сотрудник' AS [Должность]
FROM dbo._Reference299 ref_emp WITH (NOLOCK)
WHERE ref_emp._Description <> ''
AND ref_emp._Marked = 0x00
ORDER BY [ФИО] ASC;
"""
try:
conn_str = get_zup_connection_string()
with pyodbc.connect(conn_str, timeout=5) as conn:
df = pd.read_sql(query, conn)
if not df.empty:
df['fio_clean'] = df['ФИО'].apply(normalize_fio)
return df
except Exception as e:
logger.error(f"[❌] Ошибка выгрузки штата из MS SQL: {e}")
return pd.DataFrame()
def sync_zup_to_excel(target_date) -> bool:
"""Создает дамп в Excel при необходимости."""
try:
if isinstance(target_date, str):
clean_date_str = target_date.replace('_', '.')
target_date_obj = datetime.strptime(clean_date_str, "%d.%m.%Y").date()
else:
target_date_obj = target_date
date_str_file = target_date_obj.strftime("%d_%m_%Y")
c_1c_dir = os.path.join(DATA_DIR, "1c")
os.makedirs(c_1c_dir, exist_ok=True)
df_staff = fetch_zup_staff_from_sql()
df_absences = fetch_zup_absences_from_sql(target_date_obj)
staff_excel_path = os.path.join(c_1c_dir, f"Штат_{date_str_file}.xlsx")
absences_excel_path = os.path.join(c_1c_dir, f"Отсутствия_{date_str_file}.xlsx")
with pd.ExcelWriter(staff_excel_path, engine='openpyxl') as writer:
dummy_headers = pd.DataFrame([[""] * 13] * 8)
dummy_headers.to_excel(writer, index=False, header=False)
df_staff[['ФИО', 'Подразделение', 'Должность']].to_excel(writer, startrow=8, index=False)
with pd.ExcelWriter(absences_excel_path, engine='openpyxl') as writer:
dummy_headers = pd.DataFrame([[""] * 3] * 3)
dummy_headers.to_excel(writer, index=False, header=False)
df_absences[['ФИО', 'Вид_отсутствия']].to_excel(writer, startrow=3, index=False)
return True
except Exception as e:
logger.error(f"[❌] Ошибка прямого импорта из MS SQL 1С: {e}")
return False
File: ./docs/PROJECT BRAIN_ SCUD Orion AI & Context API (Master Manifesto v5.0).md
# **PROJECT BRAIN: SCUD Orion AI & Context API (Master Technical & Architectural Manifesto)**
**Версия документа:** 5.0 (Ultimate Developer & LLM Context Engine)
**Назначение:** Исчерпывающий контекстный дамп проекта для немедленного продолжения разработки в любой передовой LLM без потери исторического контекста, с полным пониманием бизнес-логики, схем баз данных, подводных камней и нерешенных задач.
## ---
**1\. Архитектурный паспорт и инфраструктурный профиль**
| Компонент | Параметры и стек |
| :---- | :---- |
| **Среда и ОС** | Linux Docker Home (Debian/Ubuntu), Python 3.11/3.12 в виртуальном окружении venv, Git/Gitea. Разработка ведется через VS Code Remote-SSH. |
| **Локальная СУБД проекта** | SQLite (data/scud\_orion\_ai.db), режим PRAGMA journal\_mode \= WAL, PRAGMA synchronous \= NORMAL, PRAGMA foreign\_keys \= ON. |
| **СКУД Источник (MSSQL)** | MS SQL Server 172.16.31.221\\SQL (база Orion-14.01.21-1), драйвер ODBC Driver 18 for SQL Server (TrustServerCertificate=yes, Encrypt=no). Доступ через pyodbc. |
| **1С:ЗУП 3.1 Источник (MSSQL)** | MS SQL Server ACCOUNT-01 (база ZUP30, пользователь scud\_reader). Прямое чтение таблиц dbo.\_Reference299 (сотрудники) и dbo.\_InfoRg16921 (состояния отсутствий). Резервная Samba-шара: /mnt/scud\_share. |
| **Локальный контур LLM** | Ollama на http://192.168.11.3:11434 (GPU NVIDIA RTX 5070 Ti 16GB). Модель qwen2.5:14b (основной агент, Function Calling, анализ аномалий), qwen2.5vl:7b-q8\_0 (OCR документов/картинок). |
| **Web API & Фронтенд** | FastAPI (Uvicorn), JWT (HS256, 30 дней), Passlib (bcrypt). Фронтенд: Single Page Application (HTML5, Tailwind CSS, Vanilla JS) с Generative UI виджетами. |
## ---
**2\. Энциклопедия подводных камней, инцидентов и принятых инженерных решений**
### **2.1. Галлюцинации LLM и переход к парадигме Python-Driven Diff**
> * **Проблема:** Попытка передать списки в 300 ФИО напрямую в промпт модели с командой «сравни списки» приводила к потере контекста, выдуманным опечаткам (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*) и плейсхолдерам ('Васильев Л.И.', ...).
> * **Решение:** Утверждена строгая гибридная схема:
1. **Детерминированный слой (Python / Pandas / SQL):** читает файлы, нормализует строки, агрегирует совмещения и мгновенно находит 95%+ точных соответствий.
2. **Интеллектуальный слой (Qwen 2.5:14b):** вызывается *только на спорный несовпавший остаток* (1–5 записей) в виде строгого JSON-интерфейса Entity Resolution.
### **2.2. Символьная нормализация и кадровые совмещения (Кейс Балаевой О.Г.)**
> * **Проблема символов:** Латинские омоглифы (буквы клавиатуры a, c, e, o, p, x, y вместо русских), разделители 1С в скобках ((осн.), (совм.), (внешн.)), неразрывные пробелы \\xa0, буква ё и случайный ввод цифры 0 вместо буквы О.
> * **Решение (normalize\_fio):** Очистка скобок через регулярное выражение re.sub(r'\\(.\*?\\)', '', fio), замена латиницы через str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ'), замена ё-\>е и удаление лишних пробелов.
> * **Коллизия 270 vs 271:** В СКУД 270 физических лиц (один пропуск на человека), в 1С — 271 строка. Сотрудник **Балаева Ольга Геннадьевна** числится на двух ставках в разных отделах (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*).
> * **Решение:** Внедрена группировка датафрейма 1С по fio\_clean с агрегацией должностей и подразделений через разделитель " / ".
### **2.3. Защита от коллизий полных тёзок и ложных связок однофамильцев (Кейс Семеновых)**
> * **Инцидент:** Неограниченная строгими правилами LLM связала двух разных сотрудниц — *«Семенову Юлию Борисовну»* (работает) и *«Семенову Дину Дмитриевну»* (в отпуске), посчитав разные имена опечаткой из\-за одинаковой фамилии. В результате Юлии Борисовне ошибочно был приписан статус отпуска.
> * **Двухконтурный барьер:**
1. **В промпте:** Запрет объединения однофамильцев с разными именами/отчествами.
2. **В Python:** Проверка совпадения первых букв имени и отчества. При несовпадении сопоставление ИИ аннулируется с логированием ⚠️ \[Блокировка фейка\].
### **2.4. Вынесение исключений в exceptions.json и синонимы отделов**
> * Служебный персонал (отдел «ЭТО», уборщики производственных и служебных помещений, клинеры, дворники, гардеробщики) вынесен в exceptions.json.
> * Для сопоставления кратких аббревиатур СКУД и полных названий отделов 1С (*«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица department\_synonyms с автоматическим самообучением при работе человека (Human-in-the-Loop).
### **2.5. Разгадка феномена «исчезающих неизвестных» и аппаратный сбой СКУД 04.08.2026**
> * **Аномалия:** Утром 04.08.2026 на главном КПП зависли контроллеры входа. У 119 сотрудников не записалось утреннее событие (time\_in \= 'Нет входа'). Из них 57 человек были в отпусках/командировках по 1С. Оставшиеся 74 человека были на работе без утренней отметки. Однако вечерняя сводка показала всего 4–7 «неизвестных».
> * **Причина:** Скрипт агрегации видел дневные перемещения (обед, внутренние двери, туалет, вечерний выход) и автоматически проставлял Статус \= 'Присутствовал' и is\_present \= 1, несмотря на то, что колонка Начало\_дня оставалась 'Нет входа'. В итоге 70 человек тихо ушли в «Итого на работе», а системный сбой оборудования был замаскирован.
> * **Архитектурный фикс:**
1. Введена колонка **first\_activity («Первая активность»)** — вычисление MIN(TimeVal) AS FirstRawEvent по таблице pLogData без учета направления двери.
2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи ставится anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\.
3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): если доля таких аномалий превышает 5% смены (\>10 чел.), ИИ генерирует системный алерт о массовом сбое контроллеров КПП.
> * **Кейс Равина В.Э.:** Главный специалист официально находился в основном отпуске по 1С, утреннего входа на КПП не было, но зафиксирована активность в 14:11:06 и вечерний выход в 17:18:38. Система зарегистрировала истинную кадровую аномалию (физическое присутствие при официальном отпуске).
### **2.6. Система сквозных датированных снапшотов (срезов)**
> * **Формат ID:** \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного).
> * **Оптимизация:** Функция has\_yesterday\_final\_snapshot() проверяет наличие вчерашнего среза в SQLite: если он есть, повторный тяжелый запрос к MS SQL Орион пропускается.
> * **CLI-управление:** Поддержка запуска отчетов по любому историческому снапшоту через ключ \--snapshot \[ID\] и пропуск выгрузки через \--skip-export.
### **2.7. Web API, Generative UI и управление жизненным циклом сессий (Topic Drift & Ephemeral Buffer)**
> * **Проблема засорения контекста LLM:** Вывод объемных списков задач, системных промптов и таблиц срезов забивал окно диалога, вызывая галлюцинации и «залипание» модели на старых данных.
> * **Решение:**
* В chat\_messages введен флаг is\_ephemeral \= 1 для временных сообщений.
* Двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик idle\_turns.
* **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages().
* **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю») и выхода («нет, спасибо») без обращения к LLM (ответ за 0.05 сек).
* **Декларативный реестр (tool\_action\_registry):** Хранение шаблонов ответов, кнопок и настроек эфемерности в SQLite.
> * **Generative UI (Задачи):** Инструмент db\_get\_tasks возвращает TASK\_INTERACTIVE\_CARD, рендерящий на клиенте живой виджет задач с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API.
## ---
**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)**
| Таблица | Назначение и бизнес-логика | Структура полей |
| :---- | :---- | :---- |
| **scud\_logs** | Логи проходов СКУД, расчетное время в здании и снапшоты. | id INTEGER PK AUTOINCREMENT, log\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, time\_in TEXT, first\_activity TEXT DEFAULT '—', time\_out TEXT, time\_in\_building TEXT, is\_present INTEGER, anomaly\_flag TEXT DEFAULT 'NONE', snapshot\_time TEXT, snapshot\_id TEXT, created\_at TIMESTAMP |
| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id INTEGER PK AUTOINCREMENT, snapshot\_date TEXT, fio TEXT, fio\_clean TEXT, department TEXT, position TEXT, created\_at TIMESTAMP |
| **zup\_absences** | Документы отклонений 1С (отпуска, больничные, командировки, декреты). | id INTEGER PK AUTOINCREMENT, absence\_date TEXT, fio TEXT, fio\_clean TEXT, absence\_type TEXT, created\_at TIMESTAMP |
| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id INTEGER PK AUTOINCREMENT, anomaly\_date TEXT, fio TEXT, anomaly\_type TEXT, details TEXT, human\_status TEXT DEFAULT 'Pending', created\_at TIMESTAMP |
| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id INTEGER PK AUTOINCREMENT, rule\_text TEXT UNIQUE, added\_by TEXT DEFAULT 'Human', created\_at TIMESTAMP |
| **system\_prompts** | Версионные системные промпты агента (активный по is\_active=1). | id INTEGER PK AUTOINCREMENT, name TEXT, prompt\_text TEXT, is\_active INTEGER DEFAULT 1, updated\_at TIMESTAMP |
| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id INTEGER PK AUTOINCREMENT, tool\_name TEXT UNIQUE, category TEXT, bypass\_llm INTEGER, success\_template TEXT, follow\_up\_question TEXT, action\_type TEXT, buttons\_json TEXT, is\_active INTEGER, is\_ephemeral INTEGER, updated\_at TIMESTAMP |
| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id TEXT PRIMARY KEY, state\_type TEXT, pending\_data TEXT, updated\_at TIMESTAMP |
| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id INTEGER PK AUTOINCREMENT, session\_id TEXT, role TEXT, content TEXT, is\_ephemeral INTEGER DEFAULT 0, created\_at TIMESTAMP |
| **tasks** | Реестр задач операторов с привязкой к user\_id. | id INTEGER PK AUTOINCREMENT, task\_id TEXT, module TEXT, title TEXT, priority TEXT, status TEXT, due\_date TEXT, user\_id INTEGER, created\_at TIMESTAMP |
| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id INTEGER PK AUTOINCREMENT, username TEXT UNIQUE, password\_hash TEXT, is\_admin INTEGER, full\_name TEXT, created\_at TIMESTAMP |
| **department\_synonyms** | База соответствия аббревиатур СКУД полным названиям отделов 1С. | id INTEGER PK AUTOINCREMENT, short\_name TEXT UNIQUE, full\_name TEXT, created\_at TIMESTAMP |
| **system\_reference** | Справочник команд и примеров промптов для операторов. | id INTEGER PK AUTOINCREMENT, category TEXT, title TEXT, example\_prompt TEXT, description TEXT |
## ---
**4\. Архитектурная структура каталогов**
scud\_ai/
├── config.py \# Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL
├── exceptions.json \# Декларативные фильтры исключений (ОВК, уборщики, подрядчики)
├── main\_etl.py \# Оркестратор ETL: экспорт СКУД/1С \-\> SQLite \-\> Excel/MD отчеты
├── project\_code\_snapshot.md \# Полный монолитный исходный код всех файлов репозитория
├── core/
│ └── database.py \# Драйвер SQLite (WAL), функции сохранения/загрузки срезов
├── services/
│ ├── scud\_export.py \# Прямой SQL-экспорт из MS SQL «Орион Pro»
│ ├── zup\_extractor.py \# Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1
│ ├── data\_loader.py \# Загрузка данных, нормализация ФИО, расчет первой активности
│ ├── data\_validator.py \# Проверка свежести и наличия файлов за Вчера и Сегодня
│ ├── excel\_exporter.py \# Генерация сводки и детального отчета в Excel (openpyxl)
│ ├── text\_reporter.py \# Построение итогового Markdown-отчета через Qwen 2.5
│ ├── ai\_verifier.py \# Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД
│ ├── feedback\_loop.py \# Интерактивный консольный модуль Human-in-the-Loop
│ ├── knowledge\_base.py \# Интерфейс работы с базой знаний правил
│ └── share\_copier.py \# Резервное скачивание файлов 1С с шары /mnt/scud\_share
├── scripts/
│ ├── db\_cli.py \# CLI-утилита управления БД, снапшотами, дампами и сессиями
│ ├── fix\_snapshots.py \# Скрипт переиндексации и нормализации снапшотов
│ ├── init\_tool\_registry.py \# Инициализация декларативного реестра инструментов
│ └── diagnostics/
│ ├── inspect\_db.py \# Диагностика структуры таблиц и количества записей
│ ├── inspect\_ephemeral.py \# Анализ распределения постоянных и эфемерных сообщений
│ ├── inspect\_files.py \# Аудит файлов и размеров на диске
│ └── show\_tree.py \# Вывод дерева каталогов
├── modules/web\_api/
│ ├── main.py \# Точка входа FastAPI приложения, роутеры и раздача статики
│ ├── routers/ (admin, auth, chat, tasks) \# REST эндпоинты
│ ├── llm/
│ │ ├── agent.py \# Координатор диалога, Tool Calls и Topic Drift Guard
│ │ ├── schemas.py \# Описание JSON-схем инструментов для Ollama
│ │ ├── file\_parser.py \# Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT)
│ │ ├── db\_tools.py \# Фасадные вызовы функций БД для агента
│ │ ├── core/ (calendar\_utils, fast\_path, ollama\_client, prompt\_merger, tool\_injector)
│ │ └── db/ (connection, db\_chat, db\_prompts, db\_snapshots, db\_tasks)
│ └── static/ (index.html, css/styles.css, js/app.js, js/auth.js, js/chat.js, js/tasks.js)
└── docs/
└── development\_roadmap.md \# Стратегическая дорожная карта и архитектурный бэклог
## ---
**5\. Справочник команд CLI (HOWTO)**
### **ETL Пайплайн (main\_etl.py):**
> * python main\_etl.py — стандартный дневной запуск (экспорт из MS SQL, сохранение снапшота, генерация отчетов).
> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL.
> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота.
> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG).
### **CLI базы данных (scripts/db\_cli.py):**
> * python scripts/db\_cli.py stats — статистика по количеству строк во всех таблицах БД.
> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (свежие сверху).
> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — инспекция логов СКУД и экспорт в Excel.
> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП.
> * python scripts/db\_cli.py anomalies — история выявленных аномалий.
> * python scripts/db\_cli.py rules — правила базы знаний компании.
> * python scripts/db\_cli.py prompts — системные промпты из system\_prompts.
> * python scripts/db\_cli.py tools — декларативный реестр действий инструментов и кнопок.
> * python scripts/db\_cli.py sessions — активные сессионные стейты и висящие черновики.
> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID.
> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за день.
> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel.
## ---
**6\. Стратегическая дорожная карта и нерешенные задачи (Roadmap & Backlog)**
> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):**
* **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP.
* **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений.
* **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py.
> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):**
* Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData.
* Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут).
* Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку.
> 3. **Интерактивный Task Planner & Roadmap Engine:**
* Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks.
> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):**
* Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст.
> 5. **Автоматизация расписания срезов и Telegram-алерты:**
* Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time.
* Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram.
File: ./docs/SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт и технический контекст (v4.0).md
# **SCUD Orion AI — Полная энциклопедическая хроника, архитектурный паспорт, разбор всех инцидентов и технический контекст проекта**
**Версия документа:** 4.0 (Исчерпывающий монолитный контекстный паспорт для новой сессии)
**Стек технологий и инфраструктура:** Python 3.11+, FastAPI (Uvicorn), SQLite (WAL mode, PRAGMA synchronous=NORMAL, внешние ключи ON), Ollama REST API (Qwen 2.5:14b — основной текстовый агент и классификатор, Qwen 2.5-VL:7b-q8\_0 / Qwen 3-VL:8b — OCR и зрение), MS SQL Server 2019/2022 (СКУД «Орион Pro» ЗАО НВП «Болид», 1С:ЗУП 3.1 через pyodbc / ODBC Driver 18), сетевая Samba-шара (/mnt/scud\_share), Git/Gitea, HTML5 / Vanilla JS / Tailwind CSS.
## ---
**1\. Исходный бизнес-контекст, роли систем и фундаментальные задачи**
Проект решает комплексную проблему контроллинга трудовой дисциплины, автоматического сопоставления кадровых фактов и защиты от рассинхронизации мастер-данных на предприятии со штатом \~270–276 сотрудников.
### **Источники первичных данных:**
> * **СКУД «Орион Pro» (Болид):** База данных MS SQL Server (SERVER: 172.16.31.221\\SQL, DB: Orion-14.01.21-1). Основные таблицы:
* pLogData — журнал всех сырых физических транзакций турникетов, калиток и внутренних дверей. Ключевые поля: TimeVal (время сервера), DeviceTime (время контроллера), HozOrgan / Hrk (ID сотрудника), Event (код события: 26, 28, 32 — вход; 27, 29, 33 — выход; 54, 55, 64, 65 — внутренние считыватели), Mode (направление: 1 — IN, 2 — OUT), ReaderIndex, DoorIndex.
* pList — реестр физических лиц и пропусков (ФИО, табельный номер, статус архива).
* PDivision, PPost — справочники подразделений и должностей СКУД.
> * **1С:ЗУП 3.1 (Кадры):** База данных MS SQL Server (SERVER: ACCOUNT-01, DB: ZUP30).
* dbo.\_Reference299 — эталонный справочник сотрудников.
* dbo.\_InfoRg16921 — периодический регистр сведений состояний сотрудников (отпуска основные, больничные, командировки, декретные отпуска, отпуска без сохранения оплаты).
* Файловые выгрузки с сетевой шары /mnt/scud\_share: файлы Штат\_ДД\_ММ\_ГГГГ.xlsx (шапка 7–8 строк) и Отсутствия\_ДД\_ММ\_ГГГГ.xlsx (шапка 3–4 строки).
> * **Реестры статических исключений:** static\_reason\_workers.csv (реестр удаленщиков и постоянных разъездных сотрудников), exceptions.json (декларативный фильтр обслуживающего персонала и спец-отделов).
## ---
**2\. Подробнейшая пошаговая хронология разработки, эволюция идей и разбор ошибок**
### **Фаза 1: Первые эксперименты с LLM, выявление галлюцинаций и гибридная парадигма**
> * **Попытка передачи сырых списков в чат:** Первоначальная попытка скормить модели Qwen 2.5:14b список из сотен ФИО с инструкцией *«запомни список 1С»* показала, что у LLM нет постоянной памяти между сессиями. Модель теряла контекст, путала фамилии и выдавала ответы с троеточиями ('Васильев Л.И.', ...) или придумывала несуществующие строки (*«ЛЕНМОРНИИПРОЕКТ ОЭС Варламова Мария»*).
> * **Ошибки openpyxl vs pandas:** Первая генерация кода моделью использовала библиотеку openpyxl с построчным перебором ячеек, жестко зашитыми опечатками (поиск слова 'фиио') и падением по FileNotFoundError.
> * **Переход к детерминированному пайплайну:** Принято ключевое архитектурное решение — **LLM не считает математику и не фильтрует 300 строк текста**. Всю механическую работу (ETL, слияние, фильтрацию) выполняет быстрый детерминированный Python (pandas, SQL), а локальная LLM выступает исключительно в роли «умного ревизора» на этапе сопоставления сомнительного несовпавшего остатка (Diff).
### **Фаза 2: Нормализация ФИО, кодировки и коллизия совместителей (Кейс Балаевой О.Г.)**
> * **Символьные ловушки:**
* Латинские буквы-двойники (омоглифы a, c, e, o, p, x, y, случайно набранные в английской раскладке).
* Служебные суффиксы в скобках, добавляемые 1С ((осн.), (совм.), (внешн.)).
* Буквы ё/е, двойные пробелы, неразрывные пробелы \\xa0 и случайный ввод цифры 0 вместо буквы О.
> * **Функция normalize\_fio():** Разработан строгий конвейер очистки:
> `def normalize_fio(fio):`
> `if not fio or not isinstance(fio, str): return ""`
> `fio_clean = re.sub(r'\(.*?\)', '', fio) # Удаление скобок`
> `fio_clean = fio_clean.replace('\xa0', ' ')`
> `trans = str.maketrans('aceopxyABCEHKMOPTX', 'асеорхуАВСЕНКМОРТХ')`
> `fio_clean = fio_clean.translate(trans)`
> `fio_clean = fio_clean.replace('ё', 'е').replace('Ё', 'Е')`
> `return " ".join(fio_clean.strip().split()).title()`
> * **Коллизия 270 vs 271:** В СКУД числилось 270 уникальных человек, а в 1С — 271 строка. Ручной разбор показал, что **Балаева Ольга Геннадьевна** занимает две ставки в 1С (*Руководитель управления персоналом* и *Руководитель режимно-секретного отдела*) при одном физическом пропуске в СКУД.
> * **Решение:** Внедрена обязательная группировка штата 1С по fio\_clean с объединением должностей и подразделений через " / ".
### **Фаза 3: Защита Entity Resolution от однофамильцев (Инцидент с Семеновыми)**
> * **Сбой модели:** При передаче несовпавших ФИО в Qwen 2.5 модель ошибочно сопоставила *«Семенову Юлию Борисовну»* (которая была на работе) с *«Семеновой Диной Дмитриевной»* (находившейся в отпуске), посчитав это опечаткой в имени из\-за одинаковой фамилии. В результате Юлия Борисовна получила статус нарушителя («в отпуске, но пришла»).
> * **Двухконтурная защита:**
1. В системный промпт ИИ зашито категорическое правило: однофамильцы с разными именами/отчествами — строго разные люди, объединение запрещено.
2. В Python встроен дублирующий барьер: если фамилии совпадают, но инициалы/первые буквы имен различаются, связь ИИ принудительно аннулируется с логированием ⚠️ \[Блокировка фейка\].
### **Фаза 4: Декларативные исключения и синонимы отделов**
> * **Переход к exceptions.json:** Исключение клининга, дворников, гардеробщиков и хозяйственного персонала отдела «ЭТО» вынесено из кода в конфигурационный файл exceptions.json (поддержка списков departments, positions, fio, position\_keywords).
> * **База синонимов отделов (department\_synonyms):** Для сопоставления сокращений СКУД с длинными названиями 1С (например, *«ОВК» ⟷ «Отдел внутреннего контроля»*) создана таблица SQLite, автоматически обучаемая при подтверждениях оператора.
### **Фаза 5: Аппаратный сбой СКУД 04.08.2026, разгадка «исчезающих неизвестных» и Первая активность**
> * **Анатомия сбоя:** Утром 04.08.2026 зависли регистраторы входа на главном КПП. У 119 сотрудников не записалось событие входа (time\_in \= 'Нет входа'). Из них 57 человек были официально закрыты документами 1С (отпуска, командировки). Оставшиеся \~74 человека реально пришли на работу, но утренней отметки не имели. Однако вечером сводка выдала всего 4–7 «неизвестных».
> * **Вскрытие механизма ошибки:** Было обнаружено, что скрипт агрегации видел дневные перемещения сотрудников (выходы на обед, считыватели туалетов, внутренние двери) и автоматически выставлял Статус \= 'Присутствовал' и is\_present \= 1\. Из-за этого люди попадали в категорию «Итого на работе», а массовый аппаратный сбой скрывался.
> * **Внедрение «Первой активности»:**
1. В базу данных и выгрузки Excel добавлена колонка **first\_activity («Первая активность»)** — минимальное время любого события дня в здании (MIN(TimeVal) AS FirstRawEvent без учета направления двери).
2. Если time\_in \== 'Нет входа', но first\_activity \!= '—', записи присваивается anomaly\_flag \= 'ANOMALY\_NO\_IN\_HAS\_ACTIVITY', а is\_present принудительно сбрасывается в 0\.
3. Внедрен модуль analyze\_scud\_mass\_failure\_ai(): при превышении порога в 5% смены (\>10 человек) ИИ поднимает тревогу о массовом сбое контроллеров КПП.
> * **Кейс Равина В.Э.:** Сотрудник находился в официальном отпуске, утреннего входа не имел, но пришел после обеда (первая активность 14:11:06, выход 17:18:38). Система корректно зафиксировала истинную кадровую аномалию (физическое присутствие при официальном отпуске).
### **Фаза 6: Архитектурная миграция под Gitea и система срезов (снапшотов)**
> * **Безопасный рефакторинг:** Монолит разделен на слои (core/, services/, scripts/, modules/web\_api/) через git mv с сохранением истории коммитов в ветке refactor/modular-structure.
> * **Формат ID снапшота:** Введена схема \[Y\]YYYYMMDD-NNN (например, Y20260804-001 для вчерашнего зафиксированного вечернего среза на 22:00 и 20260805-002 для дневного).
> * **Оптимизация экспорта:** Если в SQLite уже зафиксирован вчерашний срез с префиксом Y, повторный тяжелый SQL-запрос к СКУД Орион не выполняется (функция has\_yesterday\_final\_snapshot).
> * **Инспекция и CLI:** Написан инструмент scripts/db\_cli.py с командами stats, snapshots, scud, absences, anomalies, rules, prompts, tools, sessions, dump, snapshot del.
### **Фаза 7: Web API, Generative UI, управление памятью LLM и Topic Drift Guard**
> * **FastAPI & Безопасность:** Развернут Web API с JWT-авторизацией (bcrypt), изолированными задачами пользователей (tasks с привязкой к user\_id), гостевым режимом и ролевой моделью.
> * **Проблема деградации контекста LLM:** Вывод объемных списков задач, текстов системных промптов и таблиц снапшотов засорял контекстное окно чата, приводя к галлюцинациям и «залипанию» модели на старых правилах.
> * **Решение проблемы памяти (Topic Drift & Ephemeral Context):**
* В таблицу chat\_messages добавлен флаг is\_ephemeral \= 1 для временных сообщений.
* Введены двухфазные сессии (PROMPT\_PREVIEW → PROMPT\_FOLLOWUP) и детерминированный счетчик отвлечений idle\_turns.
* **Context Guard:** На 3-м шаге отвлечения оператора на сторонние темы система отвечает на вопрос и выводит интерактивное напоминание с кнопками \[Завершить настройку\] / \[Показать промпт\]. При idle\_turns \> 3 происходит автоочистка сессии вызовом db\_purge\_ephemeral\_messages().
* **Fast-Path:** Прямой перехват команд подтверждения («да», «подтверждаю», «сохранить») и выхода («нет, спасибо», «закончить настройку») без обращения к LLM (ответ за 0.05 сек).
* **Декларативный реестр действий (tool\_action\_registry):** Хранение шаблонов ответов, типов кнопок и признаков эфемерности в SQLite.
> * **Generative UI (Интерактивные карточки задач):** Инструмент db\_get\_tasks возвращает полезную нагрузку TASK\_INTERACTIVE\_CARD. Фронтенд рендерит живой виджет с табами (Все / В работе / В планах / Завершенные), чекбоксами и inline-созданием задач через REST API без вызова LLM.
## ---
**3\. Полная схема базы данных SQLite (data/scud\_orion\_ai.db)**
| Таблица | Назначение и бизнес-логика | Полная структура колонок |
| :---- | :---- | :---- |
| **scud\_logs** | Хранение логов проходов СКУД, расчетного времени и снапшотов. | id (PK), log\_date, fio, fio\_clean, department, position, time\_in, first\_activity, time\_out, time\_in\_building, is\_present, anomaly\_flag, snapshot\_time, snapshot\_id, created\_at |
| **zup\_staff** | Штатное расписание 1С:ЗУП на даты срезов. | id (PK), snapshot\_date, fio, fio\_clean, department, position, created\_at |
| **zup\_absences** | Официальные кадровые отклонения 1С (отпуска, больничные, командировки, декреты). | id (PK), absence\_date, fio, fio\_clean, absence\_type, created\_at |
| **anomalies\_history** | Журнал выявленных кадровых и аппаратных аномалий. | id (PK), anomaly\_date, fio, anomaly\_type, details, human\_status, created\_at |
| **ai\_knowledge\_base** | Глобальные правила арбитража и приоритеты компании для ИИ. | id (PK), rule\_text, added\_by, created\_at |
| **system\_prompts** | Версионные системные промпты агента (активный промпт по is\_active=1). | id (PK), name, prompt\_text, is\_active, updated\_at |
| **tool\_action\_registry** | Декларативный реестр шаблонов, кнопок и настроек эфемерности инструментов. | id (PK), tool\_name, category, bypass\_llm, success\_template, follow\_up\_question, action\_type, buttons\_json, is\_active, is\_ephemeral, updated\_at |
| **session\_states** | Активные состояния сессий, черновики и счетчик idle\_turns. | session\_id (PK), state\_type, pending\_data, updated\_at |
| **chat\_messages** | История диалогов с поддержкой разделения на постоянные и эфемерные сообщения. | id (PK), session\_id, role, content, is\_ephemeral, created\_at |
| **tasks** | Реестр задач пользователей с привязкой к user\_id. | id (PK), task\_id, module, title, priority, status, due\_date, user\_id, created\_at |
| **users** | Учетные записи операторов (хэши паролей bcrypt, права администратора). | id (PK), username, password\_hash, is\_admin, full\_name, created\_at |
| **department\_synonyms** | База знаний соответствия аббревиатур СКУД полным названиям отделов 1С. | id (PK), short\_name, full\_name, created\_at |
| **system\_reference** | Справочник команд и примеров промптов для операторов. | id (PK), category, title, example\_prompt, description |
## ---
**4\. Актуальная архитектура каталогов и назначение файлов**
`scud_ai/`
`├── config.py # Конфигурация: пути, расчет дат, URL Ollama, параметры MS SQL`
`├── exceptions.json # Декларативные фильтры исключений (ОВК, уборщики, подрядчики)`
`├── main_etl.py # Оркестратор ETL: экспорт СКУД/1С -> SQLite -> Excel/MD отчеты`
`├── project_code_snapshot.md # Полный исходный код всех файлов репозитория`
`├── core/`
`│ └── database.py # Драйвер SQLite (WAL), функции сохранения/загрузки срезов`
`├── services/`
`│ ├── scud_export.py # Прямой SQL-экспорт из MS SQL «Орион Pro»`
`│ ├── zup_extractor.py # Прямой SQL-экспорт из MS SQL 1С:ЗУП 3.1`
`│ ├── data_loader.py # Загрузка данных, нормализация ФИО, расчет первой активности`
`│ ├── data_validator.py # Проверка свежести и наличия файлов за Вчера и Сегодня`
`│ ├── excel_exporter.py # Генерация сводки и детального отчета в Excel (openpyxl)`
`│ ├── text_reporter.py # Построение итогового Markdown-отчета через Qwen 2.5`
`│ ├── ai_verifier.py # Модули ИИ-аудита, Fuzzy Matching и детекции сбоев СКУД`
`│ ├── feedback_loop.py # Интерактивный консольный модуль Human-in-the-Loop`
`│ ├── knowledge_base.py # Интерфейс работы с базой знаний правил`
`│ └── share_copier.py # Резервное скачивание файлов 1С с шары /mnt/scud_share`
`├── scripts/`
`│ ├── db_cli.py # CLI-утилита управления БД, снапшотами, дампами и сессиями`
`│ ├── fix_snapshots.py # Скрипт переиндексации и нормализации снапшотов`
`│ ├── init_tool_registry.py # Инициализация декларативного реестра инструментов`
`│ └── diagnostics/`
`│ ├── inspect_db.py # Диагностика структуры таблиц и количества записей`
`│ ├── inspect_ephemeral.py # Анализ распределения постоянных и эфемерных сообщений`
`│ ├── inspect_files.py # Аудит файлов и размеров на диске`
`│ └── show_tree.py # Вывод дерева каталогов`
`├── modules/web_api/`
`│ ├── main.py # Точка входа FastAPI приложения, роутеры и раздача статики`
`│ ├── routers/`
`│ │ ├── admin.py # Управление пользователями`
`│ │ ├── auth.py # JWT аутентификация и смена паролей`
`│ │ ├── chat.py # Эндпоинты диалогов (авторизованный и гостевой)`
`│ │ └── tasks.py # REST API управления задачами`
`│ ├── llm/`
`│ │ ├── agent.py # Координатор диалога, Tool Calls и Topic Drift Guard`
`│ │ ├── schemas.py # Описание JSON-схем инструментов для Ollama`
`│ │ ├── file_parser.py # Мультимодальный парсер файлов (PDF, PNG, XLSX, TXT)`
`│ │ ├── db_tools.py # Фасадные вызовы функций БД для агента`
`│ │ ├── core/`
`│ │ │ ├── calendar_utils.py # Формирование системного контекста дат и дней недели`
`│ │ │ ├── fast_path.py # Быстрый перехват команд подтверждения/отмены (0.05 с)`
`│ │ │ ├── ollama_client.py # HTTP-клиент взаимодействия с Ollama API`
`│ │ │ ├── prompt_merger.py # Парсинг и слияние правок системного промпта`
`│ │ │ └── tool_injector.py # Очистка сырых тегов и артефактов Tool Calls`
`│ │ └── db/`
`│ │ ├── connection.py # Подключение к scud_orion_ai.db`
`│ │ ├── db_chat.py # Сохранение истории и очистка эфемерных сообщений`
`│ │ ├── db_prompts.py # Системные промпты, стейты и реестр действий`
`│ │ ├── db_snapshots.py # Выборка и удаление снапшотов`
`│ │ └── db_tasks.py # CRUD операции с задачами пользователей`
`│ └── static/`
`│ ├── index.html # SPA разметка (модалки, чат, боковая панель задач)`
`│ ├── css/styles.css # Стили и мобильные оптимизации`
`│ └── js/`
`│ ├── app.js # Инициализация приложения`
`│ ├── auth.js # Логика входа, смены паролей и администрирования`
`│ ├── chat.js # Обработка сообщений, Drag&Drop, виджет задач и кнопки`
`│ └── tasks.js # Управление боковой панелью задач (Drawer)`
`└── docs/`
`└── development_roadmap.md # Стратегическая дорожная карта и архитектурный бэклог`
## ---
**5\. Справочник команд командной строки (CLI Reference)**
### **Главный конвейер контроллинга (main\_etl.py)**
> * python main\_etl.py — стандартный дневной запуск: экспорт свежих данных СКУД и 1С, сохранение снапшота в SQLite, генерация Excel и Markdown отчетов.
> * python main\_etl.py \--skip-export — построение отчетов по последнему имеющемуся снапшоту из SQLite без повторного запроса к MS SQL.
> * python main\_etl.py \--snapshot 20260805-001 — точный расчет отчетов по указанному составному ID снапшота.
> * python main\_etl.py \-d — запуск в режиме расширенной отладки (DEBUG).
### **CLI-утилита управления SQLite базой данных (scripts/db\_cli.py)**
> * python scripts/db\_cli.py stats — общая статистика количества записей по всем таблицам БД.
> * python scripts/db\_cli.py snapshots \[ДД.ММ.ГГГГ\] — просмотр реестра снапшотов с сортировкой по времени создания (самые свежие сверху).
> * python scripts/db\_cli.py scud \[ДД.ММ.ГГГГ\] \[--snapshot ID\] \[--export-xlsx NAME\] — просмотр логов СКУД и опциональный экспорт в Excel.
> * python scripts/db\_cli.py absences \[ДД.ММ.ГГГГ\] — список кадровых отклонений из 1С:ЗУП.
> * python scripts/db\_cli.py anomalies — история выявленных аномалий.
> * python scripts/db\_cli.py rules — правила базы знаний компании.
> * python scripts/db\_cli.py prompts — просмотр системных промптов из таблицы system\_prompts.
> * python scripts/db\_cli.py tools — вывод декларативного реестра действий инструментов и шаблонов кнопок.
> * python scripts/db\_cli.py sessions — просмотр активных сессионных стейтов и висящих черновиков.
> * python scripts/db\_cli.py snapshot del \[ID\] — удаление конкретного снапшота по ID.
> * python scripts/db\_cli.py snapshot del \--day \[ДД.ММ.ГГГГ\] — удаление всех снапшотов за выбранный день.
> * python scripts/db\_cli.py dump \[backup.xlsx\] — полный дамп всех таблиц базы данных в многостраничный Excel-файл.
## ---
**6\. Стратегическая дорожная карта развития (Roadmap & Backlog)**
> 1. **Масштабирование механизма эфемерного контекста на все инструменты (Rollout Roadmap):**
* **Снапшоты (snapshots):** Перевод вывода объемных таблиц срезов в эфемерный режим (is\_ephemeral \= 1), внедрение интерактивного подтверждения при удалении снапшотов по ID/дате через состояния SNAPSHOTS\_PREVIEW / SNAPSHOTS\_FOLLOWUP.
* **База знаний и аномалии (kb & anomalies):** Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой для исключения «залипания» модели на устаревших правилах из истории сообщений.
* **Декларативная унификация в tool\_action\_registry:** Расширение схемы таблицы полями followup\_state\_type, auto\_purge\_on\_exit, guard\_question\_template для устранения хардкода имен функций в agent.py.
> 2. **Модуль анализа внутридневных перемещений («Контроль перекуров»):**
* Анализ интервалов между событиями OUT (выход) и IN (возврат) по журналу pLogData.
* Подсчет суммарного времени нахождения вне рабочего места в течение смены с отсечением технологической нормы (30–40 минут).
* Вывод аналитического блока и злостных нарушителей в отдельный лист Excel-отчета и итоговую Markdown-сводку.
> 3. **Интерактивный Task Planner & Roadmap Engine:**
* Специализированный инструмент для ведения динамических проектных планов и чек-листов с чекбоксами (\[ \] / \[x\]) прямо в диалоге с синхронизацией в таблицу tasks.
> 4. **Изолированная среда исполнения кода (Code Execution Sandbox):**
* Безопасный изолированный Docker/gVisor контейнер для динамического выполнения генерируемого моделью Python/Pandas кода на лету с возвратом stdout/stderr и графиков в контекст.
> 5. **Автоматизация расписания срезов и Telegram-алерты:**
* Автозапуск выгрузок в 12:00, 17:00 и 19:00 через systemd-таймеры с фиксацией snapshot\_time.
* Отправка мгновенных алертов о массовых сбоях турникетов (\>5%) и критических кадровых конфликтах дежурному системному администратору в Telegram.
File: ./docs/development_roadmap.md
# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI
## 📌 Зафиксированные вехи и концептуальные архитектурные решения
Схема веток Gitea для ближайшего развития проекта:
main (100% стабильный релиз)
│
├──► feature/tool-registry-db ──────┐ (Создание таблицы tool_action_registry в SQLite)
│ ▼
├───────────────────────────────► merge to main
│
├──► feature/interactive-buttons ───┐ (Подключение Да/Нет чипсов на фронтенде)
│ ▼
├───────────────────────────────► merge to main
│
└──► feature/ephemeral-context ─────┐ (Внедрение флагов is_ephemeral для очистки памяти LLM)
▼
merge to main
### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking)
* **Проблема:** При работе с длинными операциями (редактирование промптов, мастера табелей, снапшотов) оператор может отвлекаться на сторонние вопросы или уточнения. Жесткий сброс сессии уничтожает черновики, а вечное хранение засоряет память LLM.
* **Решение (Сферическое / Фундаментальное):**
1. **Семантическая оценка моделью:** Модель через системный контекст определяет, относится ли вопрос оператора к активному черновику/инструменту или разговор ушел в сторону (`topic_shift = true`).
2. **Детерминированный счетчик в бэкенде:** Бэкенд фиксирует шаги отвлечения (`idle_turns`). Пока $N < 3$, оператор может свободно общаться, не теряя висящий контекст.
3. **Вежливый перехват и выбор (Guard):** На 3-м шаге отвлечения система отвечает на текущий вопрос оператора и мягко напоминает о незавершенной транзакции кнопками `[Применить / Сохранить]` или `[Отменить и сбросить]`.
---
## 🚀 Будущие модули и запланированный функционал (Backlog)
### 2. Интерактивный модуль планирования и дорожных карт (Checklist & Task Planner Engine)
* **Концепция:** Специализированный инструмент для LLM-агента, позволяющий вести динамические проектные чек-листы и планы с интерактивными чекбоксами прямо в диалоге и базе данных.
* **Ключевые возможности:**
* Составление многоуровневых планов с чекбоксами (`[ ]` / `[x]`).
* Фиксация промежуточных комментариев и статусов выполнения по каждому пункту.
* Синхронизация задач плана с SQLite-таблицей `tasks`.
* Экспорт планов в Markdown/Excel и отображение на дашборде.
### 3. Изолированная среда выполнения и песочница кода (Code Execution Sandbox Engine)
* **Концепция:** Безопасный изолированный Docker-контейнер (или gVisor / Pyodide / nsjail окружение) для динамического выполнения кода, генерируемого моделью в процессе рассуждений и анализа данных.
* **Ключевые возможности:**
* Запуск сложных вычислений, агрегаций и статистического анализа данных СКУД на лету (Pandas / NumPy).
* Выполнение тестовых сценариев и валидация скриптов перед их сохранением/применением на проде.
* Полная изоляция от хост-системы: read-only доступ к копиям данных, ограничение памяти/CPU (cgroups), отсутствие доступа к чувствительным сетевым интерфейсам.
* Возврат результатов вычислений (stdout, stderr, артефакты, сгенерированные таблицы/графики) обратно в контекст модели.
### 1. Архитектура скользящего контекста и отслеживания смены темы (Intent / Topic Drift Tracking)
* **Статус:** `MVP Реализован и протестирован` (на базе инструментов системного промпта: `db_preview_prompt_merge`, `db_confirm_prompt_preview`, `db_cancel_prompt_preview`).
* **Концептуальное решение:**
1. **Двухфазное состояние сессии:** Разделение жизненного цикла на фазу черновика (`PROMPT_PREVIEW`) и фазу интерактивного подтверждения/диалога (`PROMPT_FOLLOWUP`).
2. **Эфемерный буфер:** Промежуточные экраны, подтверждения и тяжелые выводы помечаются `is_ephemeral = 1` и не засоряют постоянную историю диалога.
3. **Детерминированный счетчик (Topic Drift):** Бэкенд инкрементирует `idle_turns` при отвлечении на сторонние темы. На шаге $N = 3$ срабатывает **Context Guard** с напоминанием и кнопками действий, а при $N > 3$ происходит автоматический сброс сессии и вызов `db_purge_ephemeral_messages`.
4. **Fast-Path очистка:** Прямой перехват команд завершения (`нет, спасибо`, `закончить настройку`) моментально очищает эфемерный контекст.
---
### 1.1. План масштабирования механизма эфемерного контекста на все инструменты (Rollout Roadmap)
План:
[1. REST API (main.py)] ────► Добавляем POST, PATCH, DELETE для /api/v1/tasks
│
[2. Agent (agent.py)] ────► При db_get_tasks отдаем payload {type: "TASK_INTERACTIVE_CARD", tasks: [...]}
│
[3. UI (chat.js / CSS)] ────► Рендерим карточку с табами [Все] [В работе] [В планах] [Завершенные]
#### Этап 1. Модуль задач и бэклога (`tasks`)
* **Целевые инструменты:** `db_get_tasks`, `db_add_task`, `db_update_task_status`, `db_delete_task`.
* **Задачи реализации:**
* Перевод вывода объемных плоских списков задач в эфемерный режим (`is_ephemeral = 1`), чтобы история диалога не забивалась длинными перечнями.
* Реализация состояния сессии `TASKS_FOLLOWUP` после создания/изменения/удаления задачи.
* Интеграция с Context Guard: напоминание о завершении работы с задачами на 3-м шаге отвлечения.
* Автоочистка промежуточных диалоговых карточек задач при переключении на аналитику СКУД или общие вопросы.
#### Этап 2. Модуль срезов и архива СКУД (`snapshots`)
* **Целевые инструменты:** `db_get_snapshots`, `db_delete_snapshots`.
* **Задачи реализации:**
* Маркировка объемных таблиц срезов и логов как эфемерных сообщений.
* Внедрение интерактивного подтверждения при удалении снапшотов по ID или по дате (`SNAPSHOTS_PREVIEW` / `SNAPSHOTS_FOLLOWUP`).
* Полная автоочистка временных таблиц срезов из памяти диалога после получения пользователем нужной информации.
#### Этап 3. Модуль базы знаний, правил и аномалий (`kb & anomalies`)
* **Целевые инструменты:** `db_get_rules`, `db_get_anomalies`, `db_get_reference`.
* **Задачи реализации:**
* Вывод справочников команд, правил арбитража и списков аномалий исключительно во временный слой.
* Предотвращение «залипания» модели на старых правилах из истории: гарантия того, что LLM всегда запрашивает актуальные правила через Tool Call, а не читает их из контекста предыдущих сообщений.
#### Этап 4. Декларативная унификация в `tool_action_registry`
* **Целевые изменения:**
* Расширение схемы `tool_action_registry` новыми декларативными полями: `followup_state_type`, `auto_purge_on_exit`, `guard_question_template`.
* Устранение жестких проверок названий функций в `agent.py`: перевод всей логики жизненного цикла сессий и очистки памяти на декларативные параметры из базы данных SQLite.
File: ./core/__init__.py
File: ./core/database.py
import os
import sqlite3
import json
import pandas as pd
from datetime import datetime
from config import DATA_DIR
DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
def get_connection():
"""Создает подключение к базе данных SQLite с оптимизированными настройками."""
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA foreign_keys = ON;")
conn.execute("PRAGMA journal_mode = WAL;")
return conn
def init_db():
"""Создает структуру таблиц и индексов в базе данных."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS scud_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
time_in TEXT,
first_activity TEXT DEFAULT '—',
time_out TEXT,
time_in_building TEXT,
is_present INTEGER NOT NULL,
anomaly_flag TEXT DEFAULT 'NONE',
snapshot_time TEXT DEFAULT NULL,
snapshot_id TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("PRAGMA table_info(scud_logs);")
cols = [col[1] for col in cursor.fetchall()]
if 'snapshot_id' not in cols:
cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_id TEXT DEFAULT NULL;")
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_staff (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
department TEXT,
position TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS zup_absences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
absence_date TEXT NOT NULL,
fio TEXT NOT NULL,
fio_clean TEXT NOT NULL,
absence_type TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS anomalies_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anomaly_date TEXT NOT NULL,
fio TEXT NOT NULL,
anomaly_type TEXT NOT NULL,
details TEXT NOT NULL,
human_status TEXT DEFAULT 'Pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS ai_knowledge_base (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_text TEXT UNIQUE NOT NULL,
added_by TEXT DEFAULT 'Human',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
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);")
conn.commit()
sync_knowledge_base_to_db()
def has_scud_logs_for_date(date_str):
"""Проверяет, есть ли в базе данные СКУД за указанную дату."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
return cursor.fetchone() is not None
def has_yesterday_final_snapshot(date_str):
"""Проверяет, зафиксирован ли уже ИТОГОВЫЙ вчерашний снапшот с индексом Y/22:00."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
(date_str,)
)
return cursor.fetchone() is not None
def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False):
"""
Генерирует датированный составной ID снапшота (YYYYMMDD-NNN или YYYYMMDD-NNN).
Префикс 'Y' присваивается автоматически, если дата логов (date_str) предшествует дате снятия (snapshot_time).
"""
try:
dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date()
date_prefix = dt_snap.strftime("%Y%m%d")
except (ValueError, TypeError):
dt_snap = datetime.now().date()
date_prefix = dt_snap.strftime("%Y%m%d")
# Автоматическая проверка: если дата логов раньше даты выгрузки — проставляем Y
if date_str:
try:
dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
if dt_log < dt_snap:
is_yesterday = True
except Exception:
pass
prefix = "Y" if is_yesterday else ""
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(date_str, snapshot_time)
)
else:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
(snapshot_time,)
)
row = cursor.fetchone()
if row and row[0]:
return row[0]
cursor.execute("""
SELECT snapshot_id
FROM scud_logs
WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
ORDER BY snapshot_id DESC LIMIT 1
""", (f"{date_prefix}-%", f"Y{date_prefix}-%"))
last_row = cursor.fetchone()
next_seq = 1
if last_row and last_row[0]:
parts = last_row[0].replace("Y", "").split("-")
if len(parts) > 1 and parts[1].isdigit():
next_seq = int(parts[1]) + 1
return f"{prefix}{date_prefix}-{next_seq:03d}"
def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False):
"""Сохраняет логи СКУД с автоматическим присвоением составного snapshot_id."""
if df_scud is None or df_scud.empty:
return
if not snapshot_time:
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Передаем date_str в генератор snapshot_id
snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
data_to_insert = []
for _, r in df_scud.iterrows():
data_to_insert.append((
date_str,
r.get('Сотрудник', r.get('fio_raw', '')),
r.get('fio_clean', ''),
r.get('Подразделение', ''),
r.get('Должность', ''),
r.get('Начало_дня', 'Нет входа'),
r.get('Первая_активность', '—'),
r.get('Конец_дня', 'Нет выхода'),
r.get('Находился_в_здании', '00:00'),
1 if r.get('Пришел', False) else 0,
r.get('anomaly_flag', 'NONE'),
snapshot_time,
snapshot_id
))
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
cursor.executemany("""
INSERT INTO scud_logs (
log_date, fio, fio_clean, department, position,
time_in, first_activity, time_out, time_in_building,
is_present, anomaly_flag, snapshot_time, snapshot_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", data_to_insert)
conn.commit()
def get_latest_snapshot_time(date_str=None):
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
else:
cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
row = cursor.fetchone()
return row[0] if row else None
def load_scud_from_db_by_snapshot(date_str, snapshot_param=None):
"""
Загружает логи СКУД по ID снапшота.
Если snapshot_param не задан (или искался прошлый день), приоритетно выбирает
финальный вечерний снапшот, начинающийся с буквы 'Y' (срез на 22:00) за дату date_str.
"""
with get_connection() as conn:
df = pd.DataFrame()
# 1. Если передан конкретный ID снапшота (например, '20260810-001')
if snapshot_param:
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(str(snapshot_param),)
)
# 2. Если по snapshot_param ничего не найдено ИЛИ snapshot_param=None (запрос за вчерашний день):
if df.empty and date_str:
cursor = conn.cursor()
# ⭐️ ПРИОРИТЕТ 1: Ищем срез, начальный символ которого 'Y' (вечерний зафиксированный Y-снапшот)
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone()
# ⭐️ ПРИОРИТЕТ 2: Если Y-снапшот не найден, берем самый свежий обычный
if not row:
cursor.execute(
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1",
(date_str,)
)
row = cursor.fetchone()
if row and row[0]:
target_snap_id = row[0]
df = pd.read_sql_query(
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
conn, params=(target_snap_id,)
)
# Приводим названия колонок к стандарту
if not df.empty:
rename_map = {
'department': 'Подразделение',
'position': 'Должность',
'fio': 'Сотрудник',
'time_in': 'Начало_дня',
'first_activity': 'Первая_активность',
'time_out': 'Конец_дня',
'duration': 'Находился_в_здании',
'is_present': 'Пришел'
}
# Переименовываем имеющиеся колонки
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
# Заполняем недостающие колонки значениями по умолчанию, если их не было в БД
required_cols = ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']
for col in required_cols:
if col not in df.columns:
if col == 'Пришел':
df[col] = False
else:
df[col] = '—'
# Приводим тип флага Пришел к bool
if 'Пришел' in df.columns:
df['Пришел'] = df['Пришел'].astype(bool)
return df
def get_available_snapshots(date_str=None):
"""
Возвращает список снапшотов.
Сортировка по времени snapshot_time DESC.
"""
with get_connection() as conn:
cursor = conn.cursor()
if date_str:
cursor.execute("""
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt
FROM scud_logs
WHERE log_date = ? AND snapshot_time IS NOT NULL
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC
""", (date_str,))
else:
cursor.execute("""
SELECT
snapshot_id,
log_date,
snapshot_time,
COUNT(*) as cnt
FROM scud_logs
WHERE snapshot_time IS NOT NULL
GROUP BY snapshot_id, log_date, snapshot_time
ORDER BY snapshot_time DESC
""")
return cursor.fetchall()
def save_staff_to_db(df_staff, date_str):
if df_staff is None or df_staff.empty:
return
data_to_insert = [
(date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
for _, r in df_staff.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data_to_insert)
conn.commit()
def save_absences_to_db(df_absent, date_str):
if df_absent is None or df_absent.empty:
return
data_to_insert = [
(date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
for _, r in df_absent.iterrows()
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data_to_insert)
conn.commit()
def save_anomalies_to_db(anomalies_list, date_str):
if not anomalies_list:
return
data_to_insert = [
(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', ''))
for a in anomalies_list
]
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data_to_insert)
conn.commit()
def get_all_rules_from_db():
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT rule_text FROM ai_knowledge_base")
return [r[0] for r in cursor.fetchall()]
def add_rule_to_db(rule_text, added_by="Human"):
if not rule_text or not rule_text.strip():
return
with get_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by))
conn.commit()
except Exception as e:
print(f"[⚠️] Ошибка записи правила в БД: {e}")
def sync_knowledge_base_to_db():
pass
def delete_snapshot_by_id(snapshot_id: str):
"""Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
deleted_count = cursor.rowcount
conn.commit()
print(f"[✓] Удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}")
return deleted_count
def delete_snapshots_by_date(date_str: str):
"""Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
with get_connection() as conn:
cursor = conn.cursor()
# Удаляем по log_date или по дате внутри snapshot_id
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
deleted_count = cursor.rowcount
conn.commit()
print(f"[✓] Удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}")
return deleted_count
def init_department_synonyms_db():
"""Создает таблицу синонимов отделов в БД SQLite."""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS department_synonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_name TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# По умолчанию добавляем ОВК -> Отдел внутреннего контроля
cursor.execute("""
INSERT OR IGNORE INTO department_synonyms (short_name, full_name)
VALUES ('овк', 'отдел внутреннего контроля')
""")
conn.commit()
def get_department_synonyms_dict():
"""Возвращает словарь всех изученных синонимов {short_name: full_name}."""
init_department_synonyms_db()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms")
return {row[0]: row[1] for row in cursor.fetchall()}
def add_department_synonym_to_db(short_name, full_name):
"""Сохраняет новую пару синонимов отдела в базу SQLite."""
init_department_synonyms_db()
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)",
(short_name.strip().lower(), full_name.strip().lower())
)
conn.commit()
print(f"[✓] В базу SQLite добавлен новый синоним отдела: '{short_name}' ⟷ '{full_name}'")
def load_staff_from_db(date_str):
"""Загружает Штатное расписание 1С из базы SQLite за указанную дату."""
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
def load_absences_from_db(date_str):
"""Загружает документальные отсутствия 1С из базы SQLite за указанную дату."""
with get_connection() as conn:
df = pd.read_sql_query(
"SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
conn, params=(date_str,)
)
return df if not df.empty else None
File: ./modules/__init__.py
File: ./modules/web_api/__init__.py
File: ./modules/web_api/main.py
"""
===============================================================================
FILE: modules/web_api/main.py
PROJECT: SCUD Orion AI (Unified Repository)
MODULE: web_api (Main Application Entry Point)
ROLE: Инициализация FastAPI приложения, подключение роутеров и статики.
===============================================================================
"""
# ANCHOR[APP_INIT_IMPORTS]
import os
import sys
import logging
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if CURRENT_DIR not in sys.path:
sys.path.insert(0, CURRENT_DIR)
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError
from routers.auth import router as auth_router
from routers.admin import router as admin_router
from routers.tasks import router as tasks_router
from routers.chat import router as chat_router
# ANCHOR[APP_CONFIG]
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()]
)
STATIC_DIR = os.path.join(CURRENT_DIR, "static")
app = FastAPI(title="SCUD Orion AI Context API", version="2.5")
if os.path.exists(STATIC_DIR):
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}")
return JSONResponse(
status_code=422,
content={"detail": exc.errors(), "body": str(exc)}
)
# ANCHOR[ROUTER_REGISTRATION]
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(tasks_router)
app.include_router(chat_router)
# ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/")
def read_root():
index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
raise HTTPException(status_code=404, detail="Frontend index.html not found")
@app.get("/favicon.ico")
async def favicon():
file_path = os.path.join(STATIC_DIR, "favicon.ico")
if os.path.exists(file_path):
return FileResponse(file_path)
raise HTTPException(status_code=404)
@app.get("/{file_path:path}")
def serve_static_fallback(file_path: str):
clean_path = file_path.lstrip("/")
target = os.path.join(STATIC_DIR, clean_path)
if os.path.isfile(target):
return FileResponse(target)
filename = os.path.basename(clean_path)
target_js = os.path.join(STATIC_DIR, "js", filename)
if filename.endswith(".js") and os.path.isfile(target_js):
return FileResponse(target_js, media_type="application/javascript")
target_css = os.path.join(STATIC_DIR, "css", filename)
if filename.endswith(".css") and os.path.isfile(target_css):
return FileResponse(target_css, media_type="text/css")
raise HTTPException(status_code=404, detail="File not found")
File: ./modules/web_api/scripts/clear_history.py
"""
Скрипт полной очистки истории диалогов и сессионных состояний SQLite.
"""
import os
import sqlite3
# Рассчитываем путь к общей БД data/scud_orion_ai.db в корне проекта
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db")
def clear_chat_history():
if not os.path.exists(DB_PATH):
print(f"❌ База данных не найдена по адресу: {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Очищаем таблицы сообщений и стейтов
try:
cursor.execute("DELETE FROM chat_messages;")
except sqlite3.OperationalError:
pass
try:
cursor.execute("DELETE FROM session_states;")
except sqlite3.OperationalError:
pass
try:
cursor.execute("DELETE FROM chat_sessions;")
except sqlite3.OperationalError:
pass
conn.commit()
conn.close()
print("✓ [SUCCESS] История сообщений чата и сессионные состояния успешно очищены!")
if __name__ == "__main__":
clear_chat_history()
File: ./modules/web_api/scripts/diagnostics/inspect_db.py
import os
import sqlite3
# Автопоиск файла базы данных в проекте
db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db'
print("=" * 80)
print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
print("=" * 80)
if not os.path.exists(db_path):
print(f"❌ Файл базы данных {db_path} не найден!")
exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 1. Список всех таблиц и колонок
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
tables = [t[0] for t in cursor.fetchall()]
print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
print("-" * 80)
for t_name in tables:
cursor.execute(f"PRAGMA table_info({t_name})")
cols = [c[1] for c in cursor.fetchall()]
cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
count = cursor.fetchone()[0]
print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
# 2. Просмотр правил Базы Знаний
if 'ai_knowledge_base' in tables:
print("\n" + "=" * 80)
print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
print("=" * 80)
cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
rules = cursor.fetchall()
if not rules:
print("Таблица ai_knowledge_base пуста.")
else:
for r_id, r_text, r_author in rules:
print(f" {r_id}. [{r_author}] {r_text}\n")
conn.close()
print("=" * 80)
File: ./modules/web_api/scripts/diagnostics/inspect_files.py
import os
print("=" * 80)
print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)")
print("=" * 80)
total_files = 0
total_size = 0
for root, dirs, files in os.walk('.'):
# Исключаем служебные каталоги
dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
for f in files:
p = os.path.join(root, f)
size = os.path.getsize(p)
total_files += 1
total_size += size
print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
print("-" * 80)
print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
print("=" * 80)
File: ./modules/web_api/scripts/diagnostics/make_code_snapshot.py
import os
OUTPUT_SNAPSHOT = "api_code_snapshot.md"
# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'}
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n")
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
filepath = os.path.join(root, file)
out.write(f"## File: `{filepath}`\n")
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
except Exception as e:
out.write(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\n")
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
File: ./modules/web_api/scripts/diagnostics/show_tree.py
import os
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
def print_tree(startpath):
print("=" * 60)
print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА")
print("=" * 60)
for root, dirs, files in os.walk(startpath):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
level = root.replace(startpath, '').count(os.sep)
indent = ' ' * 4 * (level)
print(f'{indent}📁 {os.path.basename(root)}/')
subindent = ' ' * 4 * (level + 1)
for f in sorted(files):
if not f.endswith('.pyc'):
print(f'{subindent}📄 {f}')
print("=" * 60)
if __name__ == "__main__":
print_tree('.')
File: ./modules/web_api/static/index.html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>SCUD Orion AI — Context Manager</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
<!-- ANCHOR[AUTH_MODAL]: Модальное окно входа -->
<div id="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200">
<div class="flex items-center space-x-3 mb-6">
<div class="bg-indigo-600 text-white p-3 rounded-xl">
<i class="fa-solid fa-user-shield text-xl"></i>
</div>
<div>
<h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2>
<p class="text-xs text-slate-500">Авторизация в системе</p>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Имя пользователя</label>
<input type="text" id="auth-username-input" placeholder="Введите логин..." required autocomplete="username"
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
</div>
<div>
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Пароль</label>
<input type="password" id="auth-password-input" placeholder="Введите пароль..." required autocomplete="current-password"
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
</div>
<div id="auth-error" class="hidden text-xs text-red-600 font-medium bg-red-50 p-3 rounded-xl border border-red-200"></div>
<button type="button" onclick="handleLogin()" id="auth-btn" class="w-full bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-semibold py-3 rounded-xl text-sm transition shadow-md flex items-center justify-center gap-2">
<i class="fa-solid fa-right-to-bracket"></i>
<span>Войти в систему</span>
</button>
</div>
<div class="relative my-4">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
<div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-medium">Или</span></div>
</div>
<button type="button" onclick="enableGuestMode()" class="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-2.5 rounded-xl text-xs transition border border-slate-300 flex items-center justify-center gap-2">
<i class="fa-solid fa-user-ninja"></i>
<span>Войти как гость (Локальный ИИ)</span>
</button>
</div>
</div>
<!-- ANCHOR[CHANGE_PWD_MODAL]: Модальное окно смены пароля -->
<div id="change-pwd-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-slate-200">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
</h3>
<button type="button" onclick="closeChangePasswordModal()" class="text-slate-400 hover:text-slate-700">
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Старый пароль</label>
<input type="password" id="old-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Новый пароль</label>
<input type="password" id="new-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div>
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Повторите новый пароль</label>
<input type="password" id="confirm-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
</div>
<div id="pwd-error" class="hidden text-xs text-red-600 bg-red-50 p-2 rounded-lg border border-red-200"></div>
<div id="pwd-success" class="hidden text-xs text-emerald-600 bg-emerald-50 p-2 rounded-lg border border-emerald-200"></div>
<button type="button" onclick="handleChangePassword()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2.5 rounded-xl text-xs transition shadow-sm mt-2">
Сохранить новый пароль
</button>
</div>
</div>
</div>
<!-- ANCHOR[ADMIN_MODAL]: Модальное окно управления пользователями -->
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
<div class="bg-white rounded-2xl p-6 max-w-lg w-full shadow-2xl border border-slate-200 flex flex-col max-h-[85vh]">
<div class="flex justify-between items-center mb-4 pb-2 border-b border-slate-200">
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление пользователями
</h3>
<button type="button" onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-700">
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
<div class="space-y-2 mb-4 bg-slate-50 p-3.5 rounded-xl border border-slate-200 shrink-0">
<p class="text-[11px] font-bold text-slate-700 uppercase">Создать нового пользователя</p>
<div class="grid grid-cols-2 gap-2">
<input type="text" id="new-user-name" placeholder="Логин *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
<input type="password" id="new-user-pwd" placeholder="Пароль *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
</div>
<input type="text" id="new-user-fullname" placeholder="ФИО (необязательно)" class="w-full bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
<div class="flex items-center justify-between pt-1">
<label class="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
<input type="checkbox" id="new-user-is-admin" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Права администратора</span>
</label>
<button type="button" onclick="handleCreateUser()" class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-1.5 rounded-lg text-xs transition">
+ Добавить
</button>
</div>
<div id="admin-msg" class="hidden text-[11px] text-red-600 pt-1"></div>
</div>
<div class="flex-1 overflow-y-auto space-y-2 pr-1" id="admin-users-list">
<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>
</div>
</div>
</div>
<!-- ANCHOR[HEADER_BAR]: Хедер интерфейса -->
<header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20">
<div class="flex items-center space-x-2.5">
<div class="bg-indigo-600 text-white p-2 rounded-xl shrink-0">
<i class="fa-solid fa-brain text-lg"></i>
</div>
<div>
<h1 class="text-sm font-bold text-slate-900 leading-tight">SCUD Orion AI</h1>
<span class="text-[11px] text-slate-500">Task & Context API</span>
</div>
</div>
<div class="flex items-center space-x-1.5">
<span id="guest-badge" class="hidden text-[10px] text-amber-700 font-semibold bg-amber-50 px-2 py-0.5 rounded-full border border-amber-200">
Гость
</span>
<span id="username-badge" class="hidden text-xs text-indigo-700 font-bold bg-indigo-50 px-2.5 py-1 rounded-full border border-indigo-200">
puh
</span>
<button id="admin-users-btn" type="button" onclick="openAdminModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Управление пользователями">
<i class="fa-solid fa-users-gear text-base"></i>
</button>
<button id="change-pwd-btn" type="button" onclick="openChangePasswordModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Сменить пароль">
<i class="fa-solid fa-key text-base"></i>
</button>
<button id="tasks-drawer-btn" type="button" onclick="toggleDrawer()" class="bg-indigo-600 active:bg-indigo-700 text-white px-3 py-1.5 rounded-xl text-xs font-semibold flex items-center gap-1.5 shadow-sm">
<i class="fa-solid fa-list-check"></i>
<span>Задачи</span>
<span id="task-count-badge" class="bg-white text-indigo-700 text-[10px] font-bold px-1.5 py-0.2 rounded-full">0</span>
</button>
<button type="button" onclick="logout()" class="text-slate-400 hover:text-red-600 transition p-2 rounded-xl" title="Выйти">
<i class="fa-solid fa-right-from-bracket text-base"></i>
</button>
</div>
</header>
<!-- ANCHOR[CHAT_MAIN_CONTAINER]: Главный контейнер чата -->
<div class="flex-1 flex flex-col min-h-0 w-full max-w-4xl mx-auto bg-white relative overflow-hidden">
<div id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50">
<div id="drop-overlay" class="absolute inset-0 bg-indigo-600/10 backdrop-blur-sm border-2 border-dashed border-indigo-600 rounded-2xl hidden flex-col items-center justify-center z-30 transition-all pointer-events-none">
<div class="bg-white p-4 rounded-2xl shadow-xl flex flex-col items-center gap-2">
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
<p class="text-sm font-bold text-slate-800">Перетащите файл сюда</p>
<p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
</div>
</div>
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
</p>
<p class="text-slate-800 text-xs sm:text-sm leading-relaxed">
Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
</p>
</div>
</div>
<!-- Превью прикрепленного файла -->
<div id="file-preview-container" class="hidden px-4 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between text-xs text-slate-700">
<div class="flex items-center gap-2 truncate">
<i class="fa-solid fa-paperclip text-indigo-600"></i>
<span id="file-name-display" class="font-medium truncate">file.pdf</span>
<span id="file-size-display" class="text-slate-400 text-[10px]">(0 KB)</span>
</div>
<button type="button" onclick="clearAttachedFile()" class="text-slate-400 hover:text-red-500 p-1 transition">
<i class="fa-solid fa-xmark text-sm"></i>
</button>
</div>
<!-- Поле ввода -->
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
<div class="flex items-center gap-2">
<input type="file" id="file-input" class="hidden" onchange="handleFileSelect(event)" accept=".png,.jpg,.jpeg,.pdf,.txt,.csv,.xlsx">
<button type="button" onclick="document.getElementById('file-input').click()" class="text-slate-500 hover:text-indigo-600 p-2 rounded-xl transition" title="Прикрепить файл">
<i class="fa-solid fa-paperclip text-lg"></i>
</button>
<div class="flex-1 bg-slate-100 border border-slate-300 rounded-2xl px-3 py-1.5 focus-within:border-indigo-600 focus-within:bg-white transition">
<textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
placeholder="Команда, вопрос или перетащите файл сюда..."
class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto h-[24px] max-h-[120px] leading-[24px] fade-scroll-top no-scrollbar"></textarea>
</div>
<button type="button" id="send-btn" onclick="sendMessage()" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm">
<span>Отправить</span>
<i class="fa-solid fa-paper-plane text-xs"></i>
</button>
</div>
</div>
</div>
<!-- ANCHOR[TASK_DRAWER_CONTAINER]: Боковая панель задач -->
<div id="drawer-backdrop" onclick="toggleDrawer()" class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm hidden transition-opacity z-40"></div>
<aside id="task-drawer" class="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-white border-l border-slate-200 shadow-2xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col">
<div class="p-3.5 border-b border-slate-200 flex justify-between items-center bg-slate-50 shrink-0">
<h2 class="font-bold text-slate-800 flex items-center gap-2 text-sm">
<i class="fa-solid fa-list-check text-indigo-600"></i> Мой реестр задач
</h2>
<div class="flex items-center gap-3">
<button type="button" onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
<i class="fa-solid fa-rotate-right text-sm"></i>
</button>
<button type="button" onclick="toggleDrawer()" class="text-slate-500 hover:text-slate-800 transition p-1">
<i class="fa-solid fa-xmark text-lg"></i>
</button>
</div>
</div>
<!-- ANCHOR[DRAWER_TAB_RENAMING]: Вкладки фильтрации боковой панели -->
<div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-semibold text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0">
<button type="button" onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button>
<button type="button" onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В работе</button>
<button type="button" onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В планах</button>
<button type="button" onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершенные</button>
</div>
<div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
<div class="text-center text-slate-400 py-8 text-xs">Загрузка ваших задач...</div>
</div>
</aside>
<script src="/static/js/auth.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/chat.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
File: ./modules/web_api/static/js/app.js
const AUTH_TOKEN_KEY = "scud_api_auth_token";
const SESSION_ID = "web_session_main";
const STORAGE_KEY = "scud_chat_input_history";
let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
let CURRENT_USERNAME = localStorage.getItem("scud_username") || "";
let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true";
let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
let historyIndex = -1;
document.addEventListener("DOMContentLoaded", () => {
const userInputEl = document.getElementById("user-input");
if (userInputEl) {
userInputEl.addEventListener("input", function() {
this.style.height = "24px";
const newHeight = Math.min(this.scrollHeight, 120);
this.style.height = newHeight + "px";
});
}
if (IS_GUEST) {
hideAuthModal();
updateUIState();
} else if (API_TOKEN) {
hideAuthModal();
updateUIState();
if (typeof loadTasks === "function") {
loadTasks();
}
} else {
showAuthModal();
}
});
File: ./modules/web_api/static/js/auth.js
function showAuthModal() {
const el = document.getElementById("auth-modal");
if (el) el.classList.remove("hidden");
}
function hideAuthModal() {
const el = document.getElementById("auth-modal");
if (el) el.classList.add("hidden");
}
async function handleLogin(e) {
if (e && e.preventDefault) e.preventDefault();
const usernameInput = document.getElementById("auth-username-input");
const passwordInput = document.getElementById("auth-password-input");
const errorEl = document.getElementById("auth-error");
if (!usernameInput || !passwordInput) return;
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!username || !password) return;
if (errorEl) errorEl.classList.add("hidden");
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.status === 200) {
// Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError
API_TOKEN = data.token;
CURRENT_USERNAME = data.username;
IS_ADMIN = data.is_admin;
IS_GUEST = false;
localStorage.setItem("scud_api_auth_token", data.token);
localStorage.setItem("scud_username", data.username);
localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false");
localStorage.removeItem("scud_is_guest");
hideAuthModal();
updateUIState();
if (typeof loadTasks === 'function') {
loadTasks();
}
} else {
if (errorEl) {
errorEl.innerText = data.detail || "Ошибка авторизации";
errorEl.classList.remove("hidden");
}
}
} catch (err) {
console.error("[Auth Error]", err);
if (errorEl) {
errorEl.innerText = "Ошибка соединения с сервером";
errorEl.classList.remove("hidden");
}
}
}
function enableGuestMode() {
IS_GUEST = true;
API_TOKEN = "";
CURRENT_USERNAME = "Гость";
IS_ADMIN = false;
localStorage.setItem("scud_is_guest", "true");
hideAuthModal();
updateUIState();
}
function logout() {
localStorage.removeItem("scud_api_auth_token");
localStorage.removeItem("scud_username");
localStorage.removeItem("scud_is_admin");
localStorage.removeItem("scud_is_guest");
API_TOKEN = "";
CURRENT_USERNAME = "";
IS_ADMIN = false;
IS_GUEST = false;
showAuthModal();
}
function updateUIState() {
const tasksBtn = document.getElementById("tasks-drawer-btn");
const adminBtn = document.getElementById("admin-users-btn");
const changePwdBtn = document.getElementById("change-pwd-btn");
const guestBadge = document.getElementById("guest-badge");
const usernameBadge = document.getElementById("username-badge");
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) {
if (tasksBtn) tasksBtn.classList.add("hidden");
if (adminBtn) adminBtn.classList.add("hidden");
if (changePwdBtn) changePwdBtn.classList.add("hidden");
if (guestBadge) guestBadge.classList.remove("hidden");
if (usernameBadge) usernameBadge.classList.add("hidden");
} else {
if (tasksBtn) tasksBtn.classList.remove("hidden");
if (changePwdBtn) changePwdBtn.classList.remove("hidden");
if (guestBadge) guestBadge.classList.add("hidden");
if (usernameBadge) {
usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User";
usernameBadge.classList.remove("hidden");
}
if (adminBtn) {
const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh");
if (isAdminUser) {
adminBtn.classList.remove("hidden");
} else {
adminBtn.classList.add("hidden");
}
}
}
}
function openChangePasswordModal() {
const el = document.getElementById("change-pwd-modal");
if (el) el.classList.remove("hidden");
}
function closeChangePasswordModal() {
const el = document.getElementById("change-pwd-modal");
if (el) el.classList.add("hidden");
const err = document.getElementById("pwd-error");
const succ = document.getElementById("pwd-success");
if (err) err.classList.add("hidden");
if (succ) succ.classList.add("hidden");
document.getElementById("old-pwd-input").value = "";
document.getElementById("new-pwd-input").value = "";
const confirmInput = document.getElementById("confirm-pwd-input");
if (confirmInput) confirmInput.value = "";
}
async function handleChangePassword(e) {
if (e && e.preventDefault) e.preventDefault();
const old_password = document.getElementById("old-pwd-input").value;
const new_password = document.getElementById("new-pwd-input").value;
const confirmInput = document.getElementById("confirm-pwd-input");
const confirm_password = confirmInput ? confirmInput.value : new_password;
const errorEl = document.getElementById("pwd-error");
const successEl = document.getElementById("pwd-success");
if (errorEl) errorEl.classList.add("hidden");
if (successEl) successEl.classList.add("hidden");
if (new_password !== confirm_password) {
if (errorEl) {
errorEl.innerText = "Новые пароли не совпадают";
errorEl.classList.remove("hidden");
}
return;
}
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/auth/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ old_password, new_password })
});
const data = await res.json();
if (res.status === 200) {
if (successEl) {
successEl.innerText = "Пароль успешно изменен!";
successEl.classList.remove("hidden");
}
setTimeout(closeChangePasswordModal, 1500);
} else {
if (errorEl) {
errorEl.innerText = data.detail || "Ошибка при смене пароля";
errorEl.classList.remove("hidden");
}
}
} catch (err) {
if (errorEl) {
errorEl.innerText = "Ошибка соединения с сервером";
errorEl.classList.remove("hidden");
}
}
}
function openAdminModal() {
const el = document.getElementById("admin-modal");
if (el) el.classList.remove("hidden");
loadUsersList();
}
function closeAdminModal() {
const el = document.getElementById("admin-modal");
if (el) el.classList.add("hidden");
}
async function loadUsersList() {
const listEl = document.getElementById("admin-users-list");
if (!listEl) return;
listEl.innerHTML = '<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>';
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/admin/users", {
headers: { "Authorization": "Bearer " + token }
});
const users = await res.json();
if (res.status === 200) {
listEl.innerHTML = users.map(u => {
const adminTag = u.is_admin ? '<span class="ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-bold">ADMIN</span>' : '<span class="ml-1.5 text-[9px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded">USER</span>';
const fullNameHtml = u.full_name ? `<div class="text-[11px] text-slate-500 font-normal">${u.full_name}</div>` : '';
const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—';
const deleteBtn = u.username !== CURRENT_USERNAME ? `<button type="button" onclick="deleteUser(${u.id}, '${u.username}')" class="text-red-500 hover:text-red-700 p-1"><i class="fa-solid fa-trash-can"></i></button>` : '<span class="text-[10px] text-slate-400">Вы</span>';
return `
<div class="flex justify-between items-center bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-xs">
<div>
<div class="flex items-center">
<span class="font-bold text-slate-800">${u.username}</span>
${adminTag}
</div>
${fullNameHtml}
<div class="text-[10px] text-slate-400 mt-0.5">Создан: ${dateStr}</div>
</div>
${deleteBtn}
</div>
`;
}).join("");
} else {
listEl.innerHTML = `<div class="text-xs text-red-500 py-2">${users.detail}</div>`;
}
} catch (err) {
listEl.innerHTML = '<div class="text-xs text-red-500 py-2">Ошибка загрузки пользователей</div>';
}
}
async function handleCreateUser(e) {
if (e && e.preventDefault) e.preventDefault();
const username = document.getElementById("new-user-name").value.trim();
const password = document.getElementById("new-user-pwd").value;
const fullNameInput = document.getElementById("new-user-fullname");
const full_name = fullNameInput ? fullNameInput.value.trim() : "";
const adminCheckbox = document.getElementById("new-user-is-admin");
const is_admin = adminCheckbox ? adminCheckbox.checked : false;
const msgEl = document.getElementById("admin-msg");
if (msgEl) msgEl.classList.add("hidden");
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const res = await fetch("/api/v1/admin/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ username, password, full_name, is_admin })
});
const data = await res.json();
if (res.status === 200) {
document.getElementById("new-user-name").value = "";
document.getElementById("new-user-pwd").value = "";
if (fullNameInput) fullNameInput.value = "";
if (adminCheckbox) adminCheckbox.checked = false;
loadUsersList();
} else {
if (msgEl) {
msgEl.innerText = data.detail || "Ошибка";
msgEl.classList.remove("hidden");
}
}
} catch (err) {
if (msgEl) {
msgEl.innerText = "Ошибка связи с сервером";
msgEl.classList.remove("hidden");
}
}
}
async function deleteUser(userId, username) {
if (!confirm("Удалить пользователя " + username + "?")) return;
try {
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
await fetch("/api/v1/admin/users/" + userId, {
method: "DELETE",
headers: { "Authorization": "Bearer " + token }
});
loadUsersList();
} catch (err) {
alert("Ошибка при удалении");
}
}
File: ./modules/web_api/static/js/chat.js
/**
===============================================================================
FILE: modules/web_api/static/js/chat.js
ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, генерация динамических
кнопок подтверждений и блокировка ввода при активном действии.
===============================================================================
*/
function updateInputHeight(el) {
if (!el) return;
el.style.height = "24px";
const newHeight = Math.min(el.scrollHeight, 120);
el.style.height = newHeight + "px";
}
let selectedFile = null;
function handleFileSelect(e) {
const file = e.target.files[0];
if (!file) return;
if (file.size > 15 * 1024 * 1024) {
alert("Файл слишком большой. Максимальный размер: 15 МБ");
e.target.value = "";
return;
}
selectedFile = file;
const fileNameEl = document.getElementById("file-name-display");
const fileSizeEl = document.getElementById("file-size-display");
const previewContainer = document.getElementById("file-preview-container");
if (fileNameEl) fileNameEl.innerText = file.name;
if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`;
if (previewContainer) previewContainer.classList.remove("hidden");
}
function clearAttachedFile() {
selectedFile = null;
const fileInput = document.getElementById("file-input");
const previewContainer = document.getElementById("file-preview-container");
if (fileInput) fileInput.value = "";
if (previewContainer) previewContainer.classList.add("hidden");
}
function setInputLocked(isLocked) {
const input = document.getElementById("user-input");
const sendBtn = document.getElementById("send-btn");
if (input) {
input.disabled = isLocked;
if (isLocked) {
input.placeholder = "Выберите действие с помощью кнопок выше...";
input.classList.add("cursor-not-allowed", "opacity-60");
} else {
input.placeholder = "Команда, вопрос или перетащите файл сюда...";
input.classList.remove("cursor-not-allowed", "opacity-60");
}
}
if (sendBtn) {
sendBtn.disabled = isLocked;
if (isLocked) {
sendBtn.classList.add("opacity-40", "cursor-not-allowed");
} else {
sendBtn.classList.remove("opacity-40", "cursor-not-allowed");
}
}
}
function disableAllActionButtons() {
const allBtnContainers = document.querySelectorAll(".action-buttons-container");
allBtnContainers.forEach(container => {
container.querySelectorAll("button").forEach(btn => {
btn.disabled = true;
btn.classList.add("opacity-40", "cursor-not-allowed");
});
});
}
function handleActionButtonClick(text) {
disableAllActionButtons();
setInputLocked(false);
const input = document.getElementById("user-input");
if (input) {
input.value = text;
sendMessage();
}
}
// --- [INTERACTIVE TASK WIDGET] ---
let activeWidgetTasksMap = new Map();
function renderTaskItemsHtml(tasks, filterStatus) {
const filtered = tasks.filter(t => {
if (filterStatus === "ALL") return true;
return t.status === filterStatus;
});
if (filtered.length === 0) {
const label = filterStatus === 'BACKLOG' ? 'В планах' : filterStatus === 'IN_PROGRESS' ? 'В работе' : filterStatus === 'COMPLETED' ? 'Завершенные' : 'Все';
return `<div class="text-slate-400 text-xs py-4 text-center">Нет задач со статусом «${label}»</div>`;
}
return filtered.map(t => {
const isDone = t.status === "COMPLETED";
const isProgress = t.status === "IN_PROGRESS";
const checkedAttr = isDone ? "checked" : "";
const textClass = isDone ? "line-through text-slate-400" : "text-slate-800 font-medium";
let statusPill = `<span class="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded border border-slate-200">В планах</span>`;
if (isDone) {
statusPill = `<span class="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded border border-emerald-300 font-semibold">Завершено</span>`;
} else if (isProgress) {
statusPill = `<span class="text-[10px] bg-amber-50 text-amber-700 px-2 py-0.5 rounded border border-amber-300 font-semibold">В работе</span>`;
}
let prioPill = "";
if (t.priority === "HIGH") {
prioPill = `<span class="text-[9px] bg-red-50 text-red-700 font-bold px-1.5 py-0.5 rounded border border-red-200 uppercase">HIGH</span>`;
}
return `
<div class="flex items-center justify-between p-2.5 bg-slate-50 hover:bg-slate-100/80 rounded-xl border border-slate-200 transition gap-2" id="widget-task-${t.task_id}">
<div class="flex items-center gap-2.5 flex-1 min-w-0">
<input type="checkbox" ${checkedAttr} onchange="toggleTaskStatusInline('${t.task_id}', this.checked)"
class="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500 cursor-pointer">
<div class="flex flex-col min-w-0 flex-1">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="font-mono text-[11px] font-bold text-slate-700">${t.task_id}</span>
${prioPill}
${statusPill}
</div>
<span class="text-xs ${textClass} truncate leading-tight">${escapeHtml(t.title)}</span>
</div>
</div>
<button type="button" onclick="deleteTaskInline('${t.task_id}')" class="text-slate-400 hover:text-red-500 p-1.5 transition rounded-lg hover:bg-red-50" title="Удалить задачу">
<i class="fa-solid fa-trash-can text-xs"></i>
</button>
</div>
`;
}).join("");
}
function renderInteractiveTaskCard(tasks) {
if (!tasks || !Array.isArray(tasks)) return "";
const widgetId = "task_widget_" + Date.now();
activeWidgetTasksMap.set(widgetId, { tasks: tasks, filter: "ALL" });
const itemsHtml = renderTaskItemsHtml(tasks, "ALL");
return `
<div class="task-widget-card mt-3 bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm space-y-3" id="${widgetId}">
<div class="flex items-center gap-1 pb-2 border-b border-slate-100 overflow-x-auto no-scrollbar text-xs font-semibold">
<button type="button" onclick="filterTaskWidget('${widgetId}', 'ALL', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200">Все</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'IN_PROGRESS', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В работе</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'BACKLOG', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">В планах</button>
<button type="button" onclick="filterTaskWidget('${widgetId}', 'COMPLETED', this)" class="widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700">Завершенные</button>
</div>
<div class="widget-items-container space-y-2 max-h-72 overflow-y-auto pr-1">
${itemsHtml}
</div>
<div class="pt-2 border-t border-slate-100 flex items-center gap-2">
<input type="text" placeholder="Новая задача..." class="flex-1 bg-slate-50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs text-slate-800 focus:outline-none focus:border-indigo-600 focus:bg-white transition" onkeydown="if(event.key==='Enter') addTaskInline('${widgetId}', this)">
<button type="button" onclick="addTaskInline('${widgetId}', this.previousElementSibling)" class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white px-3 py-1.5 rounded-xl text-xs font-semibold shadow-sm transition flex items-center gap-1">
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
</button>
</div>
</div>
`;
}
function filterTaskWidget(widgetId, status, btnEl) {
const state = activeWidgetTasksMap.get(widgetId);
if (!state) return;
state.filter = status;
const widgetEl = document.getElementById(widgetId);
if (!widgetEl) return;
widgetEl.querySelectorAll(".widget-tab-btn").forEach(b => {
b.className = "widget-tab-btn px-2.5 py-1 rounded-lg text-slate-500 hover:text-slate-700";
});
btnEl.className = "widget-tab-btn px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 font-bold border border-indigo-200";
const container = widgetEl.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, status);
}
}
async function toggleTaskStatusInline(taskId, isChecked) {
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const newStatus = isChecked ? "COMPLETED" : "BACKLOG";
try {
const res = await fetch(`/api/v1/tasks/${taskId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ status: newStatus })
});
if (res.ok) {
for (let [wId, state] of activeWidgetTasksMap.entries()) {
const targetTask = state.tasks.find(t => t.task_id === taskId);
if (targetTask) {
targetTask.status = newStatus;
const widgetEl = document.getElementById(wId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
}
}
}
if (typeof loadTasks === "function") loadTasks();
}
} catch (err) {
console.error("[Task Patch Error]", err);
}
}
async function deleteTaskInline(taskId) {
if (!confirm(`Удалить задачу ${taskId}?`)) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
try {
const res = await fetch(`/api/v1/tasks/${taskId}`, {
method: "DELETE",
headers: { "Authorization": "Bearer " + token }
});
if (res.ok) {
for (let [wId, state] of activeWidgetTasksMap.entries()) {
state.tasks = state.tasks.filter(t => t.task_id !== taskId);
const widgetEl = document.getElementById(wId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(state.tasks, state.filter);
}
}
if (typeof loadTasks === "function") loadTasks();
}
} catch (err) {
console.error("[Task Delete Error]", err);
}
}
async function addTaskInline(widgetId, inputEl) {
if (!inputEl) return;
const title = inputEl.value.trim();
if (!title) return;
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
try {
const res = await fetch("/api/v1/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ title: title, priority: "MEDIUM", module: "general" })
});
if (res.ok) {
inputEl.value = "";
const tasksRes = await fetch("/api/v1/tasks", {
headers: { "Authorization": "Bearer " + token }
});
const updatedTasks = await tasksRes.json();
const state = activeWidgetTasksMap.get(widgetId);
if (state) {
state.tasks = updatedTasks;
const widgetEl = document.getElementById(widgetId);
const container = widgetEl?.querySelector(".widget-items-container");
if (container) {
container.innerHTML = renderTaskItemsHtml(updatedTasks, state.filter);
}
}
if (typeof loadTasks === "function") loadTasks();
}
} catch (err) {
console.error("[Task Add Error]", err);
}
}
// --- [CHAT SEND PIPELINE] ---
async function sendMessage(e) {
if (e && e.preventDefault) e.preventDefault();
const input = document.getElementById("user-input");
const chatWindow = document.getElementById("chat-window");
const sendBtn = document.getElementById("send-btn");
if (!input || !chatWindow) return;
const text = input.value.trim();
if (!text && !selectedFile) return;
disableAllActionButtons();
let userDisplayHtml = escapeHtml(text);
if (selectedFile) {
userDisplayHtml = `<div class="font-bold border-b border-indigo-400/40 pb-1 mb-1 text-[11px] flex items-center gap-1.5">
<i class="fa-solid fa-file"></i> ${escapeHtml(selectedFile.name)}
</div>` + userDisplayHtml;
}
const userMsgHtml = `
<div class="flex justify-end mb-3">
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
${userDisplayHtml}
</div>
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
input.value = "";
updateInputHeight(input);
chatWindow.scrollTop = chatWindow.scrollHeight;
if (sendBtn) {
sendBtn.disabled = true;
sendBtn.classList.add("opacity-50");
}
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
const formData = new FormData();
formData.append("session_id", "web_session_main");
formData.append("message", text || "Проанализируй прикрепленный файл");
if (selectedFile instanceof File) {
formData.append("file", selectedFile, selectedFile.name);
}
const headers = {};
if (!isGuest && token) {
headers["Authorization"] = "Bearer " + token;
}
try {
const res = await fetch(endpoint, {
method: "POST",
headers: headers,
body: formData
});
if (res.status === 401 && !isGuest) {
if (typeof logout === 'function') logout();
return;
}
const data = await res.json();
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
const replyText = data.reply || "Пустой ответ от нейросети";
let actionButtonsHtml = "";
const actionData = data.action_type || data.action;
if (actionData && actionData.buttons && actionData.buttons.length > 0) {
const buttonsMarkup = actionData.buttons.map(btn => {
let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300";
let iconMarkup = '<i class="fa-solid fa-arrow-right text-[10px] opacity-60"></i>';
const labelLower = (btn.label || "").toLowerCase();
const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да");
const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш");
if (isPrimary) {
btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm";
iconMarkup = '<i class="fa-solid fa-check"></i>';
} else if (isDanger) {
btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300";
iconMarkup = '<i class="fa-solid fa-xmark"></i>';
}
return `
<button type="button" onclick="handleActionButtonClick('${escapeHtml(btn.value)}')"
class="${btnClasses} font-semibold px-3 py-1.5 rounded-xl text-xs flex items-center gap-1.5 transition cursor-pointer">
${iconMarkup}
<span>${escapeHtml(btn.label)}</span>
</button>
`;
}).join("");
actionButtonsHtml = `
<div class="action-buttons-container flex flex-wrap items-center gap-2 mt-3 pt-2.5 border-t border-slate-100">
${buttonsMarkup}
</div>
`;
// Если выведены кнопки подтверждения превью — блокируем ввод с клавиатуры
if (actionData.type === "PROMPT_PREVIEW") {
setInputLocked(true);
} else {
setInputLocked(false);
}
} else {
setInputLocked(false);
}
let interactiveWidgetHtml = "";
if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && Array.isArray(actionData.tasks)) {
interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks);
}
const botMsgHtml = `
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
</p>
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${escapeHtml(replyText)}</p>
${interactiveWidgetHtml}
${actionButtonsHtml}
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
clearAttachedFile();
if (!isGuest && typeof loadTasks === 'function') {
loadTasks();
}
} catch (err) {
console.error("[Chat Error]", err);
const errorHtml = `
<div class="bg-red-50 border border-red-200 rounded-2xl p-3.5 max-w-2xl text-red-700 text-xs sm:text-sm mb-3">
Ошибка связи с сервером.
</div>
`;
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
chatWindow.scrollTop = chatWindow.scrollHeight;
setInputLocked(false);
} finally {
if (sendBtn && !document.getElementById("user-input")?.disabled) {
sendBtn.disabled = false;
sendBtn.classList.remove("opacity-50");
}
}
}
function escapeHtml(text) {
if (!text) return "";
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("user-input");
const dropZone = document.getElementById("chat-window")?.parentElement;
const dropOverlay = document.getElementById("drop-overlay");
if (input) {
let historyIndex = -1;
let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (input.disabled) return;
const text = input.value.trim();
if (text) {
if (localHistory.length === 0 || localHistory[0] !== text) {
localHistory.unshift(text);
if (localHistory.length > 50) localHistory.pop();
localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
}
historyIndex = -1;
}
sendMessage(e);
updateInputHeight(input);
return;
}
if (e.key === "ArrowUp") {
const textBeforeCursor = input.value.substring(0, input.selectionStart);
const isFirstLine = !textBeforeCursor.includes("\n");
if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
if (historyIndex < localHistory.length - 1) {
e.preventDefault();
if (historyIndex === -1) {
input.dataset.draft = input.value;
}
historyIndex++;
input.value = localHistory[historyIndex];
updateInputHeight(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
if (e.key === "ArrowDown") {
const textAfterCursor = input.value.substring(input.selectionEnd);
const isLastLine = !textAfterCursor.includes("\n");
if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
e.preventDefault();
if (historyIndex > 0) {
historyIndex--;
input.value = localHistory[historyIndex];
} else {
historyIndex = -1;
input.value = input.dataset.draft || "";
}
updateInputHeight(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
});
}
if (dropZone && dropOverlay) {
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
["dragenter", "dragover"].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropOverlay.classList.remove("hidden");
dropOverlay.classList.add("flex");
}, false);
});
["dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
dropOverlay.classList.add("hidden");
dropOverlay.classList.remove("flex");
}
}, false);
});
dropZone.addEventListener("drop", (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files && files.length > 0) {
const file = files[0];
handleFileSelect({ target: { files: [file] } });
const fileInput = document.getElementById("file-input");
if (fileInput) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
}
}
}, false);
}
});
File: ./modules/web_api/static/js/tasks.js
/**
===============================================================================
FILE: modules/web_api/static/js/tasks.js
ROLE: Управление боковой панелью задач (Drawer), фильтрация и рендеринг карточек.
AI-CONTEXT-ANCHORS:
- ANCHOR[DRAWER_TOGGLE]: Открытие и закрытие выезжающей панели.
- ANCHOR[TASKS_FILTER]: Фильтрация списка (ALL, IN_PROGRESS, BACKLOG, COMPLETED).
- ANCHOR[TASKS_LOAD_FETCH]: Асинхронная загрузка задач через /api/v1/tasks.
- ANCHOR[TASKS_RENDER_DOM]: Генерация HTML-карточек в боковом меню.
===============================================================================
*/
let currentFilter = 'ALL';
let allTasks = [];
// --- [SECTION 1: DRAWER TOGGLE] --- # ANCHOR[DRAWER_TOGGLE]
function toggleDrawer() {
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
const drawer = document.getElementById("task-drawer");
const backdrop = document.getElementById("drawer-backdrop");
if (!drawer) return;
const isHidden = drawer.classList.contains("translate-x-full");
if (isHidden) {
drawer.classList.remove("translate-x-full");
if (backdrop) backdrop.classList.remove("hidden");
loadTasks();
} else {
drawer.classList.add("translate-x-full");
if (backdrop) backdrop.classList.add("hidden");
}
}
// --- [SECTION 2: FILTER CONTROL] --- # ANCHOR[TASKS_FILTER]
function setFilter(status) {
currentFilter = status;
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
const btn = document.getElementById(`filter-${f}`);
if (btn) {
btn.className = (f === status)
? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap"
: "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap";
}
});
renderTasks();
}
// --- [SECTION 3: ASYNC DATA FETCH] --- # ANCHOR[TASKS_LOAD_FETCH]
async function loadTasks() {
const badge = document.getElementById("task-count-badge");
const container = document.getElementById("tasks-container");
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
if (isGuest || !token) {
if (badge) badge.innerText = "0";
return;
}
try {
const res = await fetch("/api/v1/tasks", {
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/json"
}
});
if (res.status === 401) {
if (typeof logout === 'function') logout();
return;
}
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
if (Array.isArray(data)) {
allTasks = data;
} else if (data && Array.isArray(data.tasks)) {
allTasks = data.tasks;
} else if (data && typeof data === 'object') {
allTasks = Object.values(data).find(val => Array.isArray(val)) || [];
} else {
allTasks = [];
}
if (badge) {
badge.innerText = allTasks.length.toString();
}
renderTasks();
} catch (err) {
console.error("[Tasks Error]", err);
if (badge) badge.innerText = "0";
if (container) {
container.innerHTML = `<div class="text-red-500 text-xs py-8 text-center font-medium">Ошибка обработки списка задач</div>`;
}
}
}
// --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM]
function renderTasks() {
const container = document.getElementById("tasks-container");
if (!container) return;
if (!Array.isArray(allTasks)) {
allTasks = [];
}
const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
if (filtered.length === 0) {
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
return;
}
container.innerHTML = filtered.map(t => {
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
let statusLabel = "В планах";
let cardBg = "bg-white";
if (t.status === "COMPLETED") {
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
statusLabel = "Завершено";
cardBg = "bg-emerald-50/20";
} else if (t.status === "IN_PROGRESS") {
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
statusLabel = "В работе";
cardBg = "bg-amber-50/20 border-amber-200";
}
let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200";
if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold";
let dueDateHtml = t.due_date ? `
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
<i class="fa-solid fa-clock text-amber-600"></i>
<span>Срок: ${t.due_date}</span>
</div>` : "";
return `
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
<div class="flex justify-between items-center mb-1.5">
<div class="flex items-center gap-1.5">
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id || t.id || 'TASK'}</span>
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
</div>
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${statusLabel}</span>
</div>
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
<i class="fa-solid fa-folder-closed text-slate-300"></i>
<span>${t.module || 'General'}</span>
</div>
${dueDateHtml}
</div>
`;
}).join("");
}
File: ./modules/web_api/static/css/styles.css
/* Плавное исчезновение текста сверху при скролле */
.fade-scroll-top {
mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
}
/* Скрытие стандартного скроллбара */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Оптимизация под мобильный viewport (борьба со скачками клавиатуры на iOS/Android) */
body {
min-height: 100vh;
min-height: -webkit-fill-available;
}
File: ./modules/web_api/routers/admin.py
"""
===============================================================================
FILE: modules/web_api/routers/admin.py
ROLE: Администрирование пользователей и прав доступа.
===============================================================================
"""
# ANCHOR[ADMIN_ROUTER_IMPORTS]
import logging
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .auth import get_current_user, get_db, pwd_context
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
# ANCHOR[ADMIN_SCHEMAS]
class CreateUserRequest(BaseModel):
username: str
password: str
full_name: Optional[str] = None
is_admin: Optional[bool] = False
# ANCHOR[ADMIN_ENDPOINTS]
@router.get("/users")
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
users = [dict(r) for r in cursor.fetchall()]
conn.close()
return users
@router.post("/users")
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
username = req.username.strip().lower()
if not username or not req.password:
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
pwd_hash = pwd_context.hash(req.password)
full_name = req.full_name.strip() if req.full_name else None
is_admin = 1 if req.is_admin else 0
cursor.execute(
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
(username, pwd_hash, full_name, is_admin)
)
conn.commit()
conn.close()
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
return {"status": "success", "message": f"Пользователь {username} создан"}
@router.delete("/users/{user_id}")
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
if user_id == current_user["id"]:
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
logging.info(f"Удален пользователь ID: {user_id}")
return {"status": "success", "message": "Пользователь удален"}
File: ./modules/web_api/routers/auth.py
"""
===============================================================================
FILE: modules/web_api/routers/auth.py
ROLE: Аутентификация, валидация JWT-токенов и управление паролями.
===============================================================================
"""
# ANCHOR[AUTH_ROUTER_IMPORTS]
import sqlite3
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
import jwt
from passlib.context import CryptContext
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from llm.db_tools import DB_PATH
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# ANCHOR[AUTH_DB_HELPERS]
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
payload = {
"sub": str(user_id),
"username": username,
"is_admin": is_admin,
"exp": datetime.utcnow() + timedelta(days=30)
}
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
try:
token = credentials.credentials
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
user_id = int(payload.get("sub"))
username = payload.get("username")
is_admin = bool(payload.get("is_admin", False))
return {"id": user_id, "username": username, "is_admin": is_admin}
except Exception as e:
logging.warning(f"Auth error: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный или просроченный токен авторизации",
headers={"WWW-Authenticate": "Bearer"},
)
# ANCHOR[AUTH_SCHEMAS]
class AuthRequest(BaseModel):
username: str
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
# ANCHOR[AUTH_ENDPOINTS]
@router.post("/login")
def login(req: AuthRequest):
username = req.username.strip().lower()
logging.info(f"===> Попытка входа для пользователя: {username}")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
conn.close()
if not user or not pwd_context.verify(req.password, user["password_hash"]):
logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}")
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin)
logging.info(f"===> УСПЕХ: Авторизован пользователь {username}")
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
@router.post("/change-password")
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not req.new_password or len(req.new_password) < 4:
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
user = cursor.fetchone()
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
conn.close()
raise HTTPException(status_code=400, detail="Неверный старый пароль")
new_hash = pwd_context.hash(req.new_password)
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
conn.commit()
conn.close()
logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
return {"status": "success", "message": "Пароль успешно изменен"}
File: ./modules/web_api/routers/chat.py
"""
===============================================================================
FILE: modules/web_api/routers/chat.py
ROLE: Маршрутизация диалогов с LLM (авторизованный и гостевой чаты).
===============================================================================
"""
# ANCHOR[CHAT_ROUTER_IMPORTS]
from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, UploadFile, File, Form
from .auth import get_current_user
from llm.agent import process_chat_message
from llm.file_parser import extract_text_from_file
router = APIRouter(prefix="/api/v1/chat", tags=["chat"])
# ANCHOR[CHAT_ENDPOINTS]
@router.post("")
async def chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Диалог авторизованного пользователя с агентом."""
parsed_file = {"text": "", "image_b64": None}
if file and file.filename:
file_bytes = await file.read()
parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history, action_type = process_chat_message(
user_id=current_user["id"],
user_message=message,
file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id
)
return {"reply": reply, "history": history, "action_type": action_type}
@router.post("/guest")
async def guest_chat_endpoint(
session_id: str = Form("web_session_main"),
message: str = Form(""),
file: Optional[UploadFile] = File(default=None)
):
"""Гостевой диалог (user_id=0)."""
parsed_file = {"text": "", "image_b64": None}
if file and file.filename:
file_bytes = await file.read()
parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history, action_type = process_chat_message(
user_id=0,
user_message=message,
file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id
)
return {"reply": reply, "history": history, "action_type": action_type}
File: ./modules/web_api/routers/tasks.py
"""
===============================================================================
FILE: modules/web_api/routers/tasks.py
ROLE: REST API управления задачами (GET / POST / PATCH / DELETE).
===============================================================================
"""
# ANCHOR[TASKS_ROUTER_IMPORTS]
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .auth import get_current_user
from llm.db_tools import (
db_get_tasks,
db_add_task,
db_update_task_status,
db_delete_task
)
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
# ANCHOR[TASKS_SCHEMAS]
class CreateTaskRequest(BaseModel):
title: str
priority: Optional[str] = "MEDIUM"
module: Optional[str] = "general"
due_date: Optional[str] = None
class UpdateTaskRequest(BaseModel):
status: Optional[str] = "COMPLETED"
due_date: Optional[str] = None
# ANCHOR[TASKS_ENDPOINTS]
@router.get("")
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
"""Получить список всех задач текущего авторизованного пользователя."""
return db_get_tasks(user_id=user["id"])
@router.post("")
def create_task_endpoint(req: CreateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)):
"""Прямое создание задачи."""
res = db_add_task(
user_id=user["id"],
module=req.module or "general",
title=req.title.strip(),
priority=req.priority or "MEDIUM",
due_date=req.due_date
)
return res
@router.patch("/{task_id}")
def update_task_endpoint(task_id: str, req: UpdateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)):
"""Прямое обновление статуса и срока задачи."""
res = db_update_task_status(
user_id=user["id"],
task_id=task_id,
status=req.status or "COMPLETED",
due_date=req.due_date
)
if "error" in res:
raise HTTPException(status_code=404, detail=res["error"])
return res
@router.delete("/{task_id}")
def delete_task_endpoint(task_id: str, user: Dict[str, Any] = Depends(get_current_user)):
"""Прямое удаление задачи."""
res = db_delete_task(user_id=user["id"], task_id=task_id)
if "error" in res:
raise HTTPException(status_code=404, detail=res["error"])
return res
File: ./modules/web_api/llm/__init__.py
File: ./modules/web_api/llm/agent.py
"""
===============================================================================
FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Оркестратор диалога и диспетчер Function Calling.
===============================================================================
"""
# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS]
import sys
import json
import logging
from typing import List, Dict, Any, Tuple, Optional
from .db.connection import get_db_connection
from .db_tools import (
db_get_active_system_prompt,
db_add_system_prompt,
db_get_tool_action,
db_get_tasks,
db_update_task_status,
db_delete_task,
db_add_task,
db_get_rules,
db_set_session_state,
db_get_session_state,
db_clear_session_state,
db_get_snapshots,
db_delete_snapshots,
db_get_current_server_time,
db_save_chat_message,
db_get_chat_history,
db_purge_ephemeral_messages,
db_get_stats,
db_get_anomalies,
db_get_session_states,
db_get_reference
)
from .schemas import TOOLS_SCHEMA
from .core.calendar_utils import get_dynamic_calendar_context
from .core.tool_injector import clean_raw_tool_tags, clean_output
from .core.ollama_client import call_ollama_chat
from .core.fast_path import handle_fast_path_intercept
from .core.prompt_merger import build_prompt_preview_merge
# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG]
logger = logging.getLogger("SCUD_AGENT")
logger.setLevel(logging.INFO)
logger.propagate = False
if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR]
def process_chat_message(
user_id: int,
user_message: str,
file_context: str = "",
image_b64: Optional[str] = None,
chat_history: List[Dict[str, Any]] = None,
session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""
Главный конвейер диалога:
1. Проверка Fast-Path кнопок (подтверждение, отмена, завершение с очисткой).
2. Формирование актуального системного контекста и вызов Ollama LLM.
3. Выполнение инструментов и фиксация в БД.
"""
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
session_state = db_get_session_state(session_id)
# 1. Fast-Path перехват
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
if fast_path_res:
return fast_path_res
# 2. Сохранение пользовательского сообщения
is_user_ephemeral = 1 if session_state else 0
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral)
db_history = db_get_chat_history(session_id, limit=20)
# 3. Сборка системного промпта
dynamic_prompt_text = db_get_active_system_prompt()
calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
active_state_context = ""
if session_state:
state_type = session_state.get("state_type", "GENERAL")
active_state_context = (
f"\n[АКТИВНЫЙ РЕЖИМ СЕССИИ: {state_type}]\n"
f"Сейчас активен сценарий: {state_type}.\n"
f"- При запросе на изменение данных вызывай соответствующий инструмент.\n"
)
system_prompt_content = (
f"Ты — интеллектуальный ассистент SCUD Orion AI. "
f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n"
f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n"
f"- Запрещено использовать панибратские или шутливые обращения (например, 'Почемучка'). Отвечай профессионально и строго по существу.\n\n"
f"[ОКРУЖЕНИЕ]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
f"{active_state_context}\n"
f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
f"1. Для любых изменений системного промпта ВСЕГДА вызывай db_preview_prompt_merge(prompt_text=...).\n"
f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n"
f"4. Не симулируй выполнение функций текстом — сразу вызывай инструмент.\n\n"
f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
)
user_msg_object = {"role": "user", "content": full_user_content}
# --- [SECTION 4: OLLAMA INFERENCE & DISPATCH] --- # ANCHOR[TOOL_ROUTER]
try:
if image_b64:
user_msg_object["images"] = [image_b64]
messages = [
{"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."},
user_msg_object
]
msg = call_ollama_chat(messages, is_vision=True)
else:
clean_db_history = [dict(m) for m in db_history]
for m in clean_db_history:
m.pop("images", None)
messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
tool_calls = msg.get("tool_calls", [])
if tool_calls:
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
messages.append(msg)
tool_names_called = []
for tool in tool_calls:
fn_name = tool["function"]["name"]
fn_args = tool["function"].get("arguments", {})
tool_names_called.append(fn_name)
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
tool_result_content = ""
action_cfg = db_get_tool_action(fn_name)
# ANCHOR[TASK_INTERACTIVE_DISPATCH]
if fn_name == "db_get_tasks":
raw_tasks = db_get_tasks(user_id)
reply_text = "Вот интерактивный список ваших текущих задач:"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
elif fn_name == "db_preview_prompt_merge":
proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or ""
if isinstance(fn_args, str):
proposed_text = fn_args
merged_prompt = build_prompt_preview_merge(db_get_active_system_prompt(), user_message, proposed_text)
db_set_session_state(session_id, "PROMPT_PREVIEW", {"draft_text": merged_prompt})
with get_db_connection() as conn_fix:
conn_fix.cursor().execute("""
UPDATE chat_messages
SET is_ephemeral = 1
WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
""", (session_id,))
conn_fix.commit()
preview_reply = (
f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n"
f"{merged_prompt}\n\n"
f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
)
db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), {
"type": "PROMPT_PREVIEW",
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"}
]
}
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, ensure_ascii=False)
elif fn_name == "db_get_snapshots":
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
elif fn_name == "db_get_current_server_time":
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
elif fn_name == "db_get_stats":
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
elif fn_name == "db_get_anomalies":
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
elif fn_name == "db_get_session_states":
tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False)
elif fn_name == "db_get_rules":
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
elif fn_name == "db_get_reference":
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
elif fn_name == "db_add_task":
res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
tool_result_content = json.dumps(res, ensure_ascii=False)
elif fn_name == "db_update_task_status":
res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date"))
tool_result_content = json.dumps(res, ensure_ascii=False)
elif fn_name == "db_delete_task":
res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
if action_cfg and action_cfg.get("bypass_llm"):
reply_text = action_cfg["success_template"]
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=action_cfg.get("is_ephemeral", 1))
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps(res, ensure_ascii=False)
elif fn_name == "db_delete_snapshots":
res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str"))
if action_cfg and action_cfg.get("bypass_llm"):
reply_text = action_cfg["success_template"]
if action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=action_cfg.get("is_ephemeral", 1))
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type"),
"buttons": action_cfg.get("buttons", [])
}
tool_result_content = json.dumps(res, ensure_ascii=False)
messages.append({"role": "tool", "content": tool_result_content})
# ANCHOR[SECONDARY_PASS]
sec_msg = call_ollama_chat(messages, is_vision=False)
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
final_content = clean_raw_tool_tags(clean_output(raw_content))
is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_preview_prompt_merge"] for name in tool_names_called) else 0
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
return final_content, db_get_chat_history(session_id), None
# Обычный текстовый диалог
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
return final_reply, db_get_chat_history(session_id), None
except Exception as ex:
logger.exception(f"Непредвиденная ошибка: {ex}")
error_reply = f"Внутренняя ошибка сервера: {ex}"
return error_reply, db_get_chat_history(session_id), None
File: ./modules/web_api/llm/db_tools.py
"""
FILE: modules/web_api/llm/db_tools.py
"""
from datetime import datetime
from .db.connection import DB_PATH, get_db_connection
from .db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
from .db.db_tasks import normalize_task_id, db_get_tasks, db_add_task, db_update_task_status, db_delete_task
from .db.db_snapshots import db_get_snapshots, db_delete_snapshots
from .db.db_prompts import (
db_get_active_system_prompt,
db_add_system_prompt,
db_get_tool_action,
db_get_rules,
db_set_session_state,
db_get_session_state,
db_clear_session_state,
db_increment_session_idle,
db_get_session_states,
db_get_stats,
db_get_anomalies,
db_get_reference
)
def db_get_current_server_time():
now = datetime.now()
days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
return {
"current_date": now.strftime("%d.%m.%Y"),
"current_time": now.strftime("%H:%M:%S"),
"day_of_week": days_ru[now.weekday()],
"iso_date": now.strftime("%Y-%m-%d")
}
File: ./modules/web_api/llm/file_parser.py
import base64
import os
import subprocess
import logging
import pandas as pd
logger = logging.getLogger("FILE_PARSER")
def extract_text_from_file(file_bytes: bytes, filename: str) -> dict:
ext = os.path.splitext(filename)[1].lower()
temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}"
with open(temp_filepath, "wb") as f:
f.write(file_bytes)
try:
# 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM
if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']:
b64_str = base64.b64encode(file_bytes).decode('utf-8')
return {
"text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]",
"image_b64": b64_str
}
# 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM)
elif ext == '.pdf':
cmd = ['pdftotext', temp_filepath, '-']
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
pdf_text = res.stdout.strip()
img_prefix = f"/tmp/pdf_preview_{os.getpid()}"
subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True)
page_png = f"{img_prefix}-1.png"
b64_str = None
if os.path.exists(page_png):
with open(page_png, "rb") as pf:
b64_str = base64.b64encode(pf.read()).decode('utf-8')
os.remove(page_png)
context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]"
if pdf_text:
context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}"
return {
"text": context_text,
"image_b64": b64_str
}
# 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv)
elif ext in ['.xlsx', '.xls', '.csv']:
if ext == '.csv':
df = pd.read_csv(temp_filepath)
else:
df = pd.read_excel(temp_filepath)
total_rows = len(df)
df_preview = df.head(100)
table_str = df_preview.to_string(index=False)
note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else ""
return {
"text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}",
"image_b64": None
}
# 4. ТЕКСТОВЫЕ ФАЙЛЫ
elif ext in ['.txt', '.log', '.json', '.xml', '.md']:
with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf:
return {
"text": tf.read().strip(),
"image_b64": None
}
else:
return {
"text": f"[ОШИБКА: Формат {ext} не поддерживается]",
"image_b64": None
}
except Exception as e:
logger.error(f"Ошибка при анализе файла {filename}: {e}")
return {
"text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]",
"image_b64": None
}
finally:
if os.path.exists(temp_filepath):
os.remove(temp_filepath)
File: ./modules/web_api/llm/schemas.py
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "db_get_tasks",
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED."
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_rules",
"description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_get_system_prompt",
"description": "ВЫЗЫВАЙ ВСЕГДА при наличии в сообщении фраз: 'покажи системный промпт', 'покажи промпт', 'выведи промпт', 'системный промпт'. Запрещено отвечать текстом без вызова этого инструмента.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "db_update_task_status",
"description": "Изменить статус и/или срок выполнения задачи в реестре.",
"parameters": {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"},
"status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"},
"due_date": {"type": "string", "description": "Срок выполнения задачи"}
},
"required": ["task_id"]
}
}
},
{
"type": "function",
"function": {
"name": "db_delete_task",
"description": "Удалить задачу из реестра по её task_id (например, TASK-18).",
"parameters": {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "Идентификатор задачи для удаления, например TASK-18"}
},
"required": ["task_id"]
}
}
},
{
"type": "function",
"function": {
"name": "db_get_snapshots",
"description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
"parameters": {
"type": "object",
"properties": {
"date_str": {
"type": "string",
"description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_delete_snapshots",
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
"parameters": {
"type": "object",
"properties": {
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_current_server_time",
"description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "db_add_task",
"description": "Добавить новую задачу в бэклог проекта.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Краткое описание задачи"},
"priority": {"type": "string", "description": "Приоритет: HIGH, MEDIUM, LOW"},
"module": {"type": "string", "description": "Модуль проекта, например general или services/scud_export"},
"due_date": {"type": "string", "description": "Срок выполнения задачи, например '2026-08-07 12:00'"}
},
"required": ["title"]
}
}
},
{
"type": "function",
"function": {
"name": "db_get_stats",
"description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_get_anomalies",
"description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"},
"date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_session_states",
"description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_get_reference",
"description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.",
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр."
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_add_system_prompt",
"description": "Прямое сохранение системного промпта в БД без предварительного просмотра.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"},
"prompt_text": {"type": "string", "description": "Полный текст системного промпта"}
},
"required": ["prompt_text"]
}
}
},
{
"type": "function",
"function": {
"name": "db_preview_prompt_merge",
"description": "Сформировать предпросмотр изменения системного промпта. Вызывается при любых запросах на добавление ('добавь пункт...'), изменение или удаление пунктов системного промпта ('удали пункт...').",
"parameters": {
"type": "object",
"properties": {
"prompt_text": {
"type": "string",
"description": "Полный текст добавляемого пункта или номер/описание удаляемого пункта."
}
},
"required": ["prompt_text"]
}
}
},
{
"type": "function",
"function": {
"name": "db_confirm_prompt_preview",
"description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.",
"parameters": {"type": "object", "properties": {}}
}
},
{
"type": "function",
"function": {
"name": "db_cancel_prompt_preview",
"description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.",
"parameters": {"type": "object", "properties": {}}
}
},
# ANCHOR[TOPIC_DRIFT_TOOL_SCHEMA]
{
"type": "function",
"function": {
"name": "db_report_topic_shift",
"description": (
"Вызывай этот инструмент ВСЕГДА, когда в сессии активно контекстное действие "
"(настройка промпта, срез СКУД, мастер задач), но текущий вопрос пользователя "
"никак не связан с этой активностью и переключает диалог на стороннюю тему."
),
"parameters": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Краткая семантическая причина, почему тема считается сменившейся"
},
"is_explicit_exit": {
"type": "boolean",
"description": "True, если пользователь прямо попросил выйти/завершить работу"
}
},
"required": ["reason"]
}
}
}
]
File: ./modules/web_api/llm/db/__init__.py
File: ./modules/web_api/llm/db/connection.py
"""
FILE: modules/web_api/llm/db/connection.py
"""
import os
import sqlite3
# Динамический путь к общей БД в корне проекта
BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../"))
DB_PATH = os.path.join(BASE_ROOT, "data", "scud_orion_ai.db")
def get_db_connection() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=30.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON;")
conn.execute("PRAGMA journal_mode = WAL;")
conn.execute("PRAGMA synchronous = NORMAL;")
return conn
File: ./modules/web_api/llm/db/db_chat.py
"""
FILE: modules/web_api/llm/db/db_chat.py
ROLE: Управление историей сообщений и очисткой эфемерного контекста.
"""
from typing import List, Dict, Any
from .connection import get_db_connection
def db_save_chat_message(session_id: str, role: str, content: str, is_ephemeral: int = 0) -> None:
"""Сохраняет сообщение в БД (is_ephemeral=1 для временных служебных шагов, 0 для постоянных)."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO chat_messages (session_id, role, content, is_ephemeral)
VALUES (?, ?, ?, ?)
""", (session_id, role, content, is_ephemeral))
conn.commit()
conn.close()
def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]:
"""Получает последние сообщения истории диалога в хронологическом порядке."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT role, content
FROM chat_messages
WHERE session_id = ?
ORDER BY id DESC
LIMIT ?
""", (session_id, limit))
rows = cursor.fetchall()
conn.close()
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
def db_purge_ephemeral_messages(session_id: str) -> int:
"""
Физически удаляет все временные служебные сообщения выбранной сессии
после завершения сценария работы с инструментом.
"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1", (session_id,))
deleted = cursor.rowcount
conn.commit()
conn.close()
return deleted
def db_clear_chat_history(session_id: str) -> None:
"""Полная очистка всех сообщений сессии."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
conn.commit()
conn.close()
File: ./modules/web_api/llm/db/db_prompts.py
"""
FILE: modules/web_api/llm/db/db_prompts.py
PROJECT: SCUD Orion AI
MODULE: web_api / llm / db
ROLE: Управление системными промптами, правилами базы знаний,
декларативным реестром действий инструментов (tool_action_registry)
и расширенным сессионным состоянием (session_states + idle_turns).
"""
import json
import logging
from typing import List, Dict, Any, Optional
from .connection import get_db_connection
logger = logging.getLogger("DB_PROMPTS")
def db_get_active_system_prompt() -> str:
"""Извлекает актуальный активный системный промпт из SQLite."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1")
row = cursor.fetchone()
conn.close()
return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI."
def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
"""Получение шаблона, кнопок и настроек эфемерности инструмента из SQLite."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT tool_name, category, bypass_llm, success_template,
follow_up_question, action_type, buttons_json, is_ephemeral
FROM tool_action_registry
WHERE tool_name = ? AND is_active = 1
""", (tool_name,))
row = cursor.fetchone()
conn.close()
if row:
res = dict(row)
res["buttons"] = json.loads(res["buttons_json"]) if res.get("buttons_json") else []
return res
return None
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
"""Сохраняет новую версию системного промпта и активирует её."""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("BEGIN IMMEDIATE;")
cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,))
existing = cursor.fetchone()
if existing:
cursor.execute(
"UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?",
(prompt_text, name)
)
else:
cursor.execute(
"INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)",
(name, prompt_text)
)
conn.commit()
return {"status": "success", "message": "Системный промпт успешно обновлен"}
except Exception as e:
logger.error(f"Ошибка при сохранении промпта в БД: {e}")
return {"status": "error", "error": str(e)}
def db_get_rules() -> List[Dict[str, Any]]:
"""Извлекает список глобальных правил компании из ai_knowledge_base."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
# =============================================================================
# РАБОТА С СЕССИОННЫМИ СОСТОЯНИЯМИ, ЧЕРНОВИКАМИ И СЧЕТЧИКОМ ОТВЛЕЧЕНИЙ (IDLE_TURNS)
# =============================================================================
def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
"""
Сохраняет состояние сессии в SQLite.
Если передан dict/list — автоматически сериализует его в JSON-строку.
"""
conn = get_db_connection()
cursor = conn.cursor()
if isinstance(data, (dict, list)):
payload_str = json.dumps(data, ensure_ascii=False)
else:
payload_str = str(data) if data is not None else ""
cursor.execute("""
INSERT INTO session_states (session_id, state_type, pending_data, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(session_id) DO UPDATE SET
state_type = excluded.state_type,
pending_data = excluded.pending_data,
updated_at = CURRENT_TIMESTAMP
""", (session_id, state_type, payload_str))
conn.commit()
conn.close()
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
"""
Извлекает состояние сессии из SQLite.
Если в pending_data лежит валидный JSON-объект — парсит его в data_json.
"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?",
(session_id,)
)
row = cursor.fetchone()
conn.close()
if not row:
return None
res = dict(row)
raw_data = res.get("pending_data") or ""
try:
if raw_data.strip().startswith("{") or raw_data.strip().startswith("["):
res["data_json"] = json.loads(raw_data)
else:
res["data_json"] = None
except Exception:
res["data_json"] = None
return res
def db_increment_session_idle(session_id: str) -> int:
"""
Инкрементирует счетчик idle_turns для активного черновика сессии.
Возвращает обновленное значение счетчика отвлечений.
"""
state = db_get_session_state(session_id)
if not state:
return 0
data_meta = state.get("data_json")
if isinstance(data_meta, dict):
current_turns = data_meta.get("idle_turns", 0) + 1
data_meta["idle_turns"] = current_turns
db_set_session_state(session_id, state["state_type"], data_meta)
return current_turns
else:
# Если ранее данные были сохранены обычной строкой
new_meta = {
"draft_text": state.get("pending_data", ""),
"idle_turns": 1
}
db_set_session_state(session_id, state["state_type"], new_meta)
return 1
def db_clear_session_state(session_id: str) -> None:
"""Сбрасывает и удаляет активное состояние сессии."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
conn.commit()
conn.close()
def db_get_session_states() -> Dict[str, Any]:
"""Возвращает реестр всех активных сессий."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
rows = cursor.fetchall()
conn.close()
return {"status": "success", "active_sessions": [dict(r) for r in rows]}
# =============================================================================
# СЛУЖЕБНАЯ ДИАГНОСТИКА И СТАТИСТИКА
# =============================================================================
def db_get_stats() -> Dict[str, Any]:
"""Возвращает статистику по количеству записей в таблицах."""
conn = get_db_connection()
cursor = conn.cursor()
tables = [
'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history',
'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'
]
stats = {}
for t in tables:
try:
cursor.execute(f"SELECT COUNT(*) FROM {t}")
stats[t] = cursor.fetchone()[0]
except Exception:
stats[t] = 0
conn.close()
return {"status": "success", "tables_stats": stats}
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
"""Извлекает журнал зафиксированных аномалий."""
conn = get_db_connection()
cursor = conn.cursor()
query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history"
params = []
if date_str:
query += " WHERE anomaly_date = ?"
params.append(date_str)
query += " ORDER BY id DESC LIMIT ?"
params.append(limit)
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]}
def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
"""Извлекает справочные команды и примеры подсказок."""
conn = get_db_connection()
cursor = conn.cursor()
query = "SELECT category, title, example_prompt, description FROM system_reference"
params = []
if category:
query += " WHERE category = ?"
params.append(category)
query += " ORDER BY id ASC"
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}
File: ./modules/web_api/llm/db/db_snapshots.py
"""
FILE: modules/web_api/llm/db/db_snapshots.py
"""
import json
from typing import Dict, Any, Optional
from .connection import get_db_connection
from .db_prompts import db_set_session_state
def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
query = "SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count FROM scud_logs "
params = []
if date_str:
iso_date = date_str
if "." in date_str:
parts = date_str.split(".")
if len(parts) == 3:
iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? "
params.extend([date_str, iso_date, f"{iso_date}%"])
query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
cursor.execute(query, params)
rows = cursor.fetchall()
snapshots = [dict(r) for r in rows]
result_data = {
"query_date": date_str or "все",
"snapshots_count": len(snapshots),
"snapshots": snapshots
}
db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=json.dumps(result_data, ensure_ascii=False))
conn.close()
return result_data
def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
if not snapshot_id and not day_str:
return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
conn = get_db_connection()
cursor = conn.cursor()
if snapshot_id:
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
else:
cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%"))
deleted = cursor.rowcount
conn.commit()
conn.close()
return {"status": "success", "message": f"Успешно удалено записей: {deleted}"}
File: ./modules/web_api/llm/db/db_tasks.py
"""
FILE: modules/web_api/llm/db/db_tasks.py
"""
import re
from typing import List, Dict, Any, Optional
from .connection import get_db_connection
def normalize_task_id(task_id_input: str) -> str:
if not task_id_input:
return ""
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
if clean_id.isdigit():
num = int(clean_id)
return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}"
return f"TASK-{clean_id}"
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, task_id, module, title, priority, status, due_date, created_at
FROM tasks
WHERE user_id = ?
ORDER BY id DESC
""", (user_id,))
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT MAX(id) FROM tasks")
max_id = cursor.fetchone()[0] or 0
new_task_id = f"TASK-{(max_id + 1):02d}"
cursor.execute("""
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?)
""", (new_task_id, module, title, priority.upper(), due_date, user_id))
conn.commit()
conn.close()
return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
formatted_id = normalize_task_id(task_id)
if due_date:
cursor.execute("""
UPDATE tasks
SET status = ?, due_date = ?
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id))
else:
cursor.execute("""
UPDATE tasks
SET status = ?
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))
if cursor.rowcount == 0:
conn.close()
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
conn.commit()
conn.close()
return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
conn = get_db_connection()
cursor = conn.cursor()
formatted_id = normalize_task_id(task_id)
cursor.execute("""
DELETE FROM tasks
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
""", (formatted_id, f"%{task_id.strip()}", user_id))
if cursor.rowcount == 0:
conn.close()
return {"error": f"Задача {task_id} не найдена"}
conn.commit()
conn.close()
return {"status": "success", "message": f"Задача {formatted_id} удалена"}
File: ./modules/web_api/llm/core/__init__.py
File: ./modules/web_api/llm/core/calendar_utils.py
import re
from datetime import datetime, timedelta
DAYS_RU = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
def parse_relative_date_ru(text: str) -> str:
now = datetime.now()
text_lower = text.lower() if text else ""
match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text)
if match:
return match.group(1)
if "вчера" in text_lower:
return (now - timedelta(days=1)).strftime("%d.%m.%Y")
elif "позавчера" in text_lower:
return (now - timedelta(days=2)).strftime("%d.%m.%Y")
elif "сегодня" in text_lower:
return now.strftime("%d.%m.%Y")
return (now - timedelta(days=1)).strftime("%d.%m.%Y")
def get_dynamic_calendar_context() -> str:
now = datetime.now()
current_wd = now.weekday()
lines = [
f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).",
"\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):",
f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})",
f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})",
f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})",
"\nПрошедшие дни недели:"
]
for days_back in range(1, 8):
dt = now - timedelta(days=days_back)
day_name = DAYS_RU[dt.weekday()]
label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}"
lines.append(f"• {label}: {dt.strftime('%d.%m.%Y')}")
return "\n".join(lines)
File: ./modules/web_api/llm/core/fast_path.py
"""
===============================================================================
FILE: modules/web_api/llm/core/fast_path.py
ROLE: Детерминированный перехват подтверждения, отмены и мгновенной очистки
эфемерного контекста по кнопкам завершения.
===============================================================================
"""
# ANCHOR[FAST_PATH_IMPORTS]
import logging
from typing import Dict, Any, Tuple, Optional
from llm.db_tools import (
db_add_system_prompt,
db_get_tool_action,
db_set_session_state,
db_clear_session_state,
db_purge_ephemeral_messages,
db_save_chat_message,
db_get_chat_history
)
logger = logging.getLogger("FAST_PATH")
TERMINATE_ACTIONS = {"нет, спасибо", "нет, закончить настройку", "закончить настройку", "завершить", "готово", "нет"}
# ANCHOR[FAST_PATH_ROUTER]
def handle_fast_path_intercept(
session_id: str,
user_message: str,
full_user_content: str,
session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
"""
1. Перехват кнопок завершения -> моментальное удаление эфемерных записей.
2. Перехват подтверждения/отмены черновика промпта.
"""
user_msg_clean = user_message.lower().strip(" .!?:;")
# 1. Завершение работы с инструментом -> мгновенная очистка эфемерных сообщений
if user_msg_clean in TERMINATE_ACTIONS:
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"Сессия закрыта по кнопке/команде '{user_msg_clean}'. Удалено эфемерных сообщений: {deleted_count}")
reply_text = "Работа с инструментом завершена. Контекст диалога чист. Чем могу помочь?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
return reply_text, db_get_chat_history(session_id), None
# 2. Подтверждение / отмена превью системного промпта
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
draft_text = ""
if session_state.get("data_json") and isinstance(session_state["data_json"], dict):
draft_text = session_state["data_json"].get("draft_text", "")
else:
draft_text = session_state.get("pending_data", "")
if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
db_add_system_prompt("main_agent", draft_text)
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {})
action_cfg = db_get_tool_action("db_confirm_prompt_preview")
reply_text = action_cfg["success_template"] if action_cfg else "Системный промпт успешно сохранен и применен в базе данных."
if action_cfg and action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION",
"buttons": action_cfg.get("buttons", []) if action_cfg else []
}
elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад"]:
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {})
action_cfg = db_get_tool_action("db_cancel_prompt_preview")
reply_text = action_cfg["success_template"] if action_cfg else "Изменения системного промпта отменены."
if action_cfg and action_cfg.get("follow_up_question"):
reply_text += f"\n\n{action_cfg['follow_up_question']}"
reply_text = reply_text.replace("✅", "").replace("❌", "").replace("**", "").replace("*", "").strip()
is_eph = action_cfg.get("is_ephemeral", 1) if action_cfg else 1
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=is_eph)
return reply_text, db_get_chat_history(session_id), {
"type": action_cfg.get("action_type") if action_cfg else "FOLLOW_UP_ACTION",
"buttons": action_cfg.get("buttons", []) if action_cfg else []
}
return None
File: ./modules/web_api/llm/core/ollama_client.py
"""
===============================================================================
FILE: modules/web_api/llm/core/ollama_client.py
ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama.
===============================================================================
"""
# ANCHOR[OLLAMA_CLIENT_IMPORTS]
import json
import urllib.request
import urllib.error
import logging
from typing import Dict, Any, Optional
logger = logging.getLogger("OLLAMA_CLIENT")
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
TEXT_MODEL = "qwen2.5:14b"
VISION_MODEL = "qwen2.5vl:7b-q8_0"
LLM_OPTIONS = {
"num_predict": 8192,
"num_ctx": 8192,
"temperature": 0.1,
"repeat_penalty": 1.1,
"presence_penalty": 0.5,
"top_p": 0.9
}
# ANCHOR[OLLAMA_REQUEST_DISPATCHER]
def call_ollama_chat(
messages: list,
tools: Optional[list] = None,
is_vision: bool = False,
timeout: int = 120
) -> Dict[str, Any]:
"""
Отправляет подготовленный массив сообщений в API Ollama.
Возвращает разобранный словарь сообщения ответа или генерирует исключение.
"""
model_name = VISION_MODEL if is_vision else TEXT_MODEL
payload = {
"model": model_name,
"messages": messages,
"stream": False,
"options": LLM_OPTIONS
}
if tools and not is_vision:
payload["tools"] = tools
req = urllib.request.Request(
OLLAMA_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=timeout) as response:
res_data = json.loads(response.read().decode("utf-8"))
return res_data.get("message", {})
File: ./modules/web_api/llm/core/prompt_merger.py
"""
===============================================================================
FILE: modules/web_api/llm/core/prompt_merger.py
ROLE: Алгоритмы парсинга, предпросмотра и детерминированного слияния правок
системного промпта (добавление, удаление и замена пунктов).
===============================================================================
"""
# ANCHOR[PROMPT_MERGER_IMPORTS]
import re
import logging
from typing import Tuple
logger = logging.getLogger("PROMPT_MERGER")
# ANCHOR[PROMPT_MERGE_ENGINE]
def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str:
"""
Вычисляет результирующий текст промпта на основе намерения пользователя:
1. Удаление пункта по номеру.
2. Добавление/изменение пункта в соответствующую секцию.
"""
user_msg_lower = user_message.lower()
# 1. Сценарий удаления пункта
if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]):
target_num_match = re.search(r'\d+(\.\d+)*', user_message)
target_num = target_num_match.group(0) if target_num_match else ""
lines = current_prompt.splitlines()
if target_num:
new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")]
else:
new_lines = lines
return "\n".join(new_lines)
# 2. Сценарий добавления или замены пункта
if proposed_text:
if len(proposed_text) < 500:
clean_item = proposed_text.strip()
for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]:
if prefix in clean_item.lower():
clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:")
lines = current_prompt.splitlines()
new_lines = []
inserted = False
for line in lines:
new_lines.append(line)
if "3.3." in line and not inserted:
item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}"
new_lines.append(f" {item_str}")
inserted = True
if not inserted:
new_lines.append(f" {clean_item}")
return "\n".join(new_lines)
return proposed_text or current_prompt
File: ./modules/web_api/llm/core/tool_injector.py
"""
FILE: modules/web_api/llm/core/tool_injector.py
"""
import re
import json
import logging
from typing import List, Dict, Any
logger = logging.getLogger("TOOL_INJECTOR")
def clean_raw_tool_tags(text: str) -> str:
if not text:
return ""
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
text = re.sub(r'</tool_call>\w*\[\]\(\)', '', text)
text = re.sub(r'</tool_call>', '', text)
return text.strip()
def clean_output(text: str) -> str:
if not text:
return text
# Фильтрация паразитных артефактов токенизатора Qwen
artifacts = [
"почемучка", "почемучка,", "почемучка!", "почемучка?",
"почемучто", "почто", "почему что", "почему-то",
"здравствуйте!", "привет!"
]
cleaned = text.strip()
for art in artifacts:
if cleaned.lower().startswith(art):
cleaned = cleaned[len(art):].lstrip(",.!?:; -")
return cleaned.strip()
def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
if tool_calls:
return tool_calls
if '{"name":' in raw_text_content or '<tool_call>' in raw_text_content:
try:
match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content)
if match:
fn_name = match.group(1)
fn_args = json.loads(match.group(2))
return [{"function": {"name": fn_name, "arguments": fn_args}}]
except Exception as parse_err:
logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}")
return tool_calls