diff --git a/init_db.py b/init_db.py index 08daed3..bd4a836 100644 --- a/init_db.py +++ b/init_db.py @@ -1,7 +1,7 @@ import os import sqlite3 -DB_NAME = "/home/puh/scud_context_api/scud_orion_ai.db" +DB_NAME = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db" def init_db(): os.makedirs(os.path.dirname(DB_NAME), exist_ok=True) @@ -49,6 +49,16 @@ def init_db(): ); """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, -- 'user', 'assistant', 'tool' + content TEXT NOT NULL, -- текст сообщения + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + conn.commit() conn.close() print(f"[✓] Единая база данных SQLite ({DB_NAME}) успешно инициализирована!") diff --git a/llm/agent.py b/llm/agent.py index 91b2c68..1469a17 100644 --- a/llm/agent.py +++ b/llm/agent.py @@ -2,12 +2,12 @@ import json import urllib.request import urllib.error import logging -from typing import List, Dict, Any, Tuple -from datetime import datetime +from typing import List, Dict, Any, Tuple, Optional +from datetime import datetime, timedelta from .db_tools import ( db_get_active_system_prompt, - db_add_system_prompt, + db_add_system_prompt, db_get_tasks, db_update_task_status, db_delete_task, @@ -15,7 +15,16 @@ from .db_tools import ( db_get_rules, db_set_session_state, db_get_session_state, + db_get_snapshots, + db_delete_snapshots, db_clear_session_state, + db_get_current_server_time, + db_save_chat_message, + db_get_chat_history, + db_get_stats, + db_get_anomalies, + db_get_session_states, + db_get_reference, get_db_connection ) @@ -27,6 +36,11 @@ logger = logging.getLogger("SCUD_AGENT") OLLAMA_URL = "http://192.168.11.3:11434/api/chat" MODEL_NAME = "qwen2.5:14b" +DAYS_RU = [ + "понедельник", "вторник", "среда", "четверг", + "пятница", "суббота", "воскресенье" +] + def clean_output(text: str) -> str: if not text: return text @@ -37,26 +51,71 @@ def clean_output(text: str) -> str: text = text[len(art):].lstrip(",.!?:; -") return text.strip() -def process_chat_message(user_id: int, user_message: str, chat_history: List[Dict[str, Any]] = None, session_id: str = "web_session_main") -> Tuple[str, List[Dict[str, Any]]]: - if chat_history is None: - chat_history = [] +def get_dynamic_calendar_context() -> str: + now = datetime.now() + current_wd = now.weekday() + + lines = [ + f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).", + "\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):", + f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})", + f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})", + f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})", + "\nПрошедшие дни недели:" + ] + + for days_back in range(1, 8): + dt = now - timedelta(days=days_back) + day_name = DAYS_RU[dt.weekday()] + + if days_back == 7: + label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}" + lines.append(f"• {label} (ровно неделю назад): {dt.strftime('%d.%m.%Y')}") + else: + label = f"Ближайший прошедший {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Ближайшая прошедшая {day_name}" + lines.append(f"• {label} / {day_name}: {dt.strftime('%d.%m.%Y')}") + + return "\n".join(lines) +def process_chat_message( + user_id: int, + user_message: str, + file_context: str = "", + chat_history: List[Dict[str, Any]] = None, + session_id: str = "web_session_main" +) -> Tuple[str, List[Dict[str, Any]]]: logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") - current_now = datetime.now().strftime("%Y-%m-%d %H:%M") + # Формируем итоговое содержимое запроса + full_user_content = user_message + if file_context: + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" + + db_history = db_get_chat_history(session_id, limit=20) + db_save_chat_message(session_id, "user", full_user_content) + dynamic_prompt_text = db_get_active_system_prompt() + calendar_context = get_dynamic_calendar_context() session_state = db_get_session_state(session_id) preview_status_note = "" - if session_state and session_state["state_type"] == "PROMPT_PREVIEW": + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]." + system_prompt_content = ( + f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" + f"{calendar_context}\n\n" + f"ПРАВИЛО РАБОТЫ С ДАТАМИ:\n" + f"При любых вопросах про дни недели ('прошлая среда', 'вторник', 'дата в прошлый понедельник') бери ГОТОВУЮ точную дату из справочника выше. Тебе ЗАПРЕЩЕНО вычислять даты самостоятельно!\n\n" + f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}" + ) + system_prompt = { "role": "system", - "content": f"Текущая дата и время сервера: {current_now}.\n\nТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}" + "content": system_prompt_content } - messages = [system_prompt] + chat_history + [{"role": "user", "content": user_message}] + messages = [system_prompt] + db_history + [{"role": "user", "content": full_user_content}] payload = { "model": MODEL_NAME, @@ -87,7 +146,16 @@ def process_chat_message(user_id: int, user_message: str, chat_history: List[Dic logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") tool_result_content = "" - if fn_name == "db_get_tasks": + if fn_name == "db_get_snapshots": + date_arg = fn_args.get("date_str") + snapshots_res = db_get_snapshots(session_id=session_id, date_str=date_arg, original_user_message=user_message) + tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) + + elif fn_name == "db_get_current_server_time": + time_res = db_get_current_server_time() + tool_result_content = json.dumps(time_res, ensure_ascii=False) + + elif fn_name == "db_get_tasks": tasks = db_get_tasks(user_id) tool_result_content = json.dumps(tasks, ensure_ascii=False) @@ -95,19 +163,39 @@ def process_chat_message(user_id: int, user_message: str, chat_history: List[Dic prompt_content = db_get_active_system_prompt() tool_result_content = json.dumps({"system_prompt": prompt_content}, ensure_ascii=False) + elif fn_name == "db_get_stats": + tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) + + elif fn_name == "db_get_anomalies": + limit_arg = fn_args.get("limit", 100) + date_arg = fn_args.get("date_str") + tool_result_content = json.dumps(db_get_anomalies(limit=limit_arg, date_str=date_arg), ensure_ascii=False) + + elif fn_name == "db_get_session_states": + tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) + + elif fn_name == "db_delete_snapshots": + snap_id = fn_args.get("snapshot_id") + day_arg = fn_args.get("day_str") + tool_result_content = json.dumps(db_delete_snapshots(snapshot_id=snap_id, day_str=day_arg), ensure_ascii=False) + + elif fn_name == "db_get_reference": + cat_arg = fn_args.get("category") + tool_result_content = json.dumps(db_get_reference(category=cat_arg), ensure_ascii=False) + elif fn_name == "db_preview_prompt_merge": proposed_text = fn_args.get("proposed_prompt", "") if proposed_text: - # Фиксируем превью в сессии и сразу возвращаем текст пользователю на экран db_set_session_state(session_id, "PROMPT_PREVIEW", proposed_text) preview_reply = f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n{proposed_text}\n\nДля применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)." - return preview_reply, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": preview_reply}] + db_save_chat_message(session_id, "assistant", preview_reply) + return preview_reply, db_get_chat_history(session_id) else: tool_result_content = json.dumps({"status": "error", "message": "Текст превью пуст."}, ensure_ascii=False) elif fn_name == "db_confirm_prompt_preview": - if session_state and session_state["state_type"] == "PROMPT_PREVIEW": - pending_text = session_state["pending_data"] + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + pending_text = session_state.get("pending_data", "") res = db_add_system_prompt("main_agent", pending_text) db_clear_session_state(session_id) tool_result_content = json.dumps(res, ensure_ascii=False) @@ -162,11 +250,15 @@ def process_chat_message(user_id: int, user_message: str, chat_history: List[Dic with urllib.request.urlopen(sec_req) as sec_response: sec_res_data = json.loads(sec_response.read().decode("utf-8")) final_content = clean_output(sec_res_data.get("message", {}).get("content", "").strip().replace("**", "")) - return final_content, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": final_content}] + db_save_chat_message(session_id, "assistant", final_content) + return final_content, db_get_chat_history(session_id) content_str = clean_output(msg.get("content", "").strip().replace("**", "")) - return content_str or "Запрос обработан.", chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": content_str}] + final_reply = content_str or "Запрос обработан." + db_save_chat_message(session_id, "assistant", final_reply) + return final_reply, db_get_chat_history(session_id) except Exception as ex: logger.exception(f"Непредвиденная ошибка: {ex}") - return f"Внутренняя ошибка сервера: {ex}", chat_history \ No newline at end of file + error_reply = f"Внутренняя ошибка сервера: {ex}" + return error_reply, db_get_chat_history(session_id) \ No newline at end of file diff --git a/llm/db_tools.py b/llm/db_tools.py index 8914dc6..6da7acc 100644 --- a/llm/db_tools.py +++ b/llm/db_tools.py @@ -1,16 +1,101 @@ +import json import sqlite3 import logging from typing import List, Dict, Any, Optional +from datetime import datetime, timedelta -# Настройка логирования logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("DB_TOOLS") DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db" +def db_get_current_server_time() -> Dict[str, Any]: + now = datetime.now() + days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] + return { + "current_date": now.strftime("%d.%m.%Y"), + "current_time": now.strftime("%H:%M:%S"), + "day_of_week": days_ru[now.weekday()], + "iso_date": now.strftime("%Y-%m-%d") + } + +def smart_parse_date(date_str: Optional[str], original_user_message: str = "") -> Optional[str]: + """ + Дата уже точно подготовлена моделью на основе системного календаря. + Возвращаем date_str без повторной тяжелой фильтрации. + """ + return date_str + +def db_save_chat_message(session_id: str, role: str, content: str): + if not content: + return + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO chat_messages (session_id, role, content, created_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + """, (session_id, role, content)) + conn.commit() + conn.close() + +def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT role, content FROM chat_messages + WHERE session_id = ? + ORDER BY id DESC LIMIT ? + """, (session_id, limit)) + rows = cursor.fetchall() + conn.close() + return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] + +def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]: + date_str = smart_parse_date(date_str, original_user_message) + + conn = get_db_connection() + cursor = conn.cursor() + + query = """ + SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count + FROM scud_logs + """ + params = [] + + if date_str: + # Приводим дату ДД.ММ.ГГГГ к ISO YYYY-MM-DD + iso_date = date_str + if "." in date_str: + parts = date_str.split(".") + if len(parts) == 3: + iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}" + + # Строгий поиск: ищем совпадение строго по log_date или началу snapshot_time/created_at + query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? OR created_at LIKE ? " + params.extend([date_str, iso_date, f"{iso_date}%", f"{iso_date}%"]) + + query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 20" + + cursor.execute(query, params) + rows = cursor.fetchall() + snapshots = [dict(r) for r in rows] + + result_data = { + "query_date": date_str or "все", + "snapshots_count": len(snapshots), + "snapshots": snapshots + } + + db_set_session_state( + session_id=session_id, + state_type="SNAPSHOTS_VIEW", + data=json.dumps(result_data, ensure_ascii=False) + ) + + conn.close() + return result_data + def get_db_connection(): - # Увеличиваем таймаут до 30 секунд, чтобы соединения ожидали завершения соседних транзакций, - # а также включаем WAL-режим для безопасного параллельного чтения и записи. conn = sqlite3.connect(DB_PATH, timeout=30.0) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode = WAL;") @@ -18,17 +103,14 @@ def get_db_connection(): return conn def normalize_task_id(task_id_input: str) -> str: - """Преобразует 6, '6', 'task-6' в красивый формат TASK-06 или TASK-006""" if not task_id_input: return "" clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "") if clean_id.isdigit(): num = int(clean_id) - return f"TASK-{num:02d}" if num < 100 else f"TASK-{num:03d}" + return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}" return f"TASK-{clean_id}" -# === ЗАДАЧИ С ФИЛЬТРАЦИЕЙ ПО USER_ID === - def db_get_tasks(user_id: int) -> List[Dict[str, Any]]: conn = get_db_connection() cursor = conn.cursor() @@ -105,8 +187,6 @@ def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: conn.close() return {"status": "success", "message": f"Задача {formatted_id} удалена"} -# === ОБЩИЕ СИСТЕМНЫЕ РЕСУРСЫ === - def db_get_active_system_prompt() -> str: conn = get_db_connection() cursor = conn.cursor() @@ -121,18 +201,15 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: cursor = conn.cursor() cursor.execute("BEGIN IMMEDIATE;") - # Проверяем, существует ли уже промпт с таким именем cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,)) existing = cursor.fetchone() if existing: - # Обновляем существующий активный промпт cursor.execute( "UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?", (prompt_text, name) ) else: - # Вставляем новый, если его не было cursor.execute( "INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", (name, prompt_text) @@ -146,7 +223,6 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: logger.error(f"Ошибка при сохранении промпта в БД: {e}") return {"status": "error", "error": str(e)} - def db_get_rules() -> List[Dict[str, Any]]: conn = get_db_connection() cursor = conn.cursor() @@ -182,4 +258,96 @@ def db_clear_session_state(session_id: str): cursor = conn.cursor() cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) conn.commit() - conn.close() \ No newline at end of file + conn.close() + +def db_get_stats() -> Dict[str, Any]: + """Возвращает общую статистику по количеству записей во всех таблицах БД.""" + conn = get_db_connection() + cursor = conn.cursor() + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'] + stats = {} + for t in tables: + try: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + stats[t] = cursor.fetchone()[0] + except Exception: + stats[t] = 0 + conn.close() + return {"status": "success", "tables_stats": stats} + +def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: + """Возвращает историю аномалий СКУД с опциональной фильтрацией по дате.""" + conn = get_db_connection() + cursor = conn.cursor() + + query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" + params = [] + + if date_str: + query += " WHERE anomaly_date = ?" + params.append(date_str) + + query += " ORDER BY id DESC LIMIT ?" + params.append(limit) + + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + + anomalies_list = [dict(r) for r in rows] + return { + "status": "success", + "count": len(anomalies_list), + "anomalies": anomalies_list + } + +def db_get_session_states() -> Dict[str, Any]: + """Возвращает список всех активных сессий и состояний превью.""" + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT session_id, state_type, updated_at FROM session_states") + rows = cursor.fetchall() + conn.close() + return {"status": "success", "active_sessions": [dict(r) for r in rows]} + +def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]: + """Удаляет снапшот по ID или за конкретную дату.""" + if not snapshot_id and not day_str: + return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."} + + conn = get_db_connection() + cursor = conn.cursor() + + if snapshot_id: + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) + deleted = cursor.rowcount + else: + cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%")) + deleted = cursor.rowcount + + conn.commit() + conn.close() + return {"status": "success", "message": f"Успешно удалено записей: {deleted}"} + +def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: + """Возвращает системные справочники и примеры команд для оператора.""" + conn = get_db_connection() + cursor = conn.cursor() + + query = "SELECT category, title, example_prompt, description FROM system_reference" + params = [] + + if category: + query += " WHERE category = ?" + params.append(category) + + query += " ORDER BY id ASC" + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + + return { + "status": "success", + "count": len(rows), + "reference_items": [dict(r) for r in rows] + } \ No newline at end of file diff --git a/llm/file_parser.py b/llm/file_parser.py new file mode 100644 index 0000000..945930e --- /dev/null +++ b/llm/file_parser.py @@ -0,0 +1,61 @@ +import io +import os +import subprocess +import logging +import pandas as pd +from PIL import Image + +logger = logging.getLogger("FILE_PARSER") + +def extract_text_from_file(file_bytes: bytes, filename: str) -> str: + """Извлекает текст из изображений (OCR), PDF, таблиц и текстовых файлов.""" + ext = os.path.splitext(filename)[1].lower() + + # Создаем временный файл во избежание проблем с памятью + temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}" + with open(temp_filepath, "wb") as f: + f.write(file_bytes) + + try: + # 1. ИЗОБРАЖЕНИЯ (OCR через системный /usr/bin/tesseract) + if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']: + cmd = ['tesseract', temp_filepath, 'stdout', '-l', 'rus+eng'] + res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) + text = res.stdout.strip() + return text if text else "[OCR: На изображении не удалось распознать текст]" + + # 2. PDF ДОКУМЕНТЫ (через системный /usr/bin/pdftotext из poppler-utils) + elif ext == '.pdf': + cmd = ['pdftotext', temp_filepath, '-'] + res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) + text = res.stdout.strip() + return text if text else "[PDF: Текстовый слой не найден. Возможно, скан без OCR]" + + # 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (XLSX, CSV через Pandas) + elif ext in ['.xlsx', '.xls', '.csv']: + if ext == '.csv': + df = pd.read_csv(temp_filepath) + else: + df = pd.read_excel(temp_filepath) + + total_rows = len(df) + df_preview = df.head(100) # Показываем первые 100 строк + + table_str = df_preview.to_string(index=False) + note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else "" + return f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}" + + # 4. ТЕКСТОВЫЕ ФАЙЛЫ (TXT, LOG, JSON) + elif ext in ['.txt', '.log', '.json', '.xml', '.md']: + with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf: + return tf.read().strip() + + else: + return f"[ОШИБКА: Формат {ext} не поддерживается для анализа]" + + except Exception as e: + logger.error(f"Ошибка при анализе файла {filename}: {e}") + return f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]" + finally: + if os.path.exists(temp_filepath): + os.remove(temp_filepath) \ No newline at end of file diff --git a/llm/schemas.py b/llm/schemas.py index 3df8037..da7325f 100644 --- a/llm/schemas.py +++ b/llm/schemas.py @@ -61,6 +61,34 @@ TOOLS_SCHEMA = [ } } }, + { + "type": "function", + "function": { + "name": "db_get_snapshots", + "description": "ПОЛУЧИТЬ СНИМКИ/СНАПШОТЫ СКУД (из таблицы scud_logs). Вызывай, когда пользователь просит показать снапшоты, срезы или логи за дату/день недели.", + "parameters": { + "type": "object", + "properties": { + "date_str": { + "type": "string", + "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '05.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]." + } + } + } + } +}, + { + "type": "function", + "function": { + "name": "db_get_current_server_time", + "description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, { "type": "function", "function": { @@ -81,19 +109,78 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_confirm_prompt_preview", - "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.", + "name": "db_get_stats", + "description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.", "parameters": {"type": "object", "properties": {}} } }, { "type": "function", "function": { - "name": "db_cancel_prompt_preview", - "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.", + "name": "db_get_anomalies", + "description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.", + "parameters": { + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"}, + "date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"} + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_session_states", + "description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.", "parameters": {"type": "object", "properties": {}} } }, + { + "type": "function", + "function": { + "name": "db_delete_snapshots", + "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", + "parameters": { + "type": "object", + "properties": { + "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, + "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_reference", + "description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.", + "parameters": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр." + } + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_add_system_prompt", + "description": "ВНУТРЕННИЙ СИСТЕМНЫЙ ИНСТРУМЕНТ. ЗАПРЕЩЕНО вызывать напрямую при запросах пользователя на изменение промпта! Для ЛЮБЫХ изменений системного промпта ты ОБЯЗАН сначала вызвать db_preview_prompt_merge.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}, + "prompt_text": {"type": "string", "description": "Полный текст системного промпта"} + }, + "required": ["prompt_text"] + } + } + }, { "type": "function", "function": { @@ -110,5 +197,21 @@ TOOLS_SCHEMA = [ "required": ["proposed_prompt"] } } - } -] + }, + { + "type": "function", + "function": { + "name": "db_confirm_prompt_preview", + "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.", + "parameters": {"type": "object", "properties": {}} + } + }, + { + "type": "function", + "function": { + "name": "db_cancel_prompt_preview", + "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.", + "parameters": {"type": "object", "properties": {}} + } + } +] \ No newline at end of file diff --git a/main.py b/main.py index 0166d6a..e33edfd 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ from typing import List, Dict, Any, Optional import jwt from passlib.context import CryptContext -from fastapi import FastAPI, Depends, HTTPException, status +from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse @@ -16,9 +16,10 @@ from pydantic import BaseModel from llm.agent import process_chat_message from llm.db_tools import db_get_tasks, DB_PATH +from llm.file_parser import extract_text_from_file logging.basicConfig( - level=logging.INFO, + level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler()] ) @@ -65,6 +66,16 @@ app = FastAPI(title="SCUD Orion AI Context API") if os.path.exists("static"): app.mount("/static", StaticFiles(directory="static"), name="static") +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}") + return JSONResponse( + status_code=422, + content={"detail": exc.errors(), "body": str(exc)} + ) class AuthRequest(BaseModel): username: str @@ -80,16 +91,42 @@ class ChangePasswordRequest(BaseModel): old_password: str new_password: str -class ChatRequest(BaseModel): - session_id: str - message: str - # === API МАРШРУТЫ === @app.get("/") def read_root(): return FileResponse("static/index.html") +@app.get("/favicon.ico") +async def favicon(): + file_path = os.path.join("static", "favicon.ico") + if os.path.exists(file_path): + return FileResponse(file_path) + raise HTTPException(status_code=404) + +@app.get("/{file_path:path}") +def serve_static_fallback(file_path: str): + clean_path = file_path.lstrip("/") + + # Игнорируем сканеры WordPress / PHP + if any(clean_path.startswith(prefix) for prefix in ["wp-", "wordpress", "php", "cms", "shop"]): + raise HTTPException(status_code=404, detail="Not Found") + + target = os.path.join("static", clean_path) + if os.path.isfile(target): + return FileResponse(target) + + filename = os.path.basename(clean_path) + target_js = os.path.join("static/js", filename) + if filename.endswith(".js") and os.path.isfile(target_js): + return FileResponse(target_js, media_type="application/javascript") + + target_css = os.path.join("static/css", filename) + if filename.endswith(".css") and os.path.isfile(target_css): + return FileResponse(target_css, media_type="text/css") + + raise HTTPException(status_code=404, detail="File not found") + @app.post("/api/v1/auth/login") def login(req: AuthRequest): username = req.username.strip().lower() @@ -200,34 +237,48 @@ def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): return db_get_tasks(user_id=user["id"]) +# ЧАТ С ПОДДЕРЖКОЙ ФАЙЛОВ И АВТОРИЗАЦИИ @app.post("/api/v1/chat") -def chat_endpoint(req: ChatRequest, user: Dict[str, Any] = Depends(get_current_user)): - reply, _ = process_chat_message(user_id=user["id"], user_message=req.message) - return {"reply": reply} +async def chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None), + current_user: dict = Depends(get_current_user) +): + logging.info(f"=== [CHAT API] Входящий запрос от user_id={current_user['id']}, file={file.filename if file else 'None'} ===") + file_content_text = "" + if file and file.filename: + file_bytes = await file.read() + file_content_text = extract_text_from_file(file_bytes, file.filename) + reply, history = process_chat_message( + user_id=current_user["id"], + user_message=message, + file_context=file_content_text, + session_id=session_id + ) + return {"reply": reply, "history": history} + +# ЕДИНЫЙ ГОСТЕВОЙ ЧАТ (FormData + Файлы) @app.post("/api/v1/chat/guest") -def guest_chat_endpoint(req: ChatRequest): - payload = { - "model": MODEL_NAME, - "messages": [ - {"role": "system", "content": "Ты — полезный ИИ-ассистент. Отвечай на вопросы пользователя четко и по существу."}, - {"role": "user", "content": req.message} - ], - "stream": False, - "options": {"num_predict": 2048, "temperature": 0.3} - } - try: - req_ollama = urllib.request.Request( - OLLAMA_URL, - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req_ollama) as response: - res_data = json.loads(response.read().decode("utf-8")) - reply = res_data.get("message", {}).get("content", "").strip() - return {"reply": reply} - except Exception as e: - return {"reply": f"Ошибка связи с локальной нейросетью: {e}"} +async def guest_chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None) +): + logging.info(f"=== [GUEST CHAT API] Входящий запрос, file={file.filename if file else 'None'} ===") + file_content_text = "" + if file and file.filename: + file_bytes = await file.read() + file_content_text = extract_text_from_file(file_bytes, file.filename) + + reply, history = process_chat_message( + user_id=0, + user_message=message, + file_context=file_content_text, + session_id=session_id + ) + return {"reply": reply, "history": history} # === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ === diff --git a/pypdf-4.3.1-py3-none-any.whl b/pypdf-4.3.1-py3-none-any.whl new file mode 100644 index 0000000..d7d2339 --- /dev/null +++ b/pypdf-4.3.1-py3-none-any.whl @@ -0,0 +1 @@ +SNI is required. \ No newline at end of file diff --git a/pytesseract-0.3.10-py3-none-any.whl b/pytesseract-0.3.10-py3-none-any.whl new file mode 100644 index 0000000..d7d2339 --- /dev/null +++ b/pytesseract-0.3.10-py3-none-any.whl @@ -0,0 +1 @@ +SNI is required. \ No newline at end of file diff --git a/scud_context_api.tar.gz b/scud_context_api.tar.gz new file mode 100644 index 0000000..c020912 Binary files /dev/null and b/scud_context_api.tar.gz differ diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..8c54837 --- /dev/null +++ b/static/favicon.ico @@ -0,0 +1,6 @@ +@app.get("/favicon.ico") +async def favicon(): + file_path = os.path.join("static", "favicon.ico") + if os.path.exists(file_path): + return FileResponse(file_path) + raise HTTPException(status_code=404) \ No newline at end of file diff --git a/static/index.html b/static/index.html index 2d9ed20..f977ce1 100644 --- a/static/index.html +++ b/static/index.html @@ -178,16 +178,34 @@ ИИ-Ассистент
- Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети или ставить персональные задачи. + Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
+ +