13.08.2026 налажена работа с ИИ. Подготовка к разбивке файлов agent и db_tools

This commit is contained in:
2026-08-13 14:21:43 +03:00
parent a622fd98d2
commit fc1a608a5f
13 changed files with 3628 additions and 210 deletions
+2956
View File
File diff suppressed because it is too large Load Diff
+210 -15
View File
@@ -1,10 +1,33 @@
"""
===============================================================================
MODULE: llm/agent.py
PROJECT: SCUD Orion AI Context API
ROLE: Главный оркестратор взаимодействия с Ollama LLM, обработки вызовов
инструментов (Tools) и сохранения диалогов.
DEPENDENCIES:
- llm/db_tools.py (доступ к SQLite)
- llm/schemas.py (схема функций TOOLS_SCHEMA)
CRITICAL INVARIANTS:
1. Tool Injector перехватывает фразы пользователя до/после запроса к LLM,
если Ollama вернула Tool calls: False или прислала JSON в content.
2. parse_relative_date_ru всегда отсчитывает относительные даты
('вчера', 'позавчера') от текущего серверного времени.
3. Опции llm_options содержат repeat_penalty и presence_penalty для
предотвращения урезания ответов моделью Qwen2.5.
===============================================================================
"""
import json import json
import urllib.request import urllib.request
import urllib.error import urllib.error
import logging import logging
from typing import List, Dict, Any, Tuple, Optional from typing import List, Dict, Any, Tuple, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
import re
# Импорт внутренних утилит работы с БД
from .db_tools import ( from .db_tools import (
db_get_active_system_prompt, db_get_active_system_prompt,
db_add_system_prompt, db_add_system_prompt,
@@ -30,18 +53,40 @@ from .db_tools import (
from .schemas import TOOLS_SCHEMA from .schemas import TOOLS_SCHEMA
# --- [SECTION 1: LOGGING & CONSTANTS] ---
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("SCUD_AGENT") logger = logging.getLogger("SCUD_AGENT")
OLLAMA_URL = "http://192.168.11.3:11434/api/chat" OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
MODEL_NAME = "qwen2.5:14b"
# Модели
TEXT_MODEL = "qwen2.5:14b" # Основная модель для логики, вызовов тулов и текста
VISION_MODEL = "qwen2.5vl:7b-q8_0" # Модель для OCR документов и изображений
DAYS_RU = [ DAYS_RU = [
"понедельник", "вторник", "среда", "четверг", "понедельник", "вторник", "среда", "четверг",
"пятница", "суббота", "воскресенье" "пятница", "суббота", "воскресенье"
] ]
# --- [SECTION 2: TEXT CLEANING & PARSING UTILS] ---
def clean_raw_tool_tags(text: str) -> str:
"""
⚠️ AI-INVARIANT: Очистка текста от сырых тегов и JSON-артефактов Ollama,
вываливающихся в поле message.content.
"""
if not text:
return ""
text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*(</tool_call>)?', '', text)
text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
text = re.sub(r'</tool_call>\w*\[\]\(\)', '', text)
text = re.sub(r'</tool_call>', '', text)
return text.strip()
def clean_output(text: str) -> str: def clean_output(text: str) -> str:
"""Удаление слов-паразитов и склеек в начале ответа."""
if not text: if not text:
return text return text
artifacts = ["почемучто", "почто", "почему что"] artifacts = ["почемучто", "почто", "почему что"]
@@ -51,7 +96,33 @@ def clean_output(text: str) -> str:
text = text[len(art):].lstrip(",.!?:; -") text = text[len(art):].lstrip(",.!?:; -")
return text.strip() return text.strip()
def parse_relative_date_ru(text: str) -> str:
"""
⚠️ AI-INVARIANT: Определение точной даты ДД.ММ.ГГГГ для инструмента db_get_snapshots.
Защищает от галлюцинаций даты, когда модель не передает аргументы за 'вчера/сегодня'.
"""
now = datetime.now()
text_lower = text.lower() if text else ""
# 1. Поиск явной даты ДД.ММ.ГГГГ
match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text)
if match:
return match.group(1)
# 2. Обработка относительно текущего дня
if "вчера" in text_lower:
return (now - timedelta(days=1)).strftime("%d.%m.%Y")
elif "позавчера" in text_lower:
return (now - timedelta(days=2)).strftime("%d.%m.%Y")
elif "сегодня" in text_lower:
return now.strftime("%d.%m.%Y")
return (now - timedelta(days=1)).strftime("%d.%m.%Y")
def get_dynamic_calendar_context() -> str: def get_dynamic_calendar_context() -> str:
"""Генерация справочника прошедших дат для системного промпта."""
now = datetime.now() now = datetime.now()
current_wd = now.weekday() current_wd = now.weekday()
@@ -77,16 +148,21 @@ def get_dynamic_calendar_context() -> str:
return "\n".join(lines) return "\n".join(lines)
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] ---
def process_chat_message( def process_chat_message(
user_id: int, user_id: int,
user_message: str, user_message: str,
file_context: str = "", file_context: str = "",
image_b64: Optional[str] = None,
chat_history: List[Dict[str, Any]] = None, chat_history: List[Dict[str, Any]] = None,
session_id: str = "web_session_main" session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]]]: ) -> Tuple[str, List[Dict[str, Any]]]:
"""Главный входной метод обработки пользовательского сообщения."""
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
# Формируем итоговое содержимое запроса # 3.1. Формирование контекста сообщения пользователя
full_user_content = user_message full_user_content = user_message
if file_context: if file_context:
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}"
@@ -97,12 +173,20 @@ def process_chat_message(
dynamic_prompt_text = db_get_active_system_prompt() dynamic_prompt_text = db_get_active_system_prompt()
calendar_context = get_dynamic_calendar_context() calendar_context = get_dynamic_calendar_context()
# Check состояния превью системного промпта
session_state = db_get_session_state(session_id) session_state = db_get_session_state(session_id)
preview_status_note = "" preview_status_note = ""
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]." preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]."
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
# 3.2. Сборка системного контекста
system_prompt_content = ( system_prompt_content = (
f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n"
f"Вы общаетесь с пользователем: {user_info}.\n"
f"Все запрашиваемые задачи через инструмент db_get_tasks автоматически принадлежат ИМЕННО этому пользователю. "
f"Тебе НЕ НУЖНО уточнять, чьи это задачи или просить дополнительные идентификаторы. При запросах 'покажи мои задачи', 'список задач', 'мои дела' — СРАЗУ вызывай db_get_tasks.\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"{calendar_context}\n\n" f"{calendar_context}\n\n"
f"ПРАВИЛО РАБОТЫ С ДАТАМИ:\n" f"ПРАВИЛО РАБОТЫ С ДАТАМИ:\n"
@@ -110,21 +194,60 @@ def process_chat_message(
f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}" f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}"
) )
system_prompt = { user_msg_object = {"role": "user", "content": full_user_content}
"role": "system",
"content": system_prompt_content # 3.3. Параметры инференса (Отказ от "ленивого вывода" Qwen)
llm_options = {
"num_predict": 8192,
"num_ctx": 8192,
"temperature": 0.1,
"repeat_penalty": 1.1, # Запрет на скомканное завершение ответа
"presence_penalty": 0.5, # Стимулирование полной генерации списков
"top_p": 0.9
} }
messages = [system_prompt] + db_history + [{"role": "user", "content": full_user_content}] # --- [SUB-SECTION 3.4: ROUTING & PAYLOAD BUILD] ---
if image_b64:
# Ветка Vision Model (Зрение/OCR)
user_msg_object["images"] = [image_b64]
messages = [
{
"role": "system",
"content": (
"Ты — строгий модуль OCR для документов. Твоя задача — дословно переписать весь печатный и рукописный текст с изображения.\n"
"ПРАВИЛА:\n"
"1. Переписывай рукописный текст СТРОГО буква в букву так, как он написан от руки. Не додумывай слова от себя!\n"
"2. Отдельно выдели блок с рукописными записями, подписями и датами.\n"
"3. Не добавляй лишних слов, которых нет в графической части."
)
},
user_msg_object
]
payload = { payload = {
"model": MODEL_NAME, "model": VISION_MODEL,
"messages": messages,
"stream": False,
"options": llm_options
}
else:
# Ветка Text & Tools Model
clean_db_history = []
for msg in db_history:
msg_copy = dict(msg)
msg_copy.pop("images", None)
clean_db_history.append(msg_copy)
system_prompt = {"role": "system", "content": system_prompt_content}
messages = [system_prompt] + clean_db_history + [user_msg_object]
payload = {
"model": TEXT_MODEL,
"messages": messages, "messages": messages,
"tools": TOOLS_SCHEMA, "tools": TOOLS_SCHEMA,
"stream": False, "stream": False,
"options": {"num_predict": 8192, "num_ctx": 8192, "temperature": 0.1} "options": llm_options
} }
# --- [SUB-SECTION 3.5: OLLAMA REQUEST & TOOL INJECTION] ---
try: try:
req = urllib.request.Request( req = urllib.request.Request(
OLLAMA_URL, OLLAMA_URL,
@@ -135,8 +258,41 @@ def process_chat_message(
res_data = json.loads(response.read().decode("utf-8")) res_data = json.loads(response.read().decode("utf-8"))
msg = res_data.get("message", {}) msg = res_data.get("message", {})
tool_calls = msg.get("tool_calls", []) tool_calls = msg.get("tool_calls", [])
raw_text_content = msg.get("content", "")
user_msg_lower = user_message.lower()
# ⚠️ AI-INVARIANT: TOOL INJECTOR (Инжектор вызовов)
# Если модель проигнорировала вызов функции или вывела его текстом
is_snapshot_req = any(w in user_msg_lower for w in ["снапшот", "срез", "среза", "лог"])
is_prompt_req = any(w in user_msg_lower for w in ["покажи системный промпт", "покажи промпт", "весь промпт"])
# Снапшоты запрашиваем из БД только при явных командах выгрузки/обновления
is_snapshot_fetch_req = any(w in user_msg_lower for w in ["покажи снапшоты", "список снапшотов", "выведи снапшоты", "срезы за", "логи за"])
is_refresh_req = any(w in user_msg_lower for w in ["запроси из базы", "обнови из базы", "повторно запроси", "свежие данные"])
if not tool_calls:
if is_prompt_req:
tool_calls = [{"function": {"name": "db_get_system_prompt", "arguments": {}}}]
logger.info("ИНЖЕКТОР: Активирован вызов db_get_system_prompt.")
elif (is_snapshot_fetch_req or is_refresh_req) and "задач" not in user_msg_lower:
target_date = parse_relative_date_ru(user_message)
tool_calls = [{"function": {"name": "db_get_snapshots", "arguments": {"date_str": target_date}}}]
logger.info(f"ИНЖЕКТОР: Активирован принудительный вызов db_get_snapshots за {target_date}.")
elif '{"name":' in raw_text_content or '<tool_call>' in raw_text_content:
try:
match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content)
if match:
fn_name = match.group(1)
fn_args = json.loads(match.group(2))
tool_calls = [{"function": {"name": fn_name, "arguments": fn_args}}]
logger.info(f"ИНЖЕКТОР: Извлечен сырой Tool Call из текста: {fn_name}")
except Exception as parse_err:
logger.warning(f"Ошибка парсинга сырого tool call: {parse_err}")
logger.info(f"Ответ от Ollama получен. Tool calls: {bool(tool_calls)}") logger.info(f"Ответ от Ollama получен. Tool calls: {bool(tool_calls)}")
# --- [SUB-SECTION 3.6: TOOL EXECUTION ROUTER] ---
if tool_calls: if tool_calls:
messages.append(msg) messages.append(msg)
@@ -184,12 +340,47 @@ def process_chat_message(
tool_result_content = json.dumps(db_get_reference(category=cat_arg), ensure_ascii=False) tool_result_content = json.dumps(db_get_reference(category=cat_arg), ensure_ascii=False)
elif fn_name == "db_preview_prompt_merge": elif fn_name == "db_preview_prompt_merge":
proposed_text = fn_args.get("proposed_prompt", "") # Обработка точечных правок системного промпта
proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or fn_args.get("section_3_4") or ""
if isinstance(fn_args, str):
proposed_text = fn_args
if proposed_text: if proposed_text:
if len(proposed_text) < 500:
current_prompt = db_get_active_system_prompt()
lines = current_prompt.splitlines()
new_lines = []
found_3_4 = False
clean_text = proposed_text.strip()
if clean_text.startswith("3.4."):
clean_text = clean_text[4:].strip()
for line in lines:
if line.strip().startswith("3.4."):
new_lines.append(f" 3.4. {clean_text}")
found_3_4 = True
else:
new_lines.append(line)
if not found_3_4:
final_lines = []
added = False
for l in new_lines:
final_lines.append(l)
if l.strip().startswith("3.3."):
final_lines.append(f" 3.4. {clean_text}")
added = True
if not added:
final_lines.append(f" 3.4. {clean_text}")
new_lines = final_lines
proposed_text = "\n".join(new_lines)
db_set_session_state(session_id, "PROMPT_PREVIEW", proposed_text) db_set_session_state(session_id, "PROMPT_PREVIEW", proposed_text)
preview_reply = f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n{proposed_text}\n\nДля применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)." preview_reply = f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n{proposed_text}\n\nДля применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
db_save_chat_message(session_id, "assistant", preview_reply) db_save_chat_message(session_id, "assistant", preview_reply)
return preview_reply, db_get_chat_history(session_id) return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id)
else: else:
tool_result_content = json.dumps({"status": "error", "message": "Текст превью пуст."}, ensure_ascii=False) tool_result_content = json.dumps({"status": "error", "message": "Текст превью пуст."}, ensure_ascii=False)
@@ -236,11 +427,12 @@ def process_chat_message(
"content": tool_result_content "content": tool_result_content
}) })
# Вторичный вызов Ollama для формирования текстового ответа пользователя с учетом результатов Tool
second_payload = { second_payload = {
"model": MODEL_NAME, "model": TEXT_MODEL,
"messages": messages, "messages": messages,
"stream": False, "stream": False,
"options": {"num_predict": 8192, "num_ctx": 8192, "temperature": 0.1} "options": llm_options
} }
sec_req = urllib.request.Request( sec_req = urllib.request.Request(
OLLAMA_URL, OLLAMA_URL,
@@ -249,11 +441,14 @@ def process_chat_message(
) )
with urllib.request.urlopen(sec_req) as sec_response: with urllib.request.urlopen(sec_req) as sec_response:
sec_res_data = json.loads(sec_response.read().decode("utf-8")) sec_res_data = json.loads(sec_response.read().decode("utf-8"))
final_content = clean_output(sec_res_data.get("message", {}).get("content", "").strip().replace("**", "")) raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "")
final_content = clean_raw_tool_tags(clean_output(raw_content))
db_save_chat_message(session_id, "assistant", final_content) db_save_chat_message(session_id, "assistant", final_content)
return final_content, db_get_chat_history(session_id) return final_content, db_get_chat_history(session_id)
content_str = clean_output(msg.get("content", "").strip().replace("**", "")) # Если вызовов функций не было
raw_str = msg.get("content", "").strip().replace("**", "")
content_str = clean_raw_tool_tags(clean_output(raw_str))
final_reply = content_str or "Запрос обработан." final_reply = content_str or "Запрос обработан."
db_save_chat_message(session_id, "assistant", final_reply) db_save_chat_message(session_id, "assistant", final_reply)
return final_reply, db_get_chat_history(session_id) return final_reply, db_get_chat_history(session_id)
+128 -45
View File
@@ -1,15 +1,52 @@
"""
===============================================================================
MODULE: llm/db_tools.py
PROJECT: SCUD Orion AI Context API
ROLE: Низкоуровневый модуль работы с СУБД SQLite. Реализует CRUD-операции
для задач, истории чатов, состояния превью промпта и запросов к
логам/снапшотам СКУД.
DB PATH: /home/puh/scud_orion_ai_v2/data/scud_orion_ai.db
CRITICAL INVARIANTS:
1. db_get_snapshots выполняет фильтрацию СТРОГО по log_date или snapshot_time,
чтобы исключить попадание логов за другие даты по служебномуcreated_at.
2. WAL-режим (PRAGMA journal_mode = WAL) обязателен для предотвращения
блокировок файла БД при параллельных запросах FastAPI/Uvicorn.
3. normalize_task_id гарантирует единый формат ID задач ('TASK-01', 'TASK-12').
===============================================================================
"""
import json import json
import sqlite3 import sqlite3
import logging import logging
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
# --- [SECTION 1: LOGGING & CONFIGURATION] ---
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("DB_TOOLS") logger = logging.getLogger("DB_TOOLS")
# ⚠️ AI-INVARIANT: Единый абсолютный путь к рабочей БД проекта
DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db" DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db"
def get_db_connection() -> sqlite3.Connection:
"""
⚠️ AI-INVARIANT: Фабрика подключений к SQLite.
Включает WAL-режим и timeout=30.0 для высокой отказоустойчивости при конкурентном доступе.
"""
conn = sqlite3.connect(DB_PATH, timeout=30.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL;")
conn.execute("PRAGMA synchronous = NORMAL;")
return conn
# --- [SECTION 2: TIME & DATE HELPERS] ---
def db_get_current_server_time() -> Dict[str, Any]: def db_get_current_server_time() -> Dict[str, Any]:
"""Возвращает текущую дату, точное время и день недели сервера."""
now = datetime.now() now = datetime.now()
days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
return { return {
@@ -19,14 +56,16 @@ def db_get_current_server_time() -> Dict[str, Any]:
"iso_date": now.strftime("%Y-%m-%d") "iso_date": now.strftime("%Y-%m-%d")
} }
def smart_parse_date(date_str: Optional[str], original_user_message: str = "") -> Optional[str]: def smart_parse_date(date_str: Optional[str], original_user_message: str = "") -> Optional[str]:
""" """Вспомогательный транзит даты без избыточной вторичной фильтрации."""
Дата уже точно подготовлена моделью на основе системного календаря.
Возвращаем date_str без повторной тяжелой фильтрации.
"""
return date_str return date_str
# --- [SECTION 3: CHAT HISTORY STORAGE] ---
def db_save_chat_message(session_id: str, role: str, content: str): def db_save_chat_message(session_id: str, role: str, content: str):
"""Сохранение отдельного сообщения (user / assistant / tool) в историю чата."""
if not content: if not content:
return return
conn = get_db_connection() conn = get_db_connection()
@@ -38,7 +77,9 @@ def db_save_chat_message(session_id: str, role: str, content: str):
conn.commit() conn.commit()
conn.close() conn.close()
def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]: def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]:
"""Получение последних N сообщений из истории диалога текущей сессии."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
@@ -50,7 +91,15 @@ def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]
conn.close() conn.close()
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
# --- [SECTION 4: SCUD LOGS & SNAPSHOTS ENGINE] ---
def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]: def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]:
"""
⚠️ AI-INVARIANT: Функция получения реестра снапшотов/срезов СКУД.
Фильтрация делается СТРОГО по log_date или snapshot_time. Оператор OR created_at LIKE
исключен, чтобы исключить подмешивание артефактных снапшотов за другие дни!
"""
date_str = smart_parse_date(date_str, original_user_message) date_str = smart_parse_date(date_str, original_user_message)
conn = get_db_connection() conn = get_db_connection()
@@ -63,18 +112,17 @@ def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[st
params = [] params = []
if date_str: if date_str:
# Приводим дату ДД.ММ.ГГГГ к ISO YYYY-MM-DD # Приведение даты ДД.ММ.ГГГГ к ISO YYYY-MM-DD
iso_date = date_str iso_date = date_str
if "." in date_str: if "." in date_str:
parts = date_str.split(".") parts = date_str.split(".")
if len(parts) == 3: if len(parts) == 3:
iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}" 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 ? "
query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? OR created_at LIKE ? " params.extend([date_str, iso_date, f"{iso_date}%"])
params.extend([date_str, iso_date, f"{iso_date}%", f"{iso_date}%"])
query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 20" query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
cursor.execute(query, params) cursor.execute(query, params)
rows = cursor.fetchall() rows = cursor.fetchall()
@@ -86,6 +134,7 @@ def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[st
"snapshots": snapshots "snapshots": snapshots
} }
# Сохраняем результат в состояние сессии для истории просмотра
db_set_session_state( db_set_session_state(
session_id=session_id, session_id=session_id,
state_type="SNAPSHOTS_VIEW", state_type="SNAPSHOTS_VIEW",
@@ -95,14 +144,34 @@ def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[st
conn.close() conn.close()
return result_data return result_data
def get_db_connection():
conn = sqlite3.connect(DB_PATH, timeout=30.0) def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
conn.row_factory = sqlite3.Row """Удаление конкретного снапшота по ID или всех снапшотов за день."""
conn.execute("PRAGMA journal_mode = WAL;") if not snapshot_id and not day_str:
conn.execute("PRAGMA synchronous = NORMAL;") return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
return conn
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}"}
# --- [SECTION 5: TASK TRACKER CRUD ENGINE] ---
def normalize_task_id(task_id_input: str) -> str: def normalize_task_id(task_id_input: str) -> str:
"""
⚠️ AI-INVARIANT: Приведение ID задачи к каноническому виду 'TASK-XX'.
Примеры: '17' -> 'TASK-17', 'task-5' -> 'TASK-05'.
"""
if not task_id_input: if not task_id_input:
return "" return ""
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "") clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
@@ -111,7 +180,9 @@ def normalize_task_id(task_id_input: str) -> str:
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}" return f"TASK-{clean_id}"
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]: def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
"""Получение всех задач, принадлежащих конкретному авторизованному пользователю."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
@@ -124,7 +195,9 @@ def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
conn.close() conn.close()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]: def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
"""Создание новой задачи в бэклоге пользователя."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
@@ -141,7 +214,9 @@ def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM",
conn.close() conn.close()
return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"} return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]: def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
"""Обновление статуса и/или срока задачи с проверкой прав пользователя."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
@@ -168,7 +243,9 @@ def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED",
conn.close() conn.close()
return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"} return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
"""Удаление задачи из бэклога."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
@@ -187,7 +264,11 @@ def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
conn.close() conn.close()
return {"status": "success", "message": f"Задача {formatted_id} удалена"} return {"status": "success", "message": f"Задача {formatted_id} удалена"}
# --- [SECTION 6: SYSTEM PROMPTS & KNOWLEDGE BASE] ---
def db_get_active_system_prompt() -> str: def db_get_active_system_prompt() -> str:
"""Извлечение текущего активного системного промпта из БД."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1") cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1")
@@ -195,7 +276,12 @@ def db_get_active_system_prompt() -> str:
conn.close() conn.close()
return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI."
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
"""
⚠️ AI-INVARIANT: Прямая запись нового активного системного промпта в SQLite.
Вызывается ТОЛЬКО после подтверждения превью через db_confirm_prompt_preview.
"""
try: try:
with get_db_connection() as conn: with get_db_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -223,7 +309,9 @@ def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
logger.error(f"Ошибка при сохранении промпта в БД: {e}") logger.error(f"Ошибка при сохранении промпта в БД: {e}")
return {"status": "error", "error": str(e)} return {"status": "error", "error": str(e)}
def db_get_rules() -> List[Dict[str, Any]]: def db_get_rules() -> List[Dict[str, Any]]:
"""Получение правил арбитража и базы знаний из ai_knowledge_base."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
@@ -231,7 +319,11 @@ def db_get_rules() -> List[Dict[str, Any]]:
conn.close() conn.close()
return [dict(r) for r in rows] return [dict(r) for r in rows]
# --- [SECTION 7: SESSION STATES & PREVIEW STORAGE] ---
def db_set_session_state(session_id: str, state_type: str, data: str): def db_set_session_state(session_id: str, state_type: str, data: str):
"""Сохранение временного состояния сессии (например, PROMPT_PREVIEW)."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
@@ -245,7 +337,9 @@ def db_set_session_state(session_id: str, state_type: str, data: str):
conn.commit() conn.commit()
conn.close() conn.close()
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
"""Получение активного сессионного состояния по session_id."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,)) cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,))
@@ -253,15 +347,30 @@ def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
conn.close() conn.close()
return dict(row) if row else None return dict(row) if row else None
def db_clear_session_state(session_id: str): def db_clear_session_state(session_id: str):
"""Сброс и очистка сессионного состояния (при отмене или подтверждении)."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
conn.commit() conn.commit()
conn.close() conn.close()
def db_get_session_states() -> Dict[str, Any]:
"""Список всех активных предпросмотров и сессий."""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
rows = cursor.fetchall()
conn.close()
return {"status": "success", "active_sessions": [dict(r) for r in rows]}
# --- [SECTION 8: SYSTEM STATS & REFERENCE] ---
def db_get_stats() -> Dict[str, Any]: def db_get_stats() -> Dict[str, Any]:
"""Возвращает общую статистику по количеству записей во всех таблицах БД.""" """Возвращает общую статистику по количеству записей во всех таблицах СУБД."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'] tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks']
@@ -275,8 +384,9 @@ def db_get_stats() -> Dict[str, Any]:
conn.close() conn.close()
return {"status": "success", "tables_stats": stats} return {"status": "success", "tables_stats": stats}
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
"""Возвращает историю аномалий СКУД с опциональной фильтрацией по дате.""" """История аномалий СКУД ⟷ 1С с опциональной фильтрацией по дате."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
@@ -301,36 +411,9 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s
"anomalies": 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]: def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
"""Возвращает системные справочники и примеры команд для оператора.""" """Получение системных справочников и примеров команд для оператора."""
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
+50 -23
View File
@@ -1,37 +1,53 @@
import io import base64
import os import os
import subprocess import subprocess
import logging import logging
import pandas as pd import pandas as pd
from PIL import Image
logger = logging.getLogger("FILE_PARSER") logger = logging.getLogger("FILE_PARSER")
def extract_text_from_file(file_bytes: bytes, filename: str) -> str: def extract_text_from_file(file_bytes: bytes, filename: str) -> dict:
"""Извлекает текст из изображений (OCR), PDF, таблиц и текстовых файлов."""
ext = os.path.splitext(filename)[1].lower() ext = os.path.splitext(filename)[1].lower()
# Создаем временный файл во избежание проблем с памятью
temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}" temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}"
with open(temp_filepath, "wb") as f: with open(temp_filepath, "wb") as f:
f.write(file_bytes) f.write(file_bytes)
try: try:
# 1. ИЗОБРАЖЕНИЯ (OCR через системный /usr/bin/tesseract) # 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM
if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']: if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']:
cmd = ['tesseract', temp_filepath, 'stdout', '-l', 'rus+eng'] b64_str = base64.b64encode(file_bytes).decode('utf-8')
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) return {
text = res.stdout.strip() "text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]",
return text if text else "[OCR: На изображении не удалось распознать текст]" "image_b64": b64_str
}
# 2. PDF ДОКУМЕНТЫ (через системный /usr/bin/pdftotext из poppler-utils) # 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM)
elif ext == '.pdf': elif ext == '.pdf':
cmd = ['pdftotext', temp_filepath, '-'] cmd = ['pdftotext', temp_filepath, '-']
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
text = res.stdout.strip() pdf_text = res.stdout.strip()
return text if text else "[PDF: Текстовый слой не найден. Возможно, скан без OCR]"
# 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (XLSX, CSV через Pandas) img_prefix = f"/tmp/pdf_preview_{os.getpid()}"
subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True)
page_png = f"{img_prefix}-1.png"
b64_str = None
if os.path.exists(page_png):
with open(page_png, "rb") as pf:
b64_str = base64.b64encode(pf.read()).decode('utf-8')
os.remove(page_png)
context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]"
if pdf_text:
context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}"
return {
"text": context_text,
"image_b64": b64_str
}
# 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv)
elif ext in ['.xlsx', '.xls', '.csv']: elif ext in ['.xlsx', '.xls', '.csv']:
if ext == '.csv': if ext == '.csv':
df = pd.read_csv(temp_filepath) df = pd.read_csv(temp_filepath)
@@ -39,23 +55,34 @@ def extract_text_from_file(file_bytes: bytes, filename: str) -> str:
df = pd.read_excel(temp_filepath) df = pd.read_excel(temp_filepath)
total_rows = len(df) total_rows = len(df)
df_preview = df.head(100) # Показываем первые 100 строк df_preview = df.head(100)
table_str = df_preview.to_string(index=False) table_str = df_preview.to_string(index=False)
note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else "" note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else ""
return f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}" return {
"text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}",
"image_b64": None
}
# 4. ТЕКСТОВЫЕ ФАЙЛЫ (TXT, LOG, JSON) # 4. ТЕКСТОВЫЕ ФАЙЛЫ
elif ext in ['.txt', '.log', '.json', '.xml', '.md']: elif ext in ['.txt', '.log', '.json', '.xml', '.md']:
with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf: with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf:
return tf.read().strip() return {
"text": tf.read().strip(),
"image_b64": None
}
else: else:
return f"[ОШИБКА: Формат {ext} не поддерживается для анализа]" return {
"text": f"[ОШИБКА: Формат {ext} не поддерживается]",
"image_b64": None
}
except Exception as e: except Exception as e:
logger.error(f"Ошибка при анализе файла {filename}: {e}") logger.error(f"Ошибка при анализе файла {filename}: {e}")
return f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]" return {
"text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]",
"image_b64": None
}
finally: finally:
if os.path.exists(temp_filepath): if os.path.exists(temp_filepath):
os.remove(temp_filepath) os.remove(temp_filepath)
+24 -24
View File
@@ -3,7 +3,7 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_tasks", "name": "db_get_tasks",
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ПРОЕКТА. Вызывай ТОЛЬКО когда пользователь просит показать задачи, бэклог или список дел.", "description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -27,7 +27,7 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_system_prompt", "name": "db_get_system_prompt",
"description": "ПОЛУЧИТЬ ТЕКУЩИЙ СИСТЕМНЫЙ ПРОМПТ ИИ (system_prompts). Вызывай когда пользователь просит показать системный промпт, инструкции ассистента или промпт из базы.", "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ. Ты ОБЯЗАН СРАЗУ вызывать эту функцию при любых запросах 'покажи системный промпт', 'покажи промпт', 'текущие инструкции'. Запрещено выводить промпт из памяти без вызова этой функции!",
"parameters": {"type": "object", "properties": {}} "parameters": {"type": "object", "properties": {}}
} }
}, },
@@ -65,18 +65,32 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_get_snapshots", "name": "db_get_snapshots",
"description": "ПОЛУЧИТЬ СНИМКИ/СНАПШОТЫ СКУД (из таблицы scud_logs). Вызывай, когда пользователь просит показать снапшоты, срезы или логи за дату/день недели.", "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"date_str": { "date_str": {
"type": "string", "type": "string",
"description": "Точная дата в формате ДД.ММ.ГГГГ (например, '05.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]." "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
} }
} }
} }
} }
}, },
{
"type": "function",
"function": {
"name": "db_delete_snapshots",
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
"parameters": {
"type": "object",
"properties": {
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
}
}
}
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -136,20 +150,6 @@ TOOLS_SCHEMA = [
"parameters": {"type": "object", "properties": {}} "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", "type": "function",
"function": { "function": {
@@ -170,7 +170,7 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_add_system_prompt", "name": "db_add_system_prompt",
"description": "ВНУТРЕННИЙ СИСТЕМНЫЙ ИНСТРУМЕНТ. ЗАПРЕЩЕНО вызывать напрямую при запросах пользователя на изменение промпта! Для ЛЮБЫХ изменений системного промпта ты ОБЯЗАН сначала вызвать db_preview_prompt_merge.", "description": "Прямое сохранение системного промпта в БД без предварительного просмотра.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -185,16 +185,16 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_preview_prompt_merge", "name": "db_preview_prompt_merge",
"description": "ОБЯЗАТЕЛЬНЫЙ ИНСТРУМЕНТ для ЛЮБЫХ изменений системного промпта (добавление пунктов, удаление, форматирование, отступы). Вызывай его ВСЕГДА, когда пользователь просит изменить промпт. В параметре proposed_prompt передавай ИТОГОВЫЙ полный текст со всеми разделами целиком.", "description": "Создать предварительное изменённое превью системного промпта перед сохранением.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"proposed_prompt": { "prompt_text": {
"type": "string", "type": "string",
"description": "Полный текст системного промпта, содержащий все разделы от 1 до 3 с учетом внесенных изменений." "description": "Новый полный или частично измененный текст системного промпта."
} }
}, },
"required": ["proposed_prompt"] "required": ["prompt_text"]
} }
} }
}, },
+8 -31
View File
@@ -104,28 +104,6 @@ async def favicon():
return FileResponse(file_path) return FileResponse(file_path)
raise HTTPException(status_code=404) 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") @app.post("/api/v1/auth/login")
def login(req: AuthRequest): def login(req: AuthRequest):
@@ -245,37 +223,36 @@ async def chat_endpoint(
file: Optional[UploadFile] = File(default=None), file: Optional[UploadFile] = File(default=None),
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user)
): ):
logging.info(f"=== [CHAT API] Входящий запрос от user_id={current_user['id']}, file={file.filename if file else 'None'} ===") parsed_file = {"text": "", "image_b64": None}
file_content_text = ""
if file and file.filename: if file and file.filename:
file_bytes = await file.read() file_bytes = await file.read()
file_content_text = extract_text_from_file(file_bytes, file.filename) parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history = process_chat_message( reply, history = process_chat_message(
user_id=current_user["id"], user_id=current_user["id"],
user_message=message, user_message=message,
file_context=file_content_text, file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id session_id=session_id
) )
return {"reply": reply, "history": history} return {"reply": reply, "history": history}
# ЕДИНЫЙ ГОСТЕВОЙ ЧАТ (FormData + Файлы)
@app.post("/api/v1/chat/guest") @app.post("/api/v1/chat/guest")
async def guest_chat_endpoint( async def guest_chat_endpoint(
session_id: str = Form("web_session_main"), session_id: str = Form("web_session_main"),
message: str = Form(""), message: str = Form(""),
file: Optional[UploadFile] = File(default=None) file: Optional[UploadFile] = File(default=None)
): ):
logging.info(f"=== [GUEST CHAT API] Входящий запрос, file={file.filename if file else 'None'} ===") parsed_file = {"text": "", "image_b64": None}
file_content_text = ""
if file and file.filename: if file and file.filename:
file_bytes = await file.read() file_bytes = await file.read()
file_content_text = extract_text_from_file(file_bytes, file.filename) parsed_file = extract_text_from_file(file_bytes, file.filename)
reply, history = process_chat_message( reply, history = process_chat_message(
user_id=0, user_id=0,
user_message=message, user_message=message,
file_context=file_content_text, file_context=parsed_file["text"],
image_b64=parsed_file["image_b64"],
session_id=session_id session_id=session_id
) )
return {"reply": reply, "history": history} return {"reply": reply, "history": history}
+47
View File
@@ -0,0 +1,47 @@
import os
import sqlite3
# Автопоиск файла базы данных в проекте
db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db'
print("=" * 80)
print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}")
print("=" * 80)
if not os.path.exists(db_path):
print(f"❌ Файл базы данных {db_path} не найден!")
exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 1. Список всех таблиц и колонок
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
tables = [t[0] for t in cursor.fetchall()]
print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:")
print("-" * 80)
for t_name in tables:
cursor.execute(f"PRAGMA table_info({t_name})")
cols = [c[1] for c in cursor.fetchall()]
cursor.execute(f"SELECT COUNT(*) FROM {t_name}")
count = cursor.fetchone()[0]
print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}")
# 2. Просмотр правил Базы Знаний
if 'ai_knowledge_base' in tables:
print("\n" + "=" * 80)
print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):")
print("=" * 80)
cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC")
rules = cursor.fetchall()
if not rules:
print("Таблица ai_knowledge_base пуста.")
else:
for r_id, r_text, r_author in rules:
print(f" {r_id}. [{r_author}] {r_text}\n")
conn.close()
print("=" * 80)
+23
View File
@@ -0,0 +1,23 @@
import os
print("=" * 80)
print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)")
print("=" * 80)
total_files = 0
total_size = 0
for root, dirs, files in os.walk('.'):
# Исключаем служебные каталоги
dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']]
for f in files:
p = os.path.join(root, f)
size = os.path.getsize(p)
total_files += 1
total_size += size
print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' '))
print("-" * 80)
print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB")
print("=" * 80)
+31
View File
@@ -0,0 +1,31 @@
import os
OUTPUT_SNAPSHOT = "api_code_snapshot.md"
# Расширения файлов для включения в снимок
ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'}
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'}
print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...")
with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out:
out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n")
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for file in sorted(files):
ext = os.path.splitext(file)[1].lower()
if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES:
filepath = os.path.join(root, file)
out.write(f"## File: `{filepath}`\n")
out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n")
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
out.write(f.read())
except Exception as e:
out.write(f"// Ошибка чтения файла: {e}\n")
out.write("\n```\n\n")
print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)")
Binary file not shown.
+9 -2
View File
@@ -173,6 +173,13 @@
<!-- Главный контейнер --> <!-- Главный контейнер -->
<div class="flex-1 flex flex-col min-h-0 w-full max-w-4xl mx-auto bg-white relative overflow-hidden"> <div class="flex-1 flex flex-col min-h-0 w-full max-w-4xl mx-auto bg-white relative overflow-hidden">
<div id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50"> <div id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50">
<div id="drop-overlay" class="absolute inset-0 bg-indigo-600/10 backdrop-blur-sm border-2 border-dashed border-indigo-600 rounded-2xl hidden flex-col items-center justify-center z-30 transition-all pointer-events-none">
<div class="bg-white p-4 rounded-2xl shadow-xl flex flex-col items-center gap-2">
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
<p class="text-sm font-bold text-slate-800">Перетащите файл сюда</p>
<p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
</div>
</div>
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm"> <div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1"> <p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент <i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
@@ -205,8 +212,8 @@
<div class="flex-1 bg-slate-100 border border-slate-300 rounded-2xl px-3 py-1.5 focus-within:border-indigo-600 focus-within:bg-white transition"> <div class="flex-1 bg-slate-100 border border-slate-300 rounded-2xl px-3 py-1.5 focus-within:border-indigo-600 focus-within:bg-white transition">
<textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false" <textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
placeholder="Команда, вопрос или описание файла..." placeholder="Команда, вопрос или перетащите файл сюда..."
class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto max-h-[80px] leading-normal no-scrollbar"></textarea> class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto h-[24px] max-h-[120px] leading-[24px] fade-scroll-top no-scrollbar"></textarea>
</div> </div>
<button type="button" id="send-btn" onclick="sendMessage()" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm"> <button type="button" id="send-btn" onclick="sendMessage()" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm">
<span>Отправить</span> <span>Отправить</span>
+3 -52
View File
@@ -15,58 +15,9 @@ document.addEventListener("DOMContentLoaded", () => {
if (userInputEl) { if (userInputEl) {
userInputEl.addEventListener("input", function() { userInputEl.addEventListener("input", function() {
this.style.height = "auto"; this.style.height = "24px";
this.style.height = Math.min(this.scrollHeight, 80) + "px"; const newHeight = Math.min(this.scrollHeight, 120);
}); this.style.height = newHeight + "px";
userInputEl.addEventListener("keydown", function(e) {
// Отправка по Enter
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const val = this.value.trim();
if (val) {
// Сохраняем команду в историю
if (inputHistory.length === 0 || inputHistory[inputHistory.length - 1] !== val) {
inputHistory.push(val);
if (inputHistory.length > 50) inputHistory.shift();
localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
}
historyIndex = -1;
}
if (typeof sendMessage === "function") {
sendMessage(e);
}
}
// История: стрелка ВВЕРХ
else if (e.key === "ArrowUp") {
if (inputHistory.length > 0 && historyIndex < inputHistory.length - 1) {
e.preventDefault();
if (historyIndex === -1) {
this.dataset.draft = this.value;
}
historyIndex++;
this.value = inputHistory[inputHistory.length - 1 - historyIndex];
this.dispatchEvent(new Event("input"));
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
}
}
// История: стрелка ВНИЗ
else if (e.key === "ArrowDown") {
if (historyIndex !== -1) {
e.preventDefault();
if (historyIndex > 0) {
historyIndex--;
this.value = inputHistory[inputHistory.length - 1 - historyIndex];
} else {
historyIndex = -1;
this.value = this.dataset.draft || "";
}
this.dispatchEvent(new Event("input"));
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
}
}
}); });
} }
+123 -2
View File
@@ -1,3 +1,11 @@
// Вспомогательная функция для автоматического изменения высоты текстового поля (1-3 строки)
function updateInputHeight(el) {
if (!el) return;
el.style.height = "24px";
const newHeight = Math.min(el.scrollHeight, 120);
el.style.height = newHeight + "px";
}
let selectedFile = null; let selectedFile = null;
function handleFileSelect(e) { function handleFileSelect(e) {
@@ -57,7 +65,7 @@ async function sendMessage(e) {
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
input.value = ""; input.value = "";
input.style.height = "auto"; updateInputHeight(input);
chatWindow.scrollTop = chatWindow.scrollHeight; chatWindow.scrollTop = chatWindow.scrollHeight;
if (sendBtn) { if (sendBtn) {
@@ -70,7 +78,6 @@ async function sendMessage(e) {
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat"; const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
// Формируем единый FormData без дублирования
const formData = new FormData(); const formData = new FormData();
formData.append("session_id", "web_session_main"); formData.append("session_id", "web_session_main");
formData.append("message", text || "Проанализируй прикрепленный файл"); formData.append("message", text || "Проанализируй прикрепленный файл");
@@ -143,3 +150,117 @@ function escapeHtml(text) {
.replace(/"/g, "&quot;") .replace(/"/g, "&quot;")
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("user-input");
const dropZone = document.getElementById("chat-window")?.parentElement;
const dropOverlay = document.getElementById("drop-overlay");
// --- 1. УМНАЯ НАВИГАЦИЯ СТРЕЛКАМИ В МНОГОСТРОЧНОМ ТЕКСТЕ ---
if (input) {
let historyIndex = -1;
let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]");
input.addEventListener("keydown", (e) => {
// Отправка по Enter без Shift
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const text = input.value.trim();
if (text) {
if (localHistory.length === 0 || localHistory[0] !== text) {
localHistory.unshift(text);
if (localHistory.length > 50) localHistory.pop();
localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory));
}
historyIndex = -1;
}
sendMessage(e);
updateInputHeight(input);
return;
}
// Стрелка ВВЕРХ
if (e.key === "ArrowUp") {
const textBeforeCursor = input.value.substring(0, input.selectionStart);
const isFirstLine = !textBeforeCursor.includes("\n");
// Переключаем историю ТОЛЬКО когда курсор на 1-й строке И уперся в самое начало (позиция 0)
if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) {
if (historyIndex < localHistory.length - 1) {
e.preventDefault();
if (historyIndex === -1) {
input.dataset.draft = input.value;
}
historyIndex++;
input.value = localHistory[historyIndex];
updateInputHeight(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
// Стрелка ВНИЗ
if (e.key === "ArrowDown") {
const textAfterCursor = input.value.substring(input.selectionEnd);
const isLastLine = !textAfterCursor.includes("\n");
// Переключаем историю ТОЛЬКО когда курсор на последней строке И уперся в самый конец
if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) {
e.preventDefault();
if (historyIndex > 0) {
historyIndex--;
input.value = localHistory[historyIndex];
} else {
historyIndex = -1;
input.value = input.dataset.draft || "";
}
updateInputHeight(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
});
}
// --- 2. ОБРАБОТКА DRAG-AND-DROP ФАЙЛОВ ---
if (dropZone && dropOverlay) {
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
["dragenter", "dragover"].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropOverlay.classList.remove("hidden");
dropOverlay.classList.add("flex");
}, false);
});
["dragleave", "drop"].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) {
dropOverlay.classList.add("hidden");
dropOverlay.classList.remove("flex");
}
}, false);
});
dropZone.addEventListener("drop", (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files && files.length > 0) {
const file = files[0];
handleFileSelect({ target: { files: [file] } });
const fileInput = document.getElementById("file-input");
if (fileInput) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
}
}
}, false);
}
});