From 16928de5bd654e19bb16d4e056f0c3a2056222ab Mon Sep 17 00:00:00 2001 From: manoraga Date: Fri, 21 Aug 2026 10:03:12 +0300 Subject: [PATCH] refactor: step 2 - extract domain services (tasks, prompts, snapshots, knowledge) --- core/connection.py | 27 ++ core/database.py | 515 +++----------------------------- core/repositories/scud_repo.py | 199 ++++++++++++ core/repositories/zup_repo.py | 97 ++++++ core/schema.py | 151 ++++++++++ data/scud_orion_ai.db | Bin 7520256 -> 7520256 bytes modules/web_api/llm/db_tools.py | 73 +++-- services/ai_verifier.py | 41 ++- services/knowledge/__init__.py | 0 services/knowledge/service.py | 37 +++ services/prompts/__init__.py | 0 services/prompts/diff_engine.py | 77 +++++ services/prompts/repository.py | 114 +++++++ services/prompts/service.py | 37 +++ services/snapshots/__init__.py | 0 services/tasks/__init__.py | 0 services/tasks/exporter.py | 101 +++++++ services/tasks/repository.py | 181 +++++++++++ services/tasks/service.py | 72 +++++ 19 files changed, 1204 insertions(+), 518 deletions(-) create mode 100644 core/connection.py create mode 100644 core/repositories/scud_repo.py create mode 100644 core/repositories/zup_repo.py create mode 100644 core/schema.py create mode 100644 services/knowledge/__init__.py create mode 100644 services/knowledge/service.py create mode 100644 services/prompts/__init__.py create mode 100644 services/prompts/diff_engine.py create mode 100644 services/prompts/repository.py create mode 100644 services/prompts/service.py create mode 100644 services/snapshots/__init__.py create mode 100644 services/tasks/__init__.py create mode 100644 services/tasks/exporter.py create mode 100644 services/tasks/repository.py create mode 100644 services/tasks/service.py diff --git a/core/connection.py b/core/connection.py new file mode 100644 index 0000000..943d0b9 --- /dev/null +++ b/core/connection.py @@ -0,0 +1,27 @@ +""" +=============================================================================== +FILE: core/connection.py +PROJECT: SCUD Orion AI (Unified Architecture) +ROLE: Единый менеджер подключений к базе данных SQLite (WAL mode, timeouts). +=============================================================================== +""" + +import os +import sqlite3 +from config import DATA_DIR + +DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") + + +def get_connection(row_factory: bool = False) -> sqlite3.Connection: + """ + Создает оптимизированное подключение к SQLite. + row_factory=True возвращает sqlite3.Row для доступа к полям по имени. + """ + conn = sqlite3.connect(DB_PATH, timeout=30.0) + if row_factory: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON;") + conn.execute("PRAGMA journal_mode = WAL;") + conn.execute("PRAGMA synchronous = NORMAL;") + return conn \ No newline at end of file diff --git a/core/database.py b/core/database.py index 0e0ed14..1dca47f 100644 --- a/core/database.py +++ b/core/database.py @@ -1,480 +1,35 @@ -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 - \ No newline at end of file +""" +=============================================================================== +FILE: core/database.py +ROLE: Фасад ядра базы данных с полной обратной совместимостью импортов. +=============================================================================== +""" + +from core.connection import get_connection, DB_PATH +from core.schema import init_all_tables + +from core.repositories.scud_repo import ( + has_scud_logs_for_date, + has_yesterday_final_snapshot, + get_or_create_snapshot_id, + save_scud_to_db, + get_latest_snapshot_time, + load_scud_from_db_by_snapshot, + get_available_snapshots, + delete_snapshot_by_id, + delete_snapshots_by_date +) + +from core.repositories.zup_repo import ( + save_staff_to_db, + load_staff_from_db, + save_absences_to_db, + load_absences_from_db, + save_anomalies_to_db, + get_all_rules_from_db, + add_rule_to_db, + get_department_synonyms_dict, + add_department_synonym_to_db +) + +init_db = init_all_tables \ No newline at end of file diff --git a/core/repositories/scud_repo.py b/core/repositories/scud_repo.py new file mode 100644 index 0000000..fe29767 --- /dev/null +++ b/core/repositories/scud_repo.py @@ -0,0 +1,199 @@ +""" +=============================================================================== +FILE: core/repositories/scud_repo.py +ROLE: Репозиторий логов СКУД, сохранение и загрузка снапшотов. +=============================================================================== +""" + +from datetime import datetime +import pandas as pd +from core.connection import get_connection + + +def has_scud_logs_for_date(date_str: str) -> bool: + 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: str) -> bool: + 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: str, date_str: str = None, is_yesterday: bool = False) -> str: + 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") + + 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: pd.DataFrame, date_str: str, snapshot_time: str = None, is_yesterday: bool = False) -> None: + 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") + + snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday) + + data_to_insert = [ + ( + 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 + ) + for _, r in df_scud.iterrows() + ] + + 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: 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: str, snapshot_param: str = None) -> pd.DataFrame: + with get_connection() as conn: + df = pd.DataFrame() + if snapshot_param: + df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(str(snapshot_param),)) + + if df.empty and date_str: + cursor = conn.cursor() + 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() + 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]: + df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) + + 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}) + for col in ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']: + if col not in df.columns: + df[col] = False if col == 'Пришел' else '—' + if 'Пришел' in df.columns: + df['Пришел'] = df['Пришел'].astype(bool) + + return df + + +def get_available_snapshots(date_str: str = None): + with get_connection() as conn: + cursor = conn.cursor() + query = """ + SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as cnt + FROM scud_logs + WHERE snapshot_time IS NOT NULL + """ + params = [] + if date_str: + query += " AND log_date = ?" + params.append(date_str) + query += " GROUP BY snapshot_id, log_date, snapshot_time ORDER BY snapshot_time DESC" + cursor.execute(query, params) + return cursor.fetchall() + + +def delete_snapshot_by_id(snapshot_id: str) -> int: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) + cnt = cursor.rowcount + conn.commit() + return cnt + + +def delete_snapshots_by_date(date_str: str) -> int: + 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('.', '')}%")) + cnt = cursor.rowcount + conn.commit() + return cnt \ No newline at end of file diff --git a/core/repositories/zup_repo.py b/core/repositories/zup_repo.py new file mode 100644 index 0000000..9d52b6e --- /dev/null +++ b/core/repositories/zup_repo.py @@ -0,0 +1,97 @@ +""" +=============================================================================== +FILE: core/repositories/zup_repo.py +ROLE: Репозиторий кадровых данных 1С:ЗУП, аномалий и базы знаний. +=============================================================================== +""" + +import pandas as pd +from typing import List, Dict, Any +from core.connection import get_connection + + +def save_staff_to_db(df_staff: pd.DataFrame, date_str: str) -> None: + if df_staff is None or df_staff.empty: + return + data = [ + (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) + conn.commit() + + +def load_staff_from_db(date_str: str) -> pd.DataFrame: + 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 save_absences_to_db(df_absent: pd.DataFrame, date_str: str) -> None: + if df_absent is None or df_absent.empty: + return + data = [ + (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) + conn.commit() + + +def load_absences_from_db(date_str: str) -> pd.DataFrame: + 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 + + +def save_anomalies_to_db(anomalies_list: List[Dict[str, Any]], date_str: str) -> None: + if not anomalies_list: + return + data = [(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) + conn.commit() + + +def get_all_rules_from_db() -> List[str]: + 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: str, added_by: str = "Human") -> None: + if not rule_text or not rule_text.strip(): + return + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by)) + conn.commit() + + +def get_department_synonyms_dict() -> Dict[str, str]: + 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: str, full_name: str) -> None: + 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() \ No newline at end of file diff --git a/core/schema.py b/core/schema.py new file mode 100644 index 0000000..0bcdae7 --- /dev/null +++ b/core/schema.py @@ -0,0 +1,151 @@ +""" +=============================================================================== +FILE: core/schema.py +PROJECT: SCUD Orion AI (Unified Architecture) +ROLE: DDL-схемы таблиц, создание индексов и инициализация базы данных. +=============================================================================== +""" + +import logging +from core.connection import get_connection + +logger = logging.getLogger("DB_SCHEMA") + + +def init_all_tables() -> None: + """Инициализирует все таблицы и индексы системы.""" + with get_connection() as conn: + cursor = conn.cursor() + + # 1. Логи СКУД + 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 + ); + """) + + # 2. Кадровые реестры 1С + 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 + ); + """) + + # 3. Аномалии и база знаний + 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 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 + ); + """) + + # 4. Узлы системного промпта и сессии + cursor.execute(""" + CREATE TABLE IF NOT EXISTS system_prompt_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt_name TEXT DEFAULT 'main_agent', + section_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + content TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(prompt_name, section_id, item_id) + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS session_states ( + session_id TEXT PRIMARY KEY, + state_type TEXT NOT NULL, + pending_data TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + is_ephemeral INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 5. Задачи + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT, + module TEXT DEFAULT 'general', + title TEXT NOT NULL, + priority TEXT DEFAULT 'MEDIUM', + status TEXT DEFAULT 'BACKLOG', + due_date TEXT, + user_id INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 6. Индексы + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);") + + conn.commit() \ No newline at end of file diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index b4b33611b94117ba88e2d08e6ba207948124c761..ae356bfdf8ba896e8802764937784ad8314434f4 100644 GIT binary patch delta 80457 zcmbS!349a9_jq=*xzEy4dM5=+?>0%Vv`J6yyXC$qLV;3_6eyPh$r7;z#1^DaQK5*6 zU+^fRtk+kI7a|@-Oi&bU#ao0TUSCD{zuA;_lkfNc`TPu}-F>sOZ)RuSym{BmIGv}( zPW)|*))b0FvNZfn5s5^`rldTo@Mz`h8L}gvMX1DCG?cg#!*&;Cj-@4$yYJ4l(y1A{ zPi7`iFC6NirsUqiepUHKFMbBtL(YyB9Zt* z_S{Ike88T|$IJWdIV?{69(yhdFYmJFlz2JKdN<(ZBI~We%PH173oj>G?--Ft8qRv{ z2))UAv8v)XSZ@qoUT3{(yu8MG?!e0`*0U5Z(^wCA?*jYIY4pYRQd3e9tW5K^$J>)}f_sH`( zx&K^Q{?<0?cpJ?asi7e0pm%7FYYu1zYESxpps_`=6UONsB`~f<5(6zIRG&Rv)0AKe zaWLR;(fzr1I_X$dpGdtpUTiUCn5-EVlO>lDDd9ht==45}Rws_9v&J*+eHMiEjqH$DF_>`!z68$({a*&VMaLqI(c~Ry}==STic&&=N>GV{i0&D$3+ad zDr9Ev%~`Um6oU=X0j_raXHZ^Ylz?iAEERiO1_z#{vhhZGyi&%rQ8`F-^4VIYNBo?4 zz1U9Qq+g@&5cdYB&+0$l}WSC|+Ae6@tX6NT=09z~WR)<+eK&g(_+@8A7*1`A2#w?A)QS;Y%7g zu|-kFy|Y76APyTWI!fzK>UQcTD_O-^NtR-+=n{lCD(3VP9Tm?J52AmfpQUf7%V-7F zP60KCf{+L1_lL_VlpN0OR#*_94nkN(F5YONGi6d9H>*kUxR$bmq(xz76zTHcAh$&^ zoO87(Zj)2tP;@G>A7~FNGVzU2h<{i?dZiD!$>Hw*m!f7$%wW-LBHb&xJ9GuwKaex6 z)>;&G^5^A$Dmc+!d*YOHXwhqX?ogJ2lTDYwI-d;D2nkW3=S~LJZ?3mY>cC< z*vm>A%^ZRfqjDHmZ&Yp(Gm=PA3Tetg&@oV%2TKPkZEzr7NxmSF+~jfn5|lqh5f)RT zOs3_JK}Lyk3b(aHxl(qEB{{|_?U+9an))jXx&9-SU*?9{sQX0B5&Bhnw0gSgF=?yH zLY;$fr*e**x=*r5GLY%uHaV416yVEY(6vsQgcwrb#84$^#bh|T1yPM;l`q9gi>MtU z-J76oD~^T47G*RoiG#8hTBwk)pt@=@NXA8F8fWTMeol#h z6~#1_M)V}lgEK!WO;|=axiNFsE-KgKkk}O!>Eo7BcC88G^0lACfv=T0w6}oIcKouE z=|c_OBT-GJRS|n;s8YgZ7nR>Ck1OxgeW2SX4ms9zOU0*+0og|31iv=Qqt(y=tpoy;ZL{uGdmV8M2R|ltcvD@~9XnZ!eT~ z^O`|gSzqaw(x;_&NJmP;B^Tkga@hdgSUQUOmU>p#Ol_b_CtIva}ZZvhM}%dZG3`ihsh% z&Dv~=L$ue$^v#!j?R*2@BJF*Bjqm#B2;cRO2>uEa3$%IkuRr(_uf6EI{`{El+EwSf zUiQ7jJ(Q>I`(JExu|-SRCXbH{(0<2Dv^W=*fuvHKPb<$tQKfbSqbgNNxgC|-OA1DH zhiE40&Z$1yiJr&5UkLH@F@M*C{FS-9LL`Ewi^^QU=V@F=jW&KYqrE69#wk`io0fkG zw{>WfxE&qZDRQW|s3l`^Fx38_&EqC~uYG0;;lL%j>9qVH%qh_sIcJHku4hbQH;~Z~ z38lkz`CPw|x}S2TR%(|>cM`NlWegY_XGkE`sq05GGoj3>8^P^x>i(pu_u#~_qGV{@ zqB}y0B4rNom3i8&%Z*m4hKUx5A1Cwc4N$k}-d3xW%1g@k#cz=x4riKoXzuVq-BVIp z`~&E}y)6o-AZaw1`rj7GSn(FlY`ZNCI+mLmc=E6=z8Dc*j}&L2RA9+XH(ApyCZokt zU^3xM}Br>0jffIk|v$%-A^_Nx>=IIC-O>5^v zxg(^2+u;aF*4*NMD3VFw_}Y*xe8~()*M{VC=Qf3;Q~V?pGM(0_VU9B-nQL%{oKpEF z)bhJS$b?!Boz9R#uKw0)Fj=}B~xY^O3_v0VI` z{14q^=-(DPm#XxM6O|XzRj}jrP%_;N2iNPNHtypSpY zdEA>Hgcj?GpwSVUKr5mkr6Y6A2^ued_)uTMylm1~tYWa_kKhn)%N5u4w8hHBtFq^cC#S&3o9BW0< zdT&?}C4F!g=5;}>GAU2`^AVAV8Vx5NMY*~2fv|tmovB>QgJI!f>1Qt@_E27ih8z3C zq*N|W_+dk7@wYIlCCtv%w}jnIGfb7JFU%dQ42R5vVb23KSVyMKC~{NC&3`29r*!GT zdMx5N#7~a4N&nb~MQ;Sxgy>A}tq;R~pr!W`ttJ+z*UTl-AL}sZQ&4&)s*ua~hTTp{ zcO_!}$FVU|@}<{MMUfgHp8g@D_JDkCeppETc^v`c4?o`F4hjz zbZ8#bOw)vkPN}`>`_$9bQL0Z>52|LX;*?(~Ta@#Z{S_A#k0`1XgXG`JAD36lQ)EBN zo{%k*Wk|0`Tcx#9i{zT*SxKEFm-(G}o>|Wni2oA5EZ!(CLOHUHcA>Z=rrx0L6m4Uu zk(34spAIj>&t3qRJ3~pA&xgh*!=Ht{hr&zXlgGk~>FcjU)8X(!`p-R3@nm>055T)m zgqQm6hCUVks_)MEbhy)(^_wfc>(^fX%B3F+=V;&U`9tAPGh#|4C*_sVH?H}v*I)Bp z|Gd|C{e!Ph8618%dNS*`hv+QtAp}!!=+qYQ{-d?b%e~Rhxio06e4@nLq$_$F1PK0$PG-y za4J{C3=w}WUPphS&Z8Hq&Zugr#YlZ9^Mr$`C2|O(vdZ3tF%D}C*W40`RpANss%>dF zB<-G+!s!o2Cdnb>5gvCnoH-I{rkTZj0{8xr$lQAg6Dx_TpcQp6r6j6|Yc7czDW~oy z72DHnU#bARdUzt%YXyqzQ6}!=kx^+lps8QiLhs5@ymvokicCy_AU)lfS*Dx2FiDQcySOmvM=>2lQ^dI_CDUG+8l zq{gTeQj7hZp($#SLi~dW)tU#QhC}<_sGU$TA!;z9sUtT-xbGf_iWw*#EV>D@wy0tn zufy7+MnGd*)C$l)5k-dKI=K2tSRr@&iKqsxG*5IYV5V%3>PL$spsYO#HIVkGYm|7~ zEy0=e7rggHlnFljFv^Z>Cz9~J2Cm|4)G;+-#T{5A?S&y7Q8v!i5tWCOiE1K|d?iqP zDT+)4jd0>hl%1=%NM0$xc`K@dQE!({gi2?0v?PlDf_{YC&=oab)idXuo~(cmu1A?@ zW;ay)C(6p*xE>`9C33nxI**p8gIymzjC1Rw4KiqIoUs;4OmwJE^=Wf2C8|#|R~r%C zC_{6FXt3y_2-NL~hOCL|Xw{dhf2ka*Jmn4Lo5~%^iOMKNr{ZzN3PrB`Z}}VY?eYoo zNLh#MkZiduM|xfQI+V4J9H^)3>G5>9=pywowU?Sn#SrmlGEpid@MK1`8Oys5I)_IW zf;2Ju8ji;zxN3~fgGmFU^XcnlNefzkHvhhJnwuY?0( z(L`v1?i*1dxyh$*q9%k_aHnE9S!vM)V$mP>`yTzi#drO!hQA`mI2sdOKwnGnC3pSi zyIwx!yZ*@XR|wCDEfY1s)h&V@j%ZU;Ipgc!Z&pQ{xt|@;jcP`lgZ78cRncSwJVeSXz?%n2 zN*?`NE%*M;SPSKoiQ~ocWnv5cyW$na7R3mKM*g83j>ee&VT!L5Pb#WodN<&U5#DRwls*%^CP3Y0W9`%q)MzMGo0yM_`C@hv#9d#H(%HN^fYWt1O@N|8`w z=hE~}oZ|7@8XIG8P%!WQSkmq9@HgDAyJB@3V&iJT`q8JLq$PF)x2+{sLBq^>P05&FQ1 z<3)CkI~5xtg{!Ax2_JdyczZ0>5sy!YQE?*ODfuwDj`1>!sXyt()DGo&WsYQs;-qdD z7+d4!^r7OIT!s;!5$_Y%h^=A?{UJ2A#_jNxI^$>@DfJ^TKNFY3Z95t#*TMNu{aZVIMOg<5Rtu0 z3<G%z_Qb`?A*IEDBHaljR|A?7TMSR5DKWF#Tt?gsuO2XD!d;JG1d zNcy!4t{yPtaOsa4&P5SDu+1=?R{jEW+6)ogx;Dcz3ZEXx+h&VS8;}YA0rO56O1br? z4cmEZb31mX>;ah2ZWzREXg4gA`XuZN=L{&;{s$_~Vx#r>*zmIqW%Eyb;tUwtCPqMU zhasMp-VK!6v8af8 zK?-q>5ac>tRdKZVI23hNWrQz{2=bxNO+>}N{2e;Xd}%jsR!Q$-DvkIXkH7u#cfcX0a$q~L zzMB$CY4rjaW=gPgZd1aiN=i;@%TVx2aY8Za>#oXvC3k&oPvE|`4>FvM$wYZTtWlbz zE2YJfPb7~?R!K@E8oGu#k8^9BK5-1URG%1$wna37^PCX10XcmCRur-1u7}RIP#aM8 zP5kpPv3|ED4yF~6FrqDS6nAf1;zgN&Vdv1yUg#`~&gR^w5;sa2$zl=GM|&cgs-;lU zo>;=M?TKzN9Gz$+{d1lx{V1_5nux6h)VS_`j%_Pej*eG;uH37fqU@u1U$IRwOhL`ri{JSjVey;-J9_oxdg)k-L2Q z?}@(aUkUt`Yq*}cfb!kWyODU1qDuEjlJaQD8++_YBN%C}!=?ZX-GvxTJ z`$kF9BgFbZgsW)YBtds=;IGW(v6@LxyLW~i;v`8H?h9#Bf+8k^dP}6cL;HuWKyE^f z{WA5IEJ^x^^c&d{MHu#A(kMazvv>lKLq~X`m6P^KS|NpywjuioRq%;KJJ|r8ABTi%Y)B7oxD`q zqjM24P9aaurkTS~N+(-6yFB^rsbq9jCTG&JHIQAIT*9rXOkOUf_7I-lxIZC}~X|!|iNMR*>-*@|A%o_&3RonY;c>vV3fK88tzqo2-k` zUPLF+O~oXpL;j`i}gEt2Pl<1O$a8BYoJop zT9_tIAw3dHD20$t%yE4RRVl^sGZ@tfS(n}b5ss8lFz6M^(QhiYE6Npp<>%%5N$Xp$KT{Z&Imrlgy!Bq556CLbaKOgtnBqeI%!3-^z|ic1Xr!i;#bIIC{J^lzXTx zB?>K9j0-|eq}Z5idNk(6|CX3f45(7Tr#Z0f^^^^?BrGs__8Tc2t-L8n9-f_%11;u^ zLVCyR1Ra^?S>HDnzm@W4LsXgQhRCN{(Xrxd@-fQ2D8)r7wjiyUYX~DorF_AfKVNg4~%tgx1`I zG0yZHv}LEaQMCHY9`^JV#Mh_i(Jfy==a%#`?u#wyx1%KbphU?o8iZN<@|R^g>#xLW@g!5l#T<9#n&Q=koba?{Kumi}5-vXuC88+^M zIb-0sNGcxf9#4yO+jQe~VcJiWDT><_$CXvoc8GFj%n7F^h@Ta25)T*a=}&NKsG+lQ zl)VLw&I|*5K2RxxVRvPi;cI7xoks8E(TC<|^C?)$S46fWQ`JS3qcU>1zg!vbXyEFF z3AScOe<>$z%($_c8jGTBrj6FU2POK;g4T3E+Y>`G zNM;?3S5M2;=G_WO8FUm?YchoY1}s6xUC~Ypp=3(RCdwDR2(mVFDlIjErOiBqMtDP; zxrFx|&_B%S_8OcpS25zJZuun2-!tdIMW17q|waNV2-euIMj0&(K4l`M0A+xfD6V&bNgsZ zv?d?rXu8nx(5`8Nk}kB7?$kTNprxHk0+Zg6CM6xBF~J<;qTCE$$EZzeqgt;PsZ1)P zO0NcpEd6sjjU zDdmZP3`YXs!vXNW0^r93;6nlMV*&7^y3HgOtG5D=1Y|fE06!c69|(Y30^t1t@V)@} zq0Ipa4+g*w1i*U(;QIsM`vPDHfSUv0_&os$_XfbbkN0!@LP2({!>ASnsEvXa?X;m# zrJ3e{U**#bn*{-Foji)(0Jn|+_=^De^8oll0Q?yb_jb~!0q`pbYlOY{Z)l8hJfau$ z>#HbLImyIu2S4_pF5GdCaOj;6K>0}k{BZz$E&%ohz@7m3qX76U!hS>V!+;ED0^s%l z_=5oW{Q&sA0QlVi_;ldVdnX{nsQ~!x0Qh78{8j+`W&r#~0Q|Zx(|_o_7LegY0NnPN z$zp8oFiRs=h_r4^f$F02HAT2wB7I7-nNf>H)MIG&3#*7=E$)ViA)@$Y%ci=h*sM@R zR%qX>M2iWiV&jtL*nX9;?T-9`!+WOj+%KK?jvGI2=>LfSm`>76R*Na!WXfhIyBj7T z-nu%MZDJ2zxO%&7HFQjxs{rFlbU~I=;S4n1VH*%c0Wx<(g|81BS7_G}7_i9<7oTmCUpHpbmBns4ix(LKEhwUhFzVbijqk^dBPt-Nf~W zmV0tcK~#6GyI};OY6^eG_fj?2`MzC^evy}A7%(id4JiJ{4WVQdqLHFQ6jQ@wOKc#o zw6IFW3gt)21Ipz}8@IR8vO-MD_mNPw3>f6fsD|3xki~qsz*5ba#Z@%8G?FwrTnq=Q zEf#KnmE|T)E9KC#*zy6KS!OZQXI~?4nBi=-#R@B{ExGVXjU^SH-#*M?X@DnTI(l$aHP{w|}^ka4D0oN&^VDA z7$M9N@6}+AWy^RZCw7_c_In!ZRpZm@YW`{GLy~IH-(3^mucxQYu2`?CH%HT1Eo$YX zB>qJtc$Ju?ww6Z{a<*WX@cneJ0w1nl&p$kqkx&QvgJ}$0{?_DIOuSc)*}AJ)QBdJu z&3Lb@_vwK*s+Pdq#}^Io`;vICv^PuBZf!N}KbV&wtVv(51ar)r$JeCPAe#;P50V+a zO#?77y_iD2GVK&1N%o5IA>ZI;IP=xyT4?)iqsgOEx=E0-#Z-f{L2L{a6od8IAAqPYX5-e@d2AtrYLjp4B17KYMtPOxQ0WiNbNPu4z z@LcJW?t3es2*@B0fMo%&Gys+az)S!v4uEN&V%=K-DjG_>z)dv{3js89|7?10q}1D@UH>zF9Gni0C@Ay0SQ+F;I07pN&tL00RAZez7zoe z=nrpB`N2OS<@*5my8!sx0Qj2#`0D`ps{r_-QoBJk7FTfUzR|s_<1`)Fzh>eBD-1H# z=h$e50vKEV;?!xRK^0EApYEDvFxsc*&D90Y{W374mX1!Gk(#A!TXwNKetS_MdsG_y#uNGG`zPI1?#_^KP(tJgqjx%DSV z-&&9u1jY<^eX>ur(+D(1#25wosFd;0st%nEPh}L#VEi98Ll6awcGoBIRYto@kM2mS z9Mv43{VJ+>)?l>%sqXqj#EKL@baD?>fIhR>0Uc+e)!Ze1Efj3LyM6%Tt*e{Z+s25uX-M2l;JCXW z7-QBfcYS|f6KLvssUalMu@>4^k9|1rpMSlQo+?7$a!XDHExQg=T5_^c1xWBN_XFd#q zKxVq@bNNd95i~B(jf@8UwitAn7Km>L{Ync1j}!%8jM-hE<10opzK2O;o5x3f2#)my za@bOB4GY4;m*B3?=4Tc`S3+aB{aL;fNA=9W|L1{N?yk?e#r6fQZGn!$lr3PqJZ=Hh z*I0wGGF#pC7Hop*>Zz{2J>&ru1Bw?zW#5R!%>#?0g0w@ja(BI%ALa3Wqx|ZHW&1%d zoh*Spdqx_AVM{6QdK1=p-aH}YSs;E;WE-@MTh4%FP;t zT|Weo*Vf`h-1B8nGRXcO9MTm>2P;9%aMus!(F$$D(6+b00hMJj7>V>CPT?|>2WZc3xlq*&0SxLxY3-g>7CmlJ_Y6Pn@=S^3oV)X!N??7<&yud zGNSzgj7ftS7@ghC0)uOgVqcX7;v~eUBJS47#SFOj6$}W116H`}i}t$_CTfQ@q37h>J2s{A}LXDY1=p!LFljbLm@mcpjILZ6Tx z#5A+oU2n$%>gsA;GkYl40xdZyt6=|!@k(g@X3VD|%pLEA2+2Z7h(t{;hbmo2NEDy(YfTO*c$evf`KnAo7|i}KzG#JhgIzdl1{)UJW0 zk0p+P)vE6KJqT8clge-&`NrOk1J|B{b>Pf3%><`9_`XABcbJdd1j;Tpv}g1NZ!v;5V~12s};eNtQ!tQ&Z~ zds{yujT1MpUu?#Oe5W8b?Lp()12nCqZrnbhHPGe7x7elS|{ zc=xuxh`XvP-_<(;ptjR?0a|Y62QyR?-P^*jm1}G3h6^V+^XVzCz^rHT2L&-ov)$W5 z5#_RFYkN&mNR($bq`n7h1B`3`6wfl>S=_v#tgqBQ%oks!G1Z1*<(EmA{6;;rx^=u(ZCYQ8tRLJ{U5kk;y{m$vpjFe2IXFE>MI9Dt zSgEyM8rKI|7{(sLzT3T319%aN$bRzWgAp zc3Z;T^{eo+Hg05-d$}-v7Il4ACIvrtjSaeo3*GfA5i<(8sl8?tB=^qcC1;_lxt|1{ z`n)J;Gjy4|z810Lw4d6m_`#Wd)_mx?Ej|;<|E#Hn&CeGELqdby^$x^MoCCca)&e2> zQ!p+qCuUx->~)B{eub|^1S$z>k>-f`P`paN9zqV*2Bm_dtg;-bW8+4FU50QLN%#h6 zJET!U+xwDH!5Z37K3?Xlw$PYl!o4a&=0n@$xUta2%sE$>&;(H!sKFADREG9;C-!FUVrWz0-Wy%g*$0E+k?Y;{ zOMEq7FCf8?S5bh`9hv_Cot@7+Epq>{+M=Y^;?gCFJ~xM^FF=7lcN*NQ&;15U6XwnD zUa3w(lwJwVC9A=n2BNobsiVo zp?I<-oP>6Yd`%MBJG(*2VtWiY+{}~E`ZMk{I*PLn2!gYhxa&6~ZX7+4e&fEPU&!+2 z3$toq?hDpnrg&6KHX+`+I#f$~Y?^R%M8Rrk+?+KVj04M1X@1cf9)w60=dRy~;|Ep1 z-YP@+nIZ?YzNQ}r%U`kv69ib`uD=6oj2@8?A#VlhrKM+K*65%-3}|e-9b+I}eO-Mg z3c9XsnH^W<|J$1Q&s}d@(%khMx~F0`Utprgxott{4BW#6?NLDtY@AuvW1C_WuD}F= zK9;J`C*km`)?=*C5jM>!;TBhzoGw8DtJ!SB+D_$o7}Q^m}vT|2ccuf?8P} zoUvI#;l#bU_0WEC;>SVjGyG<{IuiI8(rmIwquvu~S8IyZA<7RGF8Lx^oa7s3pLix3 zOWq@_&L&1sm+H&(5n4@^+{XV&X*IlIWG)dWEUzu>fM{_i)9)3pKv(~uj2Q*)hN*~_ z7wdY4qy~5ICcj3fdK?7q)p3PVZrpSIn zdATtNv?h1MWUM$*Zc}?I4qZh>3!uX~YatviE(%t6+=6Q*CLwO(KvWKbJ(7I-GCwhhIn&^7NP>BnlS1K4ip@>t%m={HPtq5hskX!*I3La}i8$0z;}R^I;sN^NpLgS{w%wQBToP~z*v(QZ8havJOnm1y2|Iv!>i9QIaB+A_| z6j9?EI3X7SYQ=zK@brYB*)EIZGbE6FEKnYo_AXC4K@9XrcLVV%&zqO;lJt-cj+}Y# zj(-L=FAYk5D0epymok#0z+eHTlZ&vg?vtzm=lOUBb}b7EH?mRUQYPUDy~+`k9Jl`t zU1r?k$_%x47X;%ANp&}rVAG-;C76WK7BanTCA9oLzb+VtXLmOcXEKJKx-`PZZHky# z1Dhu-T>@7sY{3jl6m^Pt#KIOvkwKcewE55Z8z5|8Q08!4)K-XXP+dK@w{4NUa?*~0 zDj^u-{T8LWfq0awt1-}`XCA@bN^Fb4_}KU_f{+uAH|8>WsYUdzNVgO8MrD+5C-dZ% z+(?>w8_xyEEd*CfuA4zF!XGZ-+kw=HJ7!Nhkh>b` ziEI?>8KLT)pKKE`EZEhV76u*F<`vLlOH)AW`W5RUdh@$5VtF`5EMtfoA47aG?WC&L z?__>%q9dH*=B!=unEVjak*k;HQQL7dKG3${Zo7lJa${)m*JM|=FqqPnYvHza<(|Qv zrziThX2Xq;q|o?FZU!8=mRkTv3;118+~g*QtN11N=~Q1Vx1**+=~;@cLZs=@xNk28 zf%9Ri?T^YA2ph*$$9ifYZG!bjK{K#Q*0TgU`xk{lbY^jtFH*E}O4s(1rVHO=BU#UF+y^tPIzLKT&wA#;PcyAo1odMhSWhMQ<1DMzFDu56&jtA$>-WN} zBGxmfd9L+0L7EG3&W6$LxmS7>12dUZBXI8z-FWR`+>~yTx=rlHlK~k&m>%dWz!&RD?_RFuGoqFsp$6y#bnS}tq`|r$P4@_2iua`A zOJp?~)c#zAq0%{H>-=5{@ua|umrQpFOAq%X!}y;}JA{bJJV}UX{rcK0xcXNS+K4CO zPUOaWYSM)-CV3L!>C2{l!n6d>Aeec@^nfs}uO|WOjgIZYG^1xA#K$|Dg%l(A48TIC zPsfFFD3h;}uJ)^Xyr(}@^ml-;nnq7NzEN9Sm_yH44se_mrs+Kf zxP72wt1vCr69>8k$8KSo*%J$`364X;8peBKy4#o3u$rm{}H|JQnLPkN`@^QElH9PpIN(|YQVU5wsDTooo34BfZe9(Whd=9P}K5=2%z+mDk z(R?gL)XSF57fN(w&S?k7rQF0I!fi2_TyjgnLI?k?Li^P?2DV?$9Z=Xqg8yfOi%BLnSf>vi9*}(oAveKOF=l3|@kNaO)>@1|NsBC61@bR-#Yx7Fljq zy5kWV7jK!GyJji+9?*G#Ha+!OW8FI+0p4uy7OMw}V9vabqOz&*`e;!}Axwpv(Yap>d z0&Ndbn^!vifMZ1k1B1{?Gu+#JJj|uY5i<1B*ZaH}tT{B-y-kAsH+%M6SA0(sKue=- zHaKD!2h{xDZOjOwz6I`W4B|$6WP+cWBn#SyU1*R4>Y=nQvSvjJ8~5Y~;b~GTA12FAyYldCli{$S zD=!&vSB!jk}L*3gbU%LzA&d%J6o`lA?vetp|yA&Dh{Hwc#f@uHY z?rp^5jXH`T+6al;b)oVLXoUP=h4r@zwIDlg#b$^*NDTOyI?Zs>M#YkF;O&~d*l z0-Sd(;2^U<1}d=3^q`i*poxh1x{=up6-Yn?dnJ@CkTpw87&Ds%6?;m9THZ0y-LSz& z5(0xL6vq_eac2Xvr9gjVjSpf<9OP~wes7GK?X|r?J5v+~#%W9oR5HOkHKw^6*4+}{ zBK@(up)$X+8XC0&WYD@l-x!487UymtZt%Lg{OTUIYJtvaY0tq%S1?8+7I#5f)7`?o z+!QET(NZwYWHv(iFG?xwm32pfng6j+cwBE~OiU>iBgZ3(iVcuhV(mvupM#oBD( zRX3jAL`|n+h@7+ox`yFVV3SL&EJKS#zOCHv8E?&o!eQ2ReR(pJmRWJXEIXr#c=NVoCReAr^kO z9^Qhsp5Lz}vR9%oyK+6DXnP@E8q|kh+UzMAIpazxrg_9vMB)0cttFmo_LW7v%`zDi333QEWv>p5sloXbE#-=NTrb=ODp zX!BiTdw3{) zCZ{}a$I~;0=W$;hEHKWZviC@ArL_FpJ;QAF=iIgv!jm>6drsR(@aL;wo`i=pOiFvGQby+z#Wj^i`It4P<8dw5 z$Z;8D+q<9PYN@RNO2^wu_#M)Hr*K{BO0;t~#@nP$b3ak7NHm%#I8Ye@;aQ6s;YcjjQwcZDx9w3 zJ!|n{_bz7#URdP6Yg~wD4L+Scn}7Ad`OxWL>SJfX_>=$EbK#yk%vM{=?{s#?q+AB? zZK(+l7tF0?p4FIxSeO6U6awl3aeuD ztl(eetAeZScKE$2_bkV!ygl2@+q37Z&*)j&i#}wUbQy2p z7JlSxPYr$~3A!d9S!y>`L&g?L0Ue(gOchpC?^%MF=FRi9*%SR{gYzZY;8&kmPc>$_ zwLe_kh=t&ERSxjmuE*wC+%1yuZB^Q5kPDno1#E<9_AEj?w{}zPJ7^=WjiUlL&0C0B zx<9?gjNVTu{iJ)UFbgjrL22j6NN_G1F$gl(*5rZyOqTyffM(Bv-V$9Kq!0OhSF&e5 zKJDH~5LxmjXgO05A#9vn&ux5l_>BPPoy~e!=s#@kuvMzgO~>y>1|PX73;3l}9UZpy z{{E$N(_nF@?TXOlHiJDkm3O(3Z6ndbb9xGH^<2Qn{n~U9du|HvCR@9RWWr@i?*Q3o zNr=6gJvSN5U)jD8B(iGu+$7HMHSSdvcc_LvH=6t8Cp>dgfQ9YLo*Tvec-f}%JJg`d zhKE9C?UC4Rv~=p861(-e4fa6{_wcCsobOyI_=&O;!F0Os=;qT>yG;Soj)g~P&mC|g z#QvVJ4BRH`c6g|d-EW($$*gAs9167yKa|z7p7oq7%+C0=Gw!{$&POR4^s4pzp(}?-(*9Lt|tbr@Y4`%tYcLkI72?%O!KpM#fGu%2aLEw|oO_VWJb)`in) zuNn&C?H>xzq8Hh#g6RJC_k{y?IqOy8hp%71aVn0-?zOD*g)NQM@hW^S^_v6imjoDD zE9;eWe-5-~2y5!9X1y}V8)WwzVhrn*BF*An%BS}h;D)SaG3x?{jq7&SD}jt8`&B_1 zI1IcDWF*`Dh5_ajlbqr3^Zu!N1QR|J2j z*{=x_*+kZ}nY)>8kM;XBq~A@DnrS~RoN8yVo{c`@NC0!DJrtZz^n1>a@3k%bPVJ5H z(H90*;L7yp!uP{wi(ie|F|0S51B<=PugFELHwwPXvR@GP+62}c$@kju$-Gy2ee-h3 zFkz>;N?C6N9LceBf{$`7V7>jIHrIY$kjSc7Z#Xw2&rbOjyNvbrg(X(|ML|}KZ19G` ztbF@B!oEsjGrbztt3^!wLU@7+7r=Y` z8VBFfz&NA*f^gKdFD#+GX;4~fzaT7OF6&K&&YAPV;oV^)rjg}ibI`@l_(g57BUx_> z7c$shENnw|EbC3?<_@tB7N#Pf_9ns6p>`pk#=skIA{-fJ_dAdV`(O}kAMW1=^H^^J zY#d=fCzu}DSk^lbzZjjmEVdnV>`a*t?IZ1v32A01>m7hJvuxSKTOkdmD#?9*?UK!U z`@{Ir_QS#k*u;9{K|aR*i!gCB>otOEto>I(A}eFP2GEVO|1C%)<2MeX$J;*@vg8e{ zHx}zVdv?C7It!Y9FPa4^{*w@^NrE>>S!jEDHu-;MJg4^toe!YOrI1+Zux1SSMtCsbSKx8PLAe#qgDX!G& z`XQuRh|V>Y^$v%{ReqD53u$*4l*Sf@!s%maF9~NBHiY#KMVw@6K%A}5q3zCLkl*Qt zb2jT80@lTLzkn`mxWN!rZU0p8xvo{Lw~YT>T&qS%yL*k=qJY39j0W?T!u2Koq*K9q zOW<;i{iF~h<}5~x{F*O<@yxu?51=j9k2T@k8a%lzgpb_(k)%$>GCU+!uG)onqQM3+6fP!Z;<@ z64pBt=DO@1LUO{fJp;zE_D*3Ua`5R;w8fu;W4}-1O56ecj%_d%7T;+J5>Nx1#d;@W%a8~{?75c1MY#OZEaUuoZX)ZQ1l#KEr^0&YUl+2Bi8#{GNP17b zJ-WFpV*%XkiXXwZs$kTTp+UCncbBEL;o0&8Tw6`X0iJ)9Eu+G^HY z-c1LD?BReT>vQ32uHo$e>+DP5qN?8a?>74aC@3Px;J!0}>;nuaAh@x)D>5jqs6d)p zDlm=;<${*HEtaUI36@ytwX(u2E3?G<0xec(o9)5(#e^L|;?k3W=Hw;S{epdRzCG|-YXT^n@eEFQ$?FM`EQoIwpkK}B)`ivzI zY8;AB+nfNr_k2Th4MVS~yp4`BPE}%g5SVG+37k3T6_)5>>>X zm*!VK!QP^VJ+lkSYS>a zUAWL{_7*)a24PIW*1qoDPl>&GJ&ZhQxgxo_96vS@yHKR?noMq90rf8qkB73~bNbO4 z5ZPfZ_`PEJN80?b*S*&{Ag**aA@tzC6dG!m&ys$X21`2E;s@g!+P;3DU}E&$PFAh~imGs@o&*OB=y(&TV2=zSiDifJ^5r zzepc}wMXy##W)seKA9fx#L{zKzr7A z+`bNLZwZ35k1T&me`T?~#RQW+wtOQgwDP;{ErG7@Fu>Uo&?vN3npOFHdrKd%UbI}4 zWM${PGT9+UmMBHF8&~Ey8>MuZ#K-n_ek(A4iZOiJJ{db1 zTJehy=yA*Ygy(J1g=(r6Y3u<8rT z^?=S%+dk6X60Udc5|Z5qB1~T5JTQoM+w3Tf^otAjmQnaFxb0j%AqWwO=ssJw_qVqU6EFT`aWgT{s+TbsJ*Lev zh|_O{;cb=);=OH_EjkGD?6=7kZ)VF&Lx}DS1GX22j1vF4Zdu*k(30l*Mw)OUlc37f zYi@;tYj0y=l2@E`VE`r7mQ;xA7AKu(;!L6?1%kWBNoNwIF(!+#-f?bgS>@Q6$B@SC z4HqAbolcZ*%G@4`wPG}+_lT2<6e#euB#CJ~;}p_TE64sU5oY_wxyi??>@8OORYbwc z+0HWm>i1FgaQE?PvArb$g8ky8lB&JCy(M1k(>u;>7)3jPmN+6+c|*E?oSO%F>>w;; z4sl*m5pZ(e5-SGwiF2QvfSV~y48M|Ze4z0as4~SJ6BK?7ZmO76xHLv0O+RdkBMW`8 zP&_nlBwoaT&1+V)IHhmgcetlwaqX#`KaZ+1-?6%(;?^N?M-)WE^L4US)Zp4dH^==G zOro~=)icJo3ToZy9Q6Yw&foy^^?|k-lb(ajYcXB%(~HsIh&Cq5hmycnU^^YZ7K%Ps zXu+D`UEWnqPc+*ix|n1Wzf)TVn-Y7W+lLVF23oar&0Keb#!;zuX6){EmQ0uXu#lHH zS%7|L+N}=LJ1W)xSr@a&QK|B7?>l=*RG-A6d|cKA>w%#H^f}rkFDfG)l|&(vtlmm| z+7?-37D821w>%j6Y?r((qdgy;5~C~)wP`N1;Qvj&0bcq(f1Y!5Ja;evf}@e#n0hioec3!R~UQ$>k-zkJrZurXJDE z2}ZqA9i_S^Z07fHB72^l$&Ciz#<*OL)5ATDad)%7iB}urdb1GJ5=Vl7=V0LVC=!7^ zn;_xhvG?P?p1?IP5g#+fk98Z1a8-4&YgP5SKDwCLXuI2c;rw;cL7cxzZz6N(g|0dD zu!+NecH0ER#TsI>hT`XB$otBSUEq&%57P4_oa8mnhtCGYOZR6`JZvt269eOakji+t zia8I5#o4pTTo*I!39TuUPt&5d9hV{Iicb%YkCVP9&JdgP;qM{w($&LqoKoB1#?W{- z(R7x*IS(I$9imI72ue|%@$Xz946zTiH_t(F8EbBAxq`@SA^Qv-ZVQ0rIM2(4nQbXIZP94~l!#$(hy-pU)ekGX@IK8CMBZHf+Xv^dK0$UzWf@dEGc4K_37+)S=t%9pBasylseM%WX z9(s(hG2;Bn_(zm#t@a0|jJ?jlcdhyT!CW@DKZLgCpYYc0(^cq}>2h>281h=F{ed(t z@V9ANp<%d2?`&D3&*gj^HLQK}(HTqM{5Qe$O}<7P{Z0NMp7%J%xMGl-O{xmlEtbC6 z7!}{=V_b5)gG~i4IWs+o>#15v^95ur26|5Z; z%_PV&@v=;uEMsvorJ)fc=m*8h-V`ItM9VT!vP>j0rFSmw2-%y$Wtma3OqeVaS}IG0 z$TDVGCRmmkDZ`Yq!U!3P6^6?)!(^GEvdoY>F{LqPGb;?f6Xj$E$ua|FnE|p)|JxYy zeCLDu-A3JF`pPmvvW!WV33O-llGfkHDL#nzZz(~owgYWr+bc-(PZA@+MQHDct&w$+Aq2ER!wEWJwwKelk-^xxGS$EHg=#nJCLl z=wOD?oz(aaie|>Sq`C1WI|rj<>vk!94fj7pYKN}0iKWuuT%Zj1oSW_rEm5{JFE zm8_P$_cYm?rpht_vW&kh(_6~8e{DZ0<@VmaWEo#srl%~^ql39^RP^bf=&$W9%XF7z zy2&zLF6K_7qNnUl9m4^HhZ3WC}rMSWq* zzM^C}i4OWJYnxhlzta8OL?@IL8)&nWYlYS zLYXNmotR?$?@j{T8QEfeLza16mN_lUyw<_oR#i`RP;^yokY!$#WnPhGUUo5es;Vbt zZ+c0Vc~O>mL6$jzjEn#$%HDKbmN_QNJTJ>UCojM~D|^#XS*Bi=IU>WL0QZax#R^Z$ zGKXcEI$5UnPK--{J9H`cWX16T!sDvqXpT0dJq1>5=WtoR$8IWbFZ!x8}P2YCiqHbdzlw}@} zWp>IkJDf}@4mh_xs7m&x?Xt`^S!Sy&v!xebDl=ifU-qW^WSL4?rb3n}-7HIOl4Ul^ zG7ed0!=0JsIDNChC=5ASLr7W|;#oTG=y-W6{5?Q8LmMM~DmLnrG^e&UVX{jtzD9bF7Wfsd< zltr>PEtF*z$TIU~7#w;FWGGgcC(F#0W%6Yi+npHK(3^KB$~pATk!5manc1?;tlOAj zw+7gmw^6s48Cw1(6U|OQS=<`skA{U>6%1=g@Zp3{!P<~8R=lqvVI|KgvVna);UXkD zdJ_@+NpcY_X1|{{UIT@td zvZ8Y?NOu4V*~y?nb~31toeV14Cxf?-Z;JN0;2k;uh3sTdAv+mV$W8_oWHNZixu8Oa z%$+*`h3ry?LUyS`A-mL}kX`CfC}leUg+lhd1zCodWjGn8lvS`Y6so<*F~Ut*=3iOn z#+?{f2XOsPl(Pf4Cd>RI%ls|N{B;}CA$$LM8+EG#_(PWYU6%Pxmig74k;>k`xKk3@ z`>HI{Cd>RR%lsr^DB1f*3FR(({~*hJFUx!<%Y1u_xn1_Ya*Mi6B>qO0`C69wN|yQ3 z$=p%){zCSq%d*TRS*BH%`CMN1{!I3!Pi2{lvdky4OzFq6)JL++hq6qIEYo~vrW7UY zraM!}{7;toK$dx5mbq{Trqs1{(s&2T#hjOA-jikCm1WM+%(T#3-OM{Q)xn&VW!{!$ z-a2lwm9rx@@OSZZ`D}hPj)DF7?mW-^%6-9oz@6rvnfk7=Rd(eQ90Kt+qX~94z(bIqq;s@AP z;74nqnOAAx6&iS%22RqzOEmBz4ZPq4q#x}BokXO8<1}!L2A-#Z=V;(rH-LP!qizTS z^)zsV2A-jTr)l6Y4b;&R z*g*qTG_aipw$Z>=8rb5=m%42w3HQ@U?xTT98mORw%`~uy1~zsASpP@?I64s*uz?2b zG*C_hWi(Jq1M6vEoo6Z4wXLO-+)D%Z(7+lRSWN?~XyEP+pwzX8C#>v1+JO}`a2E}f z&_FQ_6w$zP8d!E)87-xg6w<&F8dyvNi)dgW4J^0?l(uhT3iEFvw}1i~m`4M1X&|2l zY&4KZ1Eq6lB$oze)4(hmm`MXOXka=GOlt>lQKS8XPo}CDnG{ee zWKf7Oi3TRpzyul?PXps}XKV`yMB4J5e(D1RinBdlPh zfdm?er-3*cu+Ts(4a7JBsr(U5CyAngNE(Qsfp8iaMFU}O0FggJ-4FyqXuwPZ!89b*1mv2nqr_^n z^;K5jcQfe3GSh_K5<{(dus6yY!fnrXoo|dKXX3j38z&$>5@ucO&nGb#L6u<*;0#{S zKf{`m1g^R1ZK9|VmO%Vt|0 zNqjQX1gaX$%%eZ_ud!mh#U{R)-^VZIqi_=R9``T@wr8@@rfONX3eumj5-Tqw*bZ7F z#pJ!#%PJ*m>f!-=W6=AW?U41f!g-0Q4CEqQtU6*XGI?ti8O%HEc}D%S`ZEjxa#CHV zeq`s-9;Nyf`nmcneWKp157ZlVH*`PfKGB`ky`Xza2f9tVm8i^-txM8{pyIKI_FwIf z+KbwEv@dE8X&*u#c6VzFv^m<*+E8tf)>Cs+^ONRN%{k3Wnp(}nnhMP-RMwrW8KVi) z^wpp%5%#>gQoUNeKs`mBtRAKAr|za^Ri$mJ&sFcKURE7eJ*v7-wMLoY5$-X-!^cBm z{N4DKvB~&`@tE;R<1XVy<4R+Jak4Sl7;YS3^f4+huE|%1Cc_(sV}>UUy9^r*D-8vP z$%bS@xM6_7$Dq*vuK!Bkq<=$yO#h^QmwqGL%C>O~=ZX5lCN6`sa#dUzS2U2z;~KdJ zuAZym&Af^C5CVI@W25nf_~BVi>~5FCM5lSY*s^;G6Dp7@bF{sVJ($xU{Um@(frh{a{m zUR0e|_|ia68t6d-J~ZG>1Knw$n-h@qUQdlm;%+z$drOpgyLPd_)5u(m)FhG}Ay64g9YIP`As^ABacy7d3FD9Yj0+J`G%; zfkqlQPXq7Kz`HbX?iOIVCC|Te3%LcHrGd9;;H~4WRw2VXj`@vI`+A&Yf7e!OUehYI z@1kj34|8I!j`@wd&Yj~P~2Q$-(H;fZnL z^C5}jM^oqdU0o22Q!w4ErWa7(HFljS_T#uuk5sw zL}S;Qt#dEHUH-9M31*V$s3e|viCL#x$QZ8+z5J_7{f|1s#&p3$b-JUn$1M-lhED&% z__mmHD{Su*+rMjOvpy~_($aVVXvCIy$be?ERg1C&@OY}-tg{P#yOSK1-cHlY_OJ{A ziD84FXrtx0Gblr+pOXmq{A$dI;kWD&rjr(JjtVu8Fq=o&F=#}0SCLBMUZM}g{NcYq z#$zE$`0$sQE;vozz&I+qVZKiHN}V>Rx7;ip0aN7=B}CV?``YiquQxd=y`0r+YNstV z?5#tiG0);5jgX9QR#_UuUkiD?R(4F6PgLMMD_1N@UKuur3aRZ(+f;TsdV~= znt_3yx*m>UsiV^9Eb^)4rJb3Z#S0JT^DNx;QB+p~#bA>oQJT<1vsY(C4Qv>wv)$|r zcrgn0J~AYz3n6~A%nc@qc?+WodUzW@GCppcYxvPP*zhd-3iuZ#=6bW0d>j8P?&FN& ze&>#JtGFokZ>TCtjM|YQK*PMjN_ej*F<9`pkJ&;z4WK1rJ2o6UtON*}Yb5$8@emT4 z9w$bWB<2{172@3_ZHdc=L0RIz!9oIii2-xN&|VN!pXkG>o`s0|#3@8eJiFat|FT0k z74m;JahV|KziBsPbh+|JCJg>Mp7*~AdhbRw~JPI8L!_H6xuR42=SfY5+m*<3p=BkQ=l@b z`^D&X;xdf4wh3dnPSyHqK0;gQnHqoQFZEaIXVk0IR<%a;KCYn5Q;ksmp?p1?-eCQ z!v9tv^4-f$MAWTJ5_s3;8*7p>S)OIKLt>;gj=Q(>-^{cT<1St zcAnv(;-vN5)v+${XIEmFa&J-s^u&L}UH2ya!#YWEOG#23d%P@3$YY~1c0-ij2_E(3LQu}=Xml7c`nOFi z2_7+eCa3d;yw=es(cU`xLk(p4r@sn?mqtfo>eu1YH=`}$_U}ip^k$=WDaK6T)Fr#J z#tb;NX3SM3t0Uta#vR2FlnOYk9y5{?&ZFPM_Hnk{_5iW)i1)9&+-=^cU~R z8*_%|wS5@4uq_}MUU@amDn2`J42Rwj^2dbY%9RJX2owDmkGb*&HgknJc@U?Y55vvL zW5g13a*&q8XQpi=w$(#lty(&3H z+*y@8SIs_2zRX_dm#Ka%nN-#{;KF4rXzr0@LCIwbpsq=t+WV;f9?b^5-s25T9JUe- z_cFJZi(@l6C3_z3sY(8bIEMLqck2!4(GNGgY$cn z!O4<2F|P6>PeD8QCdEB0i}v)6doi>Au31 z*(JJ>+N;{68Uy=!a*uGcITQN@ zdjR7R3}=2O?`?BM;8;IysR4@kl)+dNG!Vq6ph(0VB^R-9nM;Y|ZY**B!Ci}Up2bKu zrH&N?`IPs07to?kSuHqMKr=5#qiZU3B83;h+|7qv=NpS%=W7<%`ER`#WJnpvI{y+6 z8dCF7)pb%CmaK^o>M`~^_<4(goL#Y0y{+#|{{d|4c z$;J~G%1n(r8z1m0>JFZV{WQnwQqy#T$GeOk0-sAHGVoIHO7#m7=jl^Na=R|25-%dl z#0$pMun?mKpQBi#uo`w&RjJ{ePzQU9Q%8v_R;6x@Dpjt>Nx~nDGMUfh zzTjWwRc`gnqg3sM1UEsp2WcU5a>xM);q{J08^@Hjm-PKaH;$w;HDz z`x$;RykKzX6}m>P6Ja(D&A@#KbqthtAds56v+xgh_{!L&fbUQtdpsIaDk>nfg#a=XjDj znp2I1v8}0N#BHsqr3yBl6i!TgAKmzKDq6fMbdWhJJ4~$qCN*cZ-$-F9Cqask%RPuPE2*HtNQMf8N+};-b8M!0YRz$jv90 zj1^BVNgF$cL@%pPi{%W9z*?UcE#6z7wnWEnA}?Uaa73@Bk#IzwILAqg6feJ=X6mLO zp5|tPs;XDyP~92bR`e;;TYDL|bBfdv7=-S1)%~h0RWD_$@}P2=GF)+8aawVoVj5&r zB|AU)=bX!V&A<=RhKpZ*oHmNX{Jo+0vox$#%t>+)$=!Uyd4|{z)1smE!?Y;y{WL8G z`_-b)(xSLOzH_lzpQb$xmp@F4fMuVh#fS$MmYiipynhPo%HzAH}9I z%2mqWRd;CuaPu!ciPQN&x;fn}I?U-m;4t+gphKuI=P)JKhNi#EJ7=G8qBt>uyS~Tu z#~Sj-KgF&;V(<_6IWqlU&bS5|4kwR5a4i}`NcMea#3n&$h0DMO_c3#-y~8pzz@brW9MkxnFmyP&)} z-6H<*NV@L;68f$o9S7Mf*gK_1iT5<5C*l}>I60V9+ke~Z+VE59#JYMLuw$`?PI@c- zxv5$}i;#yIk4LzNpifP&J}}fY&X?muAjvc?UEF3Gx1R$xcpTZ{yg)8O#9jTyEx*o& zgTgdEgj4xLglW80Tx}X3DDYPpFSzttinsVo|MAN>$Q&@9+-GxMY%U!%9*+rlOvGan z9vNckpv(;q`9)z=sa*{F8dt2~ge{6#AzSw%_q}cmJ0AKpWX>^UDF0TTR&G@WDy}H7 zvtKs0JJg@c42Q;s%x5wG{^-mvGZq?7Wv=G9Z=m>*85UTQ8Wjppy_T6ED30PFPJ%6p zeNSg@=XmW+4Et2yXSZvBJA(OO76ZhsXEIj|Arf^ge->2c;)E!D6m~K1>-OjhbJV+D}Giyt5~Us60Qla2q|NY8!zW$u+{Le<`8E&*?hH-x;y1+jMxz4}-={$=yQN{B) z7xc@Qu5;V7uJcdr_wTro`7rnOC0F|0mg0r{F(K>5a+NxpiQqP{pf1W9%;9)xF3Q4j zuqZ1`g)tQ3M+vO32cuTvmDp=_*7t)t@zvp;)&si&w(YyOW7ncM{lrS)t?WX58uvk7SYh`7%imCQf-K>zN=ja5rWR;nY9D$i}R6fz>eYlOfq#+}W75 zMa6065%KyxxKNi&2FCZvMYNdzQP#c`K8a1h$x-$k4$t#V*#ls!Df?NMoVqS3JCqZC zhND5*QDS{ic8-xn|EcEeV6E;~#Zcil;brte(9!r{hB@0Lt~F=Bt7V-*(GRu%z@Jl5 zZVk)EZbhLd9eAiX!;)RwkBGc^*#xP~ktiv5@yqIB)gXQb*NfS;V{i{ZuUxx5?PE9k=y2sgQ=Vp5 z^$x?RT1t$|hK>6{UR+w1UCN1rRdYX7AFs+5JcTemj^AY(gIz~Wb{|f465?xc#cM@P z_P?B<&SjM7bR9ST>-J}R2_$GG`6^4qs|T~Y`>NxZCm7g&HK`{&ailYewx`RoP69ZX7&L(Kp<&fbBUDN4utl|tsPOTsLDyEz? zjw{3XA2~7NeWn~7l7C^05Y#Ux5t94n#KNVaIYeafBo~olTi+aS1DlQ?Gbflctc6kL zoLseGlBSRPJM}Sfw>jssj$=L`?|mKu$Ep=@GA?HT-aZblhUbu9kq#l@Im0$zj>{1* z$4%~w0Xnd2ogB;Qj$zk2*@^)`C#PvR_6JgNub{W#cP8Ux?mVNm%o6OtNmBGsy)-k2UCeI?O%Epypwiq`26br1;a=2c=F6hcypH za^*@FIWGm=a_`+d1le1!_gLI!HwTJ=&4ZD3z8-|E3!V?>KaU+G&AfN>pw8PbBo$S3 z?yrPw@|mAXNY*9_L*9mDuT| z@7lT9D`vXr6*J@4$wDHlW-xBBM*s|{n&HDK3LvIx#uS{o&d>{N3s{Sjdb@ff)8jL6 z%izHoD^S|@BXt0$5J`TKV$-e}!T0%xGJi7cRdxlpjQNxMo(tm_@?Q!0T)oCi-3)z9 zGqc1J)6BP2u2IeD4*0rhCUFP+rsy?fW?K*9J~)MejtG;+{4D1`9;@Tb{S45t3Q!hr5Tf#1&0r|VszrngXqpMWoEc&N}KuR6yF%Wo>8Z(d#SFdL}8Ng zq%lDAj^P;p4fxf}oWs_OYilqc*GwwliJ3%;sXN%_XOl|Y4cG&i&x9vtisJ+`bA`cv z&D$F$mT5g;SldiAV0{*>Z8O8fm2ER8p#dvKMLCbF8P1K*>%Y!C4yOX0qSi?9>Tff< z_f|)<0~v+4@DZpzXQgoH*KwlftO)Tw&sq6A$2?Ca1@*4!?R~2cm16owG>kEPzX+ z28Zf?3W>m^g zge|SJKI3(NV5n%a9GLLqtP$erOS3*!;E0+r(G;j7a(UCWMtdguLy$I)*&nB~mYmoS4=t%La1+xRA$k+H6>yg}G(eOa-@3Z(C2D|6B+|gvkY;0TZ z0C9O+?vq*wSsFMRleUtJF`FO1mMcDfZO*(g$~5L>_5}vYf(DNgE!I3=R?UCJzop!% zEQi*bZeztK67vRgkUK7Kg!uR9yoWe;E~Jgi3lg78$*UH`3*++4Y-ugE-Otog+x<*! z$9BJKbDycDcKexHYPX-MrFQ$7+PB=d|Cw5+j#5Z8d_FqQ4^~ge8^aw~1V`}g;F)oG zUhI5WJ1#FoJUJn6>}&c2=Bk=6=06uEsODlVw`FjeSuoAh)?ceRi~YwC?mLePGWytD z4Q@|Qn^_#_Wy@kYMWd@@%6NN_0tgOgyV(xH(Kk@w&N-7A$z9**`Xhk+@y}H9N1^zF zx2-2DsGnxKL5Mn_yYoh|XAj$W)|r^wrE}eMzNhVTTm?JgYkQgiq-hLvGU8jkZ0~4F zYbpI0rk2u=VQMM;7^arek6~&l{TQZ}(vM+kCHgUB@0_V^SC8pXH)d)n{TQZ}(vM+k zDg79xmeP-5YAO8~rk2u=VQN2d|H!3GEu|mB)KdB}Of97!!_-pxF-$F`AH&pA`Y}vx zr+$o64Th=x!1*F0JB(eJT1qj7sihQSnA%2SqP4QLZln8;VCoz+uz?2bG*C_hWi&u( z?l5)hT`4-hS=~DNp0zY^FAdy718ZntH4UtC2TGZ`yWJ6{ZY2$@pn zBvu+opn-TAh@$}u4a9Z=P6bP*E~XQ44k>lfG!R7tku(rN1K~6W0$55E>Xv1A}N_APo$lf&RCR3;pOMeQ6+w z223;%NCSOnAmA3zZlcE2`QJj?g}%DpG~h=Ay=cIf271y!4;m=-p%HHy=uQLOXuyjG zJZZp#28`{%EfY1S&Oj&8(}0c!v^1ch0W}S%D4?`XNg;I#8W3oJrvZ)zSQ=m`p!TK< z=#)Qd|D}`Mpn>Z&aE%83p@F|?;4gOoWyU|<5vKMJ8u*I~Z@Y0+Q^RWpgY+$4D@iQ*WH`JsN^FbwVonHz?#f~rp=)<|x& zv-akZ@ZM~jG>E2sxV?FV>&7)0krg+}wg-ae+f2CiYC8qy0^3b-(tO(xwzTddRfFq3p@ENS z;3FFNkOo?4pqU1mTtH`kRQEqR$pJ`G%;fkqlQPXq6{12`Xg*BxQ%&e6aY8n~YZ z?xTT98mORw%}zi%A9{!GwCm2&H@!^*Z_&V;G;oFn-jD*$`M~Q^gviIIY2Y;)I7I^u zH1H}7yg~ynBj7$CI7ugYi3VPzffs1t1PzEZa9jeo<^#thh;u&hJPkZY1JBaHQ5vYH zfg?2V%x&|5r|BezX`qetJ857C z4OG#b&*8DwR>s4;BiW%u z0fem5Cv`gO7t6Er%k;guQ%oA}vU4-p*O))pS=?Y=t%~QD=|0dc1-~j=E~kru)m64W zJa=j-9DUF>P&{2_^VUM*0NY%sdDIrd33DLmbPCb3$c4QR;3970qc*dk*5bxY8S#|g z*x=&WJ#Rm*nYEp2wq}A4kQ+yy=TWvcOu062h8$*cZ zXek`s7!n~SeQnce`2x*AF{90PQpN4u4aUE0r!;=Vl$~KzgPeQB-NcPL3!^89>p$f& zICUW{TKTa9btfN(pbyeQluvLBqlhG1Nx|azzimqt%3sS6!BD+>1H;9YH*NQuOa{DN z&$KYc6NZ_3vo1i>rhZ1{sWb>D_;PMA(?Yh3#UA$Yj*8h%BMFQx+MxyysQc{;4cDek zuHM+^9w;x5?$?EgemRbcS(r5%!rhj&d7|Y5s5uuss0-PKIx1$m-k9yQ2yne|%b@Axx*7FcIYu*04?bIt$Vn5uleqhdO$?XO%JG&LZg zyC$r=PuL(dU${EFU_T5Gi}n5Q1+bx)qhcDq>6$h6=uT@Vu_n}FX2G*0R6vcZy$_@X zjqQTzLY$*wDrQZBe@bi@u7+9O>8jZR+MGg3K+y?QNvP4zNGJXoJeFn7U_ z#ptM*jG1C|=a9}i>-jI*LNpWxCqdi$L8#>XZ`W<>20AKoF#n}Xix+f!G{F4PP^LM~ z1UE1D2!*OZ#cc5W0PQ1YY>x8ng5A?JM@6>tGfJBWFF_kM~d&n=7)O+kX}fKvs~l<42Sf?weM<5)x(wlQ*0M{aoam}PzAP! zJ;PDC7>k31u3ysmQEPs%d;^A_UA8yepm0c6w5E zS88pVqjDbROUyVs^9_YdvBQ=_!pZ~vXsirh zQT?s843^y&-jztqs~wg3m@BRatdZn;p*GqdJZH@N1J>Oi-oFbS90uC8Vf)2UsWUsi z9AK_1pyBsP6QMD*{p0=j0m(kdQJIHz7nSZbQcJR{Tf!@#{&G+_)bNv)u>Ej!(0`AM zdps{+uo4CFn*8aUHX3qk@=an{P5w<4`!Z21dVwXX)Jo|6ME+3hDZV4tS7vc{ZT>WZh%+8Y_b7=9c*u6&cD5$ukRh`l7tp9#)F0#x_`9CHFv91lz(Fg z@I0PnkD~@kn_qel`&dV1EPhFBEdwMYl`SW>2QKgged#np?LxInaa6{*>a>@8wy5x% zpE?`brWHQWm5~~&bF{PKMVBXcw!!kLv8$mqYL*W)P8~86mJf;Ug3MyaaLG~5;csQ< zD`P*D8I!pb8e)cLJBRHqlm(jcN8&qUU!2^TF&a0Smt&=ySKF0hz^@kJECx5b>R+09il`?`VplV2vAo3W{dMb|or&tfO+2vlz0=M|6l6 zp%8L;Xd!HmA6^7UM@IX1p=BpHD#I{eo2`6+wEGN77=8|Ff}_n{7?Nf>Dnl_#(rI>z znphQ1{oCg^Ncy_KWs2N|jPbjLV8*1ibxu1-9b4qn3XMUrT^T=f9F=CwlL)UXJDLOz z%2#7iH__RCyU?SxS{xSC)6T$MuHg8!c8ydsO*8RIM5%^cfXnj>;%l zHgD0g1!Q@}>0!MyKd~1#*F&L?-WY)yEJBTGU2pK3W{ZI>Te82GD3Ow9gm*q8a5z5W z{MPIbf+Azg_=E`gCmg#xHxez=<_cy0sB6Wr-x!kFq54nsD&1qcFlCHlzM@gNL>~ox zs^+5mx2x!0bw}k&JmJu|7{U3D$~kz#zq0YP$WfV!Trl?G_}4;5WiU>thd3(xaZSKn*Zz$l*N`J<}X5bIWFlBN2 z#gLz28lbqFznhO0H)WWf)p8!A#Kvs_IN`+coGjIReuW%p|(KZ$ZeQMQB~ye-SsH?T2=~ zi;_9RkK*{EMZfBA*ldMY#rKf8W>J)ww|>zR7l?&Y#^RZrW*FpUEcO=f z&RA?vv+Y)gPTS!hGZvE-pmh+Gxi~_6WcuR8^Vt!od%rl6Q+x}ShQ&k0g$;`z6*vYh z(HlL#bgAgp>tl(EZiCZkSDbxj@xdWPW4~>2EXQv~t>DELERn^zET{TG{Os!DLoA%y z=QI_$L@uJmYrig5j#sIU<2UqWU`%6LN~l`ReZxJ?m2i<5H~bX4i5;hoRks?>8nzp9 z488Q9>G$gw>PO&iqo`Y}OT>8V@9aqQjbBF|Kfc;hh^vn! z5ENDzB90F!oUDbulhi1DR?i@dpv8nRi)KsV|H6d9%I{E1+B6^6{fY{`IpudEnu|WG zbXBo|s|wMt!FDldRpA$E^1aIn(QxJ$h$<@_DK0E4T*KjxTw{N&512O;Vx8Ir7e4Tg z5N#U@_gjSis{2UJ-G_Rr1$Cn8O^?$ao3uAQ(p2|})`r5dDrhVYz<;6#lPdB89E}|u zD{^lYa=vUZ+SwPzaYi*HwHEg0JhFBqb{E&R7T&F8eMzZ#qbmAOg{U0fDB}6~Om!R^ z#;{MbMZ~j=KMb}mwfO5SitfVaLane8Hx_m1lyfg%$Vc)V_b&I4SY}0|?|AkSvo^`7S(SxUC2 zM?vwz!9;I53^MmE4c=UNWT{wrWZAt9VZO{=jK@Uddc${yIQ>Jqo4Og=V_HEoQ~e^U z*%vBBWjDp$sL&t6S8{)HQ!((p32on2CtHN_Ax-Jve=u@S=NKGPu5C(#w+}{^_2?K! zL&^sN5Mm)_*xlv;f#a;FqBHl;w#p-6``pZ-nBn9s_UahT7J z=SP;n;o3-(^roPuG4OFM?u1Jo?%y;TO6%M+_iajoz{8Q-r0Ig165*x8?)Qc?St0M~ z$b+5oLXz&owxM|~uBjA)9*J8(fS481}9iC$@nxy!SXN6_q&%uB&ff1PdB6~0-pZT?Dm;Go5EqnSLRL9 zd&f78f}g%Jzb3^-Hif|}Uz?whn8h3@TW+pyGYft?wuCXxGj!LT)*jGoQ_oahSF9EK zb8oUGr2gixzGQ`JCCZmLeHk`YB3~Xn6(8slFS{@$n&zln;S#VVGAnp#;#Y!oHg-8} zePg?_*nvwwcVV`~jnK-DSqqeXVfhXobi@uC-|;#B&rWUbbySvkyS)6+i(Z#F4=)y4 zHpX^klK|(j#m)kVE}zg*sBlytzXHl?bM|*-PB_w0S>*aohUp#it7>EXQpi}Ixd2vH zM*DVQcGcZcxg00O#l_K`OETEz5*vCf1Cuf^9?I^I?#fb+ucLCAYqBj_%VxXb+cqA{ zOMzuuqPybOZ=9oYDOu$)lpBgA%bsTN>^c8FX#LhQ8LTNo1(>*{OFjTHxEDspK^2x=CtkGJ3e#2oAut1=Kh$)%9Ui? zx$t3FwcE{+&HdVMMjCaI494}ab+xLWWMUUm?$g{CbFkT*NsihE&W1hvBL}#3yUjtj zy4?`4J?gU*JP(>pR3@g*Ofnzvu|ZiS@17*6Ij}RA#M9lDK=6~MzP+SBc5-tcwR5Q< znY`}N*YnW3${sVW(0&#(Rr*2^%>mA=ib*ndy=tG^y9{aeN7h*+AyAxFR4u)ANOSK_ z62E^jY)>z8`?cC;KTJ_jFoa}&idWj8?qsZw+Y<&f_rfI3!9E1`uFu=y#`-t=BD-`c z4&fmXqE{}0wu&Vt=}Q=!dt!=3i=2ZHF22|)2uL#t zYW8tH#`)cBZ!g&DR+NjHy|F0C5({~Y3k69f2xtU!wKEfMRGEhc4wwrIv~j zy_;}p^^@2gQY^4(1{g2K?vi3*P1E7%#n?mMokdtasA-z>XO4sIk442p#>)b3C0-00 z==Q;eHBH4tP9tGB>OVXH8hQ_#>@+m?kv78KO;a!#S_?zbrPz7UmRK}M`uM=6$zc0D zc9*mg^=`^>-9qX~%&>egeNJ01H)XqCkVuprJ)N8Q7TuJEzM~?KNFO$&DHFEri`*lX z6n&a9Fe}nu!6kLb9A|GOl|6%+CLzCcDTI6*TYQI}s%c{T?POu_R!`;jCP7UT!1KvS zkXD0;rt!{Pu^O12U5?uwLz>3H@&l23BqHE(a}ny|>X{3S@n5tW=e0XE6I8z`pHkc_ zWb-Qa0+^~YkM?4i@_TXZ2Z!o{f>g;;uo;g2rrz;@=i>r=KYF5TL9`2kV2Go_2M0c~ zc-g5a0G|FKMNsv@k_GUze~9mYcU5UmbyRq_yQ(BBxB zRJdr?-6^;fbP5hZbBUu42t*bS*bMs4M%esPE$0{t2b)DW z)an%EyC8L19TmhEFOCjaEy8Z;kHC)IBUV73YG6KW;zGN!2T|y#AZ~b3nzidX^A#IU z^yhG<&W8@_LS0+vs4(1eP(4ht17Qa2u7By`zU>B$qe72w>h#gvF+mW6K8xtdIJMHyr}@}>vee3exQLsfQMJZ;~!R6xJ_ zB6Qko7o+NnZ~+IGOcn5=SVY`y>0s{!b%^-2Skz~*f1r9gLza6y(meFW^TwU(>6#Zb z3FCme#$<9W(TOk{zqm6%fQiJGh>y1P30yqL&##-fDUIr#Fx+% z2-)cs35%{~ekmx=GDSovF6>AVA^!DC=KH*$n#N?3dna@!f|M^@yDQez{OBp#Z&ZfYWRsBNlR)t-Z&TC z4Bqn1#&<4>ihai}Nkpf1jOeM0eUzSFRH62a;>` ziLe7;y3tF3r-Qq+E`T%4zS!(>Gc&exu?0>IjQ<$S_Aaf7peOnu%yIT?Hw6%U*&fiE zGWiV5(8l?8)zx%>qtb+V6XO0@MSXOrqp~-?E>^5woxMM_y-<<|^`G|B zg2^Xw1{~Sj?oGK140Ij*e8ljYCmXf6t;;XO1M^l5A$z-c-GawlrXTLv+6@_w>N?n|Qp9#}|0KgU2~M-o@iR Zb^V+R4$pd?<=&tB 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 z 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 {"a": 1} + json_str = re.sub(r',\s*([\}\]])', r'\1', json_str) + # Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости + json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str) + data = json.loads(json_str) for item in data.get("verified_matches", []): scud_f = item.get("scud_fio") staff_f = item.get("staff_fio") warn = item.get("warning", "Точное совпадение (без опечаток)") - if scud_f and staff_f: + if scud_f and staff_f and staff_f in staff_fios_set: mapping[scud_f] = {"staff_fio": staff_f, "warning": warn} + return mapping + except Exception: + pass + + # Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть) + try: + pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']' + matches = re.findall(pattern, raw_response, re.DOTALL) + for scud_f, staff_f in matches: + scud_clean = scud_f.strip() + staff_clean = staff_f.strip() + if staff_clean in staff_fios_set: + mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"} except Exception as e: - print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}") - + print(f"[!] Ошибка резервного парсинга опечаток: {e}") + return mapping diff --git a/services/knowledge/__init__.py b/services/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/knowledge/service.py b/services/knowledge/service.py new file mode 100644 index 0000000..c0f09a0 --- /dev/null +++ b/services/knowledge/service.py @@ -0,0 +1,37 @@ +""" +=============================================================================== +FILE: services/knowledge/service.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / knowledge +ROLE: Доменный сервис базы знаний, правил компании и синонимов подразделений. +=============================================================================== +""" + +from typing import List, Dict, Any +from core.repositories.zup_repo import ( + get_all_rules_from_db, + add_rule_to_db, + get_department_synonyms_dict, + add_department_synonym_to_db +) + + +def get_rules() -> List[Dict[str, Any]]: + """Получить все правила базы знаний в виде списка словарей.""" + raw_rules = get_all_rules_from_db() + return [{"id": idx, "rule_text": r} for idx, r in enumerate(raw_rules, 1)] + + +def add_rule(rule_text: str, added_by: str = "Human") -> None: + """Добавить новое правило в базу знаний.""" + add_rule_to_db(rule_text, added_by=added_by) + + +def get_synonyms() -> Dict[str, str]: + """Получить словарь синонимов отделов.""" + return get_department_synonyms_dict() + + +def register_department_synonym(short_name: str, full_name: str) -> None: + """Сохранить новую пару синонимов подразделения.""" + add_department_synonym_to_db(short_name, full_name) \ No newline at end of file diff --git a/services/prompts/__init__.py b/services/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/prompts/diff_engine.py b/services/prompts/diff_engine.py new file mode 100644 index 0000000..ab0c677 --- /dev/null +++ b/services/prompts/diff_engine.py @@ -0,0 +1,77 @@ +""" +=============================================================================== +FILE: services/prompts/diff_engine.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / prompts +ROLE: Формирование превью изменений промпта и генерация визуального HTML-Diff. + +AI-CONTEXT-ANCHORS: + - ANCHOR[PROMPT_DIFF_BUILDER]: Генерация черновика и HTML-разметки изменений. +=============================================================================== +""" + +from typing import Tuple, Dict +from core.connection import get_connection + + +# ANCHOR[PROMPT_DIFF_BUILDER] +def build_prompt_diff(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> Tuple[str, str]: + """ + Возвращает кортеж (merged_draft_text, diff_html_for_ui). + """ + act = (action or "ADD").upper() + + with get_connection(row_factory=True) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = ? AND is_active = 1 + ORDER BY section_id, item_id + """, (prompt_name,)) + existing_nodes = cursor.fetchall() + + nodes_dict = {(r["section_id"], r["item_id"]): r["content"] for r in existing_nodes} + + # Формируем словарь для чистого текста + nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (section_id, item_id)} if act == "DELETE" else dict(nodes_dict) + if act != "DELETE": + nodes_dict_for_draft[(section_id, item_id)] = content + + draft_lines = [] + curr_sec = None + for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()): + if i_id == 0: + if curr_sec is not None: + draft_lines.append("") + draft_lines.append(f"{s_id}. {txt}") + curr_sec = s_id + else: + draft_lines.append(f" {s_id}.{i_id}. {txt}") + merged_prompt = "\n".join(draft_lines) + + # Формируем HTML Diff + diff_lines = [] + curr_sec = None + display_nodes = dict(nodes_dict) + if act != "DELETE": + display_nodes[(section_id, item_id)] = content + + for (s_id, i_id), txt in sorted(display_nodes.items()): + if i_id == 0: + if curr_sec is not None: + diff_lines.append("") + diff_lines.append(f"{s_id}. {txt}") + curr_sec = s_id + else: + if s_id == section_id and i_id == item_id: + if act == "DELETE": + line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' + else: + line_str = f' {s_id}.{i_id}. {txt}' + else: + line_str = f" {s_id}.{i_id}. {txt}" + diff_lines.append(line_str) + + diff_html = "\n".join(diff_lines) + return merged_prompt, diff_html \ No newline at end of file diff --git a/services/prompts/repository.py b/services/prompts/repository.py new file mode 100644 index 0000000..2ea9b7f --- /dev/null +++ b/services/prompts/repository.py @@ -0,0 +1,114 @@ +""" +=============================================================================== +FILE: services/prompts/repository.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / prompts +ROLE: Реляционное хранилище узлов системного промпта (таблица system_prompt_nodes). + +AI-CONTEXT-ANCHORS: + - ANCHOR[PROMPT_REPO_GET_ACTIVE]: Сборка активного промпта из узлов БД. + - ANCHOR[PROMPT_REPO_APPLY_ACTION]: Точечная вставка / изменение / удаление узла. +=============================================================================== +""" + +import re +import logging +from typing import List, Tuple, Dict, Any +from core.connection import get_connection + +logger = logging.getLogger("PROMPT_REPO") + + +# ANCHOR[PROMPT_REPO_GET_ACTIVE] +def repo_get_active_prompt(prompt_name: str = "main_agent") -> str: + """Собирает структурированный текст системного промпта из активных узлов.""" + with get_connection(row_factory=True) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = ? AND is_active = 1 + ORDER BY section_id ASC, item_id ASC + """, (prompt_name,)) + rows = cursor.fetchall() + + if not rows: + return "Ты — ИИ-ассистент SCUD Orion AI." + + lines = [] + current_section = None + + for r in rows: + sec_id = r["section_id"] + itm_id = r["item_id"] + content = r["content"] + + if itm_id == 0: + if current_section is not None: + lines.append("") + lines.append(f"{sec_id}. {content}") + current_section = sec_id + else: + lines.append(f" {sec_id}.{itm_id}. {content}") + + return "\n".join(lines) + + +# ANCHOR[PROMPT_REPO_APPLY_ACTION] +def repo_apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> None: + """Точечно применяет действие (ADD / UPDATE / DELETE) над узлом промпта.""" + with get_connection() as conn: + cursor = conn.cursor() + action_clean = (action or "").upper() + if action_clean in ["ADD", "UPDATE", "EDIT"]: + cursor.execute(""" + INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at) + VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET + content = excluded.content, + is_active = 1, + updated_at = CURRENT_TIMESTAMP + """, (prompt_name, section_id, item_id, content)) + elif action_clean == "DELETE": + cursor.execute(""" + DELETE FROM system_prompt_nodes + WHERE prompt_name = ? AND section_id = ? AND item_id = ? + """, (prompt_name, section_id, item_id)) + conn.commit() + + +def repo_save_full_prompt(prompt_text: str, prompt_name: str = "main_agent") -> None: + """Парсит и полностью перезаписывает все узлы промпта из сырого текста.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (prompt_name,)) + + current_sec = 1 + current_itm = 0 + + for raw_line in prompt_text.splitlines(): + clean_line = re.sub(r'<[^>]+>', '', raw_line).strip() + if not clean_line: + continue + + sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line) + sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line) + + if sub_match: + current_sec = int(sub_match.group(1)) + current_itm = int(sub_match.group(2)) + content = sub_match.group(3).strip() + elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]): + current_sec = int(sec_match.group(1)) + current_itm = 0 + content = sec_match.group(2).strip() + else: + current_itm += 1 + content = clean_line + + cursor.execute(""" + INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active) + VALUES (?, ?, ?, ?, 1) + """, (prompt_name, current_sec, current_itm, content)) + + conn.commit() \ No newline at end of file diff --git a/services/prompts/service.py b/services/prompts/service.py new file mode 100644 index 0000000..2906e29 --- /dev/null +++ b/services/prompts/service.py @@ -0,0 +1,37 @@ +""" +=============================================================================== +FILE: services/prompts/service.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / prompts +ROLE: Единый доменный сервис управления системным промптом. +=============================================================================== +""" + +from typing import Tuple, Dict, Any +from .repository import repo_get_active_prompt, repo_apply_prompt_action, repo_save_full_prompt +from .diff_engine import build_prompt_diff + + +def get_active_system_prompt() -> str: + """Получить текущий активный системный промпт.""" + return repo_get_active_prompt() + + +def apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "") -> None: + """Применить точечное изменение к узлу промпта.""" + repo_apply_prompt_action(action, section_id, item_id, content) + + +def save_full_prompt_draft(draft_text: str) -> None: + """Сохранить полный черновик промпта.""" + repo_save_full_prompt(draft_text) + + +def create_prompt_preview(action: str, section_id: int, item_id: int, content: str = "") -> Tuple[str, str, str]: + """ + Формирует черновик и diff. + Возвращает (merged_draft, diff_html, baseline_prompt). + """ + baseline = repo_get_active_prompt() + draft, diff_html = build_prompt_diff(action, section_id, item_id, content) + return draft, diff_html, baseline \ No newline at end of file diff --git a/services/snapshots/__init__.py b/services/snapshots/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/tasks/__init__.py b/services/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/tasks/exporter.py b/services/tasks/exporter.py new file mode 100644 index 0000000..54307da --- /dev/null +++ b/services/tasks/exporter.py @@ -0,0 +1,101 @@ +""" +=============================================================================== +FILE: services/tasks/exporter.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / tasks +ROLE: Экспорт бэклога задач в форматированный Markdown файл (ROADMAP). + +AI-CONTEXT-ANCHORS: + - ANCHOR[TASK_EXPORT_MARKDOWN]: Построение структуры Markdown с чекбоксами. +=============================================================================== +""" + +import os +import uuid +import logging +from datetime import datetime +from typing import Dict, Any, Optional, List +from config import OUTPUT_DIR +from .repository import repo_get_tasks + +logger = logging.getLogger("TASK_EXPORTER") + + +# ANCHOR[TASK_EXPORT_MARKDOWN] +def export_tasks_to_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]: + """Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/.""" + tasks = repo_get_tasks(user_id) + if not tasks: + return {"status": "error", "message": "Список задач пуст, экспорт отменен"} + + # 1. Фильтрация задач по статусу + if status_filter and status_filter.upper() != "ALL": + tgt = status_filter.upper() + if tgt in ["COMPLETED", "DONE", "ВЫПОЛНЕННЫЕ"]: + tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["COMPLETED", "DONE"]] + elif tgt in ["IN_PROGRESS", "PROGRESS", "В РАБОТЕ"]: + tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["IN_PROGRESS", "PROGRESS"]] + elif tgt in ["BACKLOG", "PLANNED", "В ПЛАНАХ"]: + tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["BACKLOG", "PLANNED"]] + + if not tasks: + return {"status": "error", "message": f"Нет задач с фильтром '{status_filter}' для экспорта"} + + target_filename = filename.strip() if (filename and filename.strip()) else "ROADMAP.md" + if not target_filename.endswith(".md"): + target_filename = f"{target_filename}.md" + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + + # 2. Группировка по модулям + modules: Dict[str, List[Dict[str, Any]]] = {} + for t in tasks: + mod = t.get("module") or "general" + modules.setdefault(mod, []).append(t) + + md_lines = [ + "# 🗺️ Дорожная карта задач проекта (ROADMAP)\n", + f"> **Сформировано:** {now_str} | **Всего задач:** {len(tasks)}\n", + "---\n" + ] + + for mod_name, mod_tasks in sorted(modules.items()): + md_lines.append(f"## Модуль `{mod_name}`\n") + for t in sorted(mod_tasks, key=lambda x: x.get("id", 0)): + status = str(t.get("status", "BACKLOG")).upper() + is_done = status in ["COMPLETED", "DONE"] + is_progress = status in ["IN_PROGRESS", "PROGRESS"] + + check_box = "[x]" if is_done else "[ ]" + t_id = t.get("id") + title = t.get("title", "Без названия") + prio = t.get("priority", "MEDIUM") + due = f" *(срок: {t['due_date']})*" if t.get("due_date") else "" + status_tag = " `[В РАБОТЕ]`" if is_progress else (" `[ЗАВЕРШЕНО]`" if is_done else "") + + md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}") + + md_lines.append("\n---\n") + + content = "\n".join(md_lines) + + # 3. Сохранение файла в изолированную сессионную папку + tool_dir = os.path.join(OUTPUT_DIR, "web", "tasks_export") + os.makedirs(tool_dir, exist_ok=True) + + session_token = uuid.uuid4().hex[:8] + session_dir = os.path.join(tool_dir, session_token) + os.makedirs(session_dir, exist_ok=True) + + filepath = os.path.join(session_dir, target_filename) + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + return { + "status": "success", + "filename": target_filename, + "filepath": filepath, + "download_url": f"/api/v1/files/download/tasks_export/{session_token}/{target_filename}", + "tasks_count": len(tasks), + "message": f"Отчет успешно сформирован в файл `{target_filename}` (всего задач: {len(tasks)})." + } \ No newline at end of file diff --git a/services/tasks/repository.py b/services/tasks/repository.py new file mode 100644 index 0000000..4c51c73 --- /dev/null +++ b/services/tasks/repository.py @@ -0,0 +1,181 @@ +""" +=============================================================================== +FILE: services/tasks/repository.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / tasks +ROLE: Низкоуровневые операции к таблице tasks в SQLite (CRUD). + +AI-CONTEXT-ANCHORS: + - ANCHOR[TASK_REPO_GET]: Выборка задач с фильтрацией по статусу и пользователю. + - ANCHOR[TASK_REPO_ADD]: Вставка новой задачи со сквозным ID. + - ANCHOR[TASK_REPO_UPDATE]: Обновление реквизитов и статуса задачи. + - ANCHOR[TASK_REPO_DELETE]: Удаление задачи по числовому или строковому ID. +=============================================================================== +""" + +import re +from typing import List, Dict, Any, Optional +from core.connection import get_connection + + +def normalize_task_id(task_id_input: str) -> str: + """Нормализует идентификатор задачи к формату TASK-XX.""" + if not task_id_input: + return "" + clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "") + 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}" + + +# ANCHOR[TASK_REPO_GET] +def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]: + """Получает список задач пользователя с опциональной фильтрацией по статусу.""" + conn = get_connection(row_factory=True) + cursor = conn.cursor() + + if status and status.upper() != "ALL": + target_status = status.upper() + if target_status in ["PROGRESS", "В РАБОТЕ"]: + target_status = "IN_PROGRESS" + elif target_status in ["DONE", "ГОТОВО"]: + target_status = "COMPLETED" + elif target_status in ["PLANNED", "ПЛАНЫ"]: + target_status = "BACKLOG" + + cursor.execute(""" + SELECT id, task_id, module, title, priority, status, due_date, created_at + FROM tasks + WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED')) + ORDER BY id DESC + """, (user_id, target_status, target_status)) + else: + 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] + + +# ANCHOR[TASK_REPO_ADD] +def repo_add_task( + user_id: int, + module: str, + title: str, + priority: str = "MEDIUM", + due_date: Optional[str] = None, + status: str = "BACKLOG" +) -> Dict[str, Any]: + """Добавляет новую задачу в SQLite с автогенерацией порядкового TASK-ID.""" + conn = get_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}" + + target_status = status.upper() if status else "BACKLOG" + if target_status in ["PROGRESS", "В РАБОТЕ"]: + target_status = "IN_PROGRESS" + elif target_status in ["DONE", "ГОТОВО"]: + target_status = "COMPLETED" + elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]: + target_status = "BACKLOG" + + cursor.execute(""" + INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id)) + conn.commit() + conn.close() + return {"status": "success", "task_id": new_task_id, "id": max_id + 1} + + +# ANCHOR[TASK_REPO_UPDATE] +def repo_update_task( + user_id: int, + task_id: str, + title: Optional[str] = None, + priority: Optional[str] = None, + status: Optional[str] = None, + due_date: Optional[str] = None +) -> Dict[str, Any]: + """Комплексное обновление атрибутов задачи.""" + conn = get_connection() + cursor = conn.cursor() + + clean_num = re.sub(r'\D', '', str(task_id)) + formatted_id = normalize_task_id(task_id) + + updates = [] + params = [] + + if title is not None and title.strip(): + updates.append("title = ?") + params.append(title.strip()) + + if priority is not None and priority.strip(): + updates.append("priority = ?") + params.append(priority.strip().upper()) + + if status is not None and status.strip(): + target_status = status.strip().upper() + if target_status in ["PROGRESS", "В РАБОТЕ"]: + target_status = "IN_PROGRESS" + elif target_status in ["DONE", "ГОТОВО"]: + target_status = "COMPLETED" + elif target_status in ["PLANNED", "ПЛАНЫ"]: + target_status = "BACKLOG" + updates.append("status = ?") + params.append(target_status) + + if due_date is not None: + updates.append("due_date = ?") + params.append(due_date.strip() if due_date.strip() else None) + + if not updates: + conn.close() + return {"status": "success", "message": "Нет данных для обновления"} + + params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id]) + sql = f""" + UPDATE tasks + SET {', '.join(updates)} + WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """ + cursor.execute(sql, params) + rows_affected = cursor.rowcount + conn.commit() + conn.close() + + if rows_affected == 0: + return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"} + + return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"} + + +# ANCHOR[TASK_REPO_DELETE] +def repo_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: + """Удаляет задачу по номеру ID.""" + conn = get_connection() + cursor = conn.cursor() + clean_num = re.sub(r'\D', '', str(task_id)) + formatted_id = normalize_task_id(task_id) + + cursor.execute(""" + DELETE FROM tasks + WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """, (clean_num, formatted_id, f"%{task_id.strip()}", user_id)) + + deleted = cursor.rowcount + conn.commit() + conn.close() + + if deleted == 0: + return {"error": f"Задача {task_id} не найдена"} + return {"status": "success", "message": f"Задача #{task_id} удалена"} \ No newline at end of file diff --git a/services/tasks/service.py b/services/tasks/service.py new file mode 100644 index 0000000..ada0787 --- /dev/null +++ b/services/tasks/service.py @@ -0,0 +1,72 @@ +""" +=============================================================================== +FILE: services/tasks/service.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / tasks +ROLE: Единый доменный сервис задач (бизнес-логика и диспетчер операций). + +AI-CONTEXT-ANCHORS: + - ANCHOR[TASK_SERVICE_DISPATCHER]: Маршрутизация действий ADD/UPDATE/DELETE/EXPORT. +=============================================================================== +""" + +from typing import Dict, Any, Optional, List +from .repository import repo_get_tasks, repo_add_task, repo_update_task, repo_delete_task +from .exporter import export_tasks_to_markdown + + +def get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]: + """Получить список задач.""" + return repo_get_tasks(user_id, status) + + +def add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None, status: str = "BACKLOG") -> Dict[str, Any]: + """Создать задачу.""" + res = repo_add_task(user_id, module, title, priority, due_date, status) + return {"status": "success", "task_id": res["task_id"], "message": f"Задача #{res['id']} создана и добавлена в планы"} + + +def update_task_details(user_id: int, task_id: str, title: Optional[str] = None, priority: Optional[str] = None, status: Optional[str] = None, due_date: Optional[str] = None) -> Dict[str, Any]: + """Обновить задачу.""" + return repo_update_task(user_id, task_id, title, priority, status, due_date) + + +def delete_task(user_id: int, task_id: str) -> Dict[str, Any]: + """Удалить задачу.""" + return repo_delete_task(user_id, task_id) + + +# ANCHOR[TASK_SERVICE_DISPATCHER] +def execute_task_action( + user_id: int, + action: str, + task_id: Optional[str] = None, + title: Optional[str] = None, + priority: Optional[str] = "MEDIUM", + status: Optional[str] = None, + module: Optional[str] = "general", + due_date: Optional[str] = None, + filename: Optional[str] = "ROADMAP.md" +) -> Dict[str, Any]: + """Консолидированный диспетчер операций над задачами.""" + act = (action or "").strip().upper() + + if act == "ADD": + if not title: + return {"status": "error", "message": "Для создания задачи требуется указать title"} + return add_task(user_id, module or "general", title, priority or "MEDIUM", due_date, status or "BACKLOG") + + elif act == "UPDATE": + if not task_id: + return {"status": "error", "message": "Для обновления требуется указать task_id"} + return update_task_details(user_id, str(task_id), title, priority, status, due_date) + + elif act == "DELETE": + if not task_id: + return {"status": "error", "message": "Для удаления требуется указать task_id"} + return delete_task(user_id, str(task_id)) + + elif act == "EXPORT": + return export_tasks_to_markdown(user_id, filename=filename or "ROADMAP.md", status_filter=status) + + return {"status": "error", "message": f"Неизвестное действие action='{action}'"} \ No newline at end of file