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 b4b3361..ae356bf 100644
Binary files a/data/scud_orion_ai.db and b/data/scud_orion_ai.db differ
diff --git a/modules/web_api/llm/db_tools.py b/modules/web_api/llm/db_tools.py
index f0264b1..fdfcea6 100644
--- a/modules/web_api/llm/db_tools.py
+++ b/modules/web_api/llm/db_tools.py
@@ -3,42 +3,61 @@
FILE: modules/web_api/llm/db_tools.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm
-ROLE: Фасадная точка доступа ко всем функциям базы данных SQLite.
+ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade).
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API.
===============================================================================
"""
-from .db.connection import DB_PATH, get_db_connection
-from .db.db_prompts import (
- db_get_active_system_prompt,
- db_add_system_prompt,
- db_apply_prompt_node_action,
- db_get_tool_action,
- db_set_session_state,
- db_get_session_state,
- db_clear_session_state,
- db_get_rules,
- db_get_stats,
- db_get_anomalies,
- db_get_reference
+# ANCHOR[FACADE_EXPORTS]
+from core.connection import DB_PATH, get_connection as get_db_connection
+
+# Домен: Задачи
+from services.tasks.service import (
+ get_tasks as db_get_tasks,
+ add_task as db_add_task,
+ update_task_details as db_update_task_details,
+ delete_task as db_delete_task,
+ execute_task_action as db_tasks_edit
)
+from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
+from services.tasks.repository import normalize_task_id
+
+# Домен: Системный промпт
+from services.prompts.service import (
+ get_active_system_prompt as db_get_active_system_prompt,
+ apply_prompt_action as db_apply_prompt_node_action,
+ save_full_prompt_draft as db_add_system_prompt,
+ create_prompt_preview
+)
+
+# Домен: Снапшоты СКУД
+from services.snapshots.service import (
+ get_snapshots_registry as db_get_snapshots,
+ delete_snapshots_safely as db_delete_snapshots
+)
+
+# Домен: База знаний
+from services.knowledge.service import (
+ get_rules as db_get_rules,
+ add_rule as db_add_rule
+)
+
+# Чат, сессии, статистика
from .db.db_chat import (
db_save_chat_message,
db_get_chat_history,
db_purge_ephemeral_messages,
db_clear_chat_history
)
-from .db.db_tasks import (
- normalize_task_id,
- db_get_tasks,
- db_add_task,
- db_update_task_status,
- db_update_task_details,
- db_delete_task,
- db_export_tasks_markdown,
- db_tasks_edit
-)
-from .db.db_snapshots import (
- db_get_snapshots,
- db_delete_snapshots
+from .db.db_prompts import (
+ db_get_tool_action,
+ db_set_session_state,
+ db_get_session_state,
+ db_clear_session_state,
+ db_get_stats,
+ db_get_anomalies,
+ db_get_reference
)
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
\ No newline at end of file
diff --git a/services/ai_verifier.py b/services/ai_verifier.py
index b33b361..91b20d6 100644
--- a/services/ai_verifier.py
+++ b/services/ai_verifier.py
@@ -151,7 +151,7 @@ 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]
@@ -163,7 +163,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
prompt = f"""
Ты — кадровый аудитор безопасности СКУД.
{rules_context}
-В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)}
+В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)}
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
СТРОГИЕ ПРАВИЛА:
@@ -173,40 +173,59 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
4. В поле "warning" опиши обнаруженную опечатку.
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
-ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
-
-Формат ответа:
+ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON:
{{
"verified_matches": [
{{
"scud_fio": "ФИО из СКУД",
"staff_fio": "эталон ФИО из Штат 1С",
- "warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'"
+ "warning": "Описание опечатки в СКУД"
}}
]
}}
"""
raw_response = ask_ollama(
prompt,
- system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
+ system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста."
)
mapping = {}
+ if not raw_response:
+ return mapping
+
+ # Фаза 1: Попытка прямого разбора с санитарной очисткой
try:
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
if match:
json_str = match.group(0)
- json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str)
+ # Убираем висячие запятые: {"a": 1,} -> {"a": 1}
+ json_str = re.sub(r',\s*([\}\]])', r'\1', json_str)
+ # Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости
+ json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str)
+
data = json.loads(json_str)
for item in data.get("verified_matches", []):
scud_f = item.get("scud_fio")
staff_f = item.get("staff_fio")
warn = item.get("warning", "Точное совпадение (без опечаток)")
- if scud_f and staff_f:
+ 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