13.08.2026 налажена работа с ИИ. Подготовка к разбивке файлов agent и db_tools
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+214
-19
@@ -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 urllib.request
|
||||
import urllib.error
|
||||
import logging
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
|
||||
# Импорт внутренних утилит работы с БД
|
||||
from .db_tools import (
|
||||
db_get_active_system_prompt,
|
||||
db_add_system_prompt,
|
||||
@@ -30,18 +53,40 @@ from .db_tools import (
|
||||
|
||||
from .schemas import TOOLS_SCHEMA
|
||||
|
||||
# --- [SECTION 1: LOGGING & CONSTANTS] ---
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("SCUD_AGENT")
|
||||
|
||||
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 = [
|
||||
"понедельник", "вторник", "среда", "четверг",
|
||||
"пятница", "суббота", "воскресенье"
|
||||
]
|
||||
|
||||
|
||||
# --- [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:
|
||||
"""Удаление слов-паразитов и склеек в начале ответа."""
|
||||
if not text:
|
||||
return text
|
||||
artifacts = ["почемучто", "почто", "почему что"]
|
||||
@@ -51,7 +96,33 @@ def clean_output(text: str) -> str:
|
||||
text = text[len(art):].lstrip(",.!?:; -")
|
||||
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:
|
||||
"""Генерация справочника прошедших дат для системного промпта."""
|
||||
now = datetime.now()
|
||||
current_wd = now.weekday()
|
||||
|
||||
@@ -77,16 +148,21 @@ def get_dynamic_calendar_context() -> str:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] ---
|
||||
|
||||
def process_chat_message(
|
||||
user_id: int,
|
||||
user_message: str,
|
||||
file_context: str = "",
|
||||
image_b64: Optional[str] = None,
|
||||
chat_history: List[Dict[str, Any]] = None,
|
||||
session_id: str = "web_session_main"
|
||||
) -> Tuple[str, List[Dict[str, Any]]]:
|
||||
"""Главный входной метод обработки пользовательского сообщения."""
|
||||
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
|
||||
|
||||
# Формируем итоговое содержимое запроса
|
||||
# 3.1. Формирование контекста сообщения пользователя
|
||||
full_user_content = user_message
|
||||
if 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()
|
||||
calendar_context = get_dynamic_calendar_context()
|
||||
|
||||
# Check состояния превью системного промпта
|
||||
session_state = db_get_session_state(session_id)
|
||||
preview_status_note = ""
|
||||
if session_state and session_state.get("state_type") == "PROMPT_PREVIEW":
|
||||
preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]."
|
||||
|
||||
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
|
||||
|
||||
# 3.2. Сборка системного контекста
|
||||
system_prompt_content = (
|
||||
f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n"
|
||||
f"Вы общаетесь с пользователем: {user_info}.\n"
|
||||
f"Все запрашиваемые задачи через инструмент db_get_tasks автоматически принадлежат ИМЕННО этому пользователю. "
|
||||
f"Тебе НЕ НУЖНО уточнять, чьи это задачи или просить дополнительные идентификаторы. При запросах 'покажи мои задачи', 'список задач', 'мои дела' — СРАЗУ вызывай db_get_tasks.\n\n"
|
||||
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
|
||||
f"{calendar_context}\n\n"
|
||||
f"ПРАВИЛО РАБОТЫ С ДАТАМИ:\n"
|
||||
@@ -110,21 +194,60 @@ def process_chat_message(
|
||||
f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}"
|
||||
)
|
||||
|
||||
system_prompt = {
|
||||
"role": "system",
|
||||
"content": system_prompt_content
|
||||
user_msg_object = {"role": "user", "content": full_user_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 = {
|
||||
"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)
|
||||
|
||||
payload = {
|
||||
"model": MODEL_NAME,
|
||||
"messages": messages,
|
||||
"tools": TOOLS_SCHEMA,
|
||||
"stream": False,
|
||||
"options": {"num_predict": 8192, "num_ctx": 8192, "temperature": 0.1}
|
||||
}
|
||||
system_prompt = {"role": "system", "content": system_prompt_content}
|
||||
messages = [system_prompt] + clean_db_history + [user_msg_object]
|
||||
payload = {
|
||||
"model": TEXT_MODEL,
|
||||
"messages": messages,
|
||||
"tools": TOOLS_SCHEMA,
|
||||
"stream": False,
|
||||
"options": llm_options
|
||||
}
|
||||
|
||||
# --- [SUB-SECTION 3.5: OLLAMA REQUEST & TOOL INJECTION] ---
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
OLLAMA_URL,
|
||||
@@ -135,8 +258,41 @@ def process_chat_message(
|
||||
res_data = json.loads(response.read().decode("utf-8"))
|
||||
msg = res_data.get("message", {})
|
||||
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)}")
|
||||
|
||||
# --- [SUB-SECTION 3.6: TOOL EXECUTION ROUTER] ---
|
||||
if tool_calls:
|
||||
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)
|
||||
|
||||
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 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)
|
||||
preview_reply = f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n{proposed_text}\n\nДля применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)."
|
||||
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:
|
||||
tool_result_content = json.dumps({"status": "error", "message": "Текст превью пуст."}, ensure_ascii=False)
|
||||
|
||||
@@ -236,11 +427,12 @@ def process_chat_message(
|
||||
"content": tool_result_content
|
||||
})
|
||||
|
||||
# Вторичный вызов Ollama для формирования текстового ответа пользователя с учетом результатов Tool
|
||||
second_payload = {
|
||||
"model": MODEL_NAME,
|
||||
"model": TEXT_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"num_predict": 8192, "num_ctx": 8192, "temperature": 0.1}
|
||||
"options": llm_options
|
||||
}
|
||||
sec_req = urllib.request.Request(
|
||||
OLLAMA_URL,
|
||||
@@ -249,11 +441,14 @@ def process_chat_message(
|
||||
)
|
||||
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("**", ""))
|
||||
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)
|
||||
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 "Запрос обработан."
|
||||
db_save_chat_message(session_id, "assistant", final_reply)
|
||||
return final_reply, db_get_chat_history(session_id)
|
||||
|
||||
+128
-45
@@ -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 sqlite3
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# --- [SECTION 1: LOGGING & CONFIGURATION] ---
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("DB_TOOLS")
|
||||
|
||||
# ⚠️ AI-INVARIANT: Единый абсолютный путь к рабочей БД проекта
|
||||
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]:
|
||||
"""Возвращает текущую дату, точное время и день недели сервера."""
|
||||
now = datetime.now()
|
||||
days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
|
||||
return {
|
||||
@@ -19,14 +56,16 @@ def db_get_current_server_time() -> Dict[str, Any]:
|
||||
"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
|
||||
|
||||
|
||||
# --- [SECTION 3: CHAT HISTORY STORAGE] ---
|
||||
|
||||
def db_save_chat_message(session_id: str, role: str, content: str):
|
||||
"""Сохранение отдельного сообщения (user / assistant / tool) в историю чата."""
|
||||
if not content:
|
||||
return
|
||||
conn = get_db_connection()
|
||||
@@ -38,7 +77,9 @@ def db_save_chat_message(session_id: str, role: str, content: str):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Получение последних N сообщений из истории диалога текущей сессии."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
@@ -50,7 +91,15 @@ def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]
|
||||
conn.close()
|
||||
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]:
|
||||
"""
|
||||
⚠️ AI-INVARIANT: Функция получения реестра снапшотов/срезов СКУД.
|
||||
Фильтрация делается СТРОГО по log_date или snapshot_time. Оператор OR created_at LIKE
|
||||
исключен, чтобы исключить подмешивание артефактных снапшотов за другие дни!
|
||||
"""
|
||||
date_str = smart_parse_date(date_str, original_user_message)
|
||||
|
||||
conn = get_db_connection()
|
||||
@@ -63,18 +112,17 @@ def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[st
|
||||
params = []
|
||||
|
||||
if date_str:
|
||||
# Приводим дату ДД.ММ.ГГГГ к ISO YYYY-MM-DD
|
||||
# Приведение даты ДД.ММ.ГГГГ к 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 += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? "
|
||||
params.extend([date_str, iso_date, f"{iso_date}%"])
|
||||
|
||||
query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 20"
|
||||
query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
@@ -86,6 +134,7 @@ def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[st
|
||||
"snapshots": snapshots
|
||||
}
|
||||
|
||||
# Сохраняем результат в состояние сессии для истории просмотра
|
||||
db_set_session_state(
|
||||
session_id=session_id,
|
||||
state_type="SNAPSHOTS_VIEW",
|
||||
@@ -95,14 +144,34 @@ def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[st
|
||||
conn.close()
|
||||
return result_data
|
||||
|
||||
def get_db_connection():
|
||||
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
|
||||
|
||||
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}"}
|
||||
|
||||
|
||||
# --- [SECTION 5: TASK TRACKER CRUD ENGINE] ---
|
||||
|
||||
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:
|
||||
return ""
|
||||
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-{clean_id}"
|
||||
|
||||
|
||||
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
|
||||
"""Получение всех задач, принадлежащих конкретному авторизованному пользователю."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
@@ -124,7 +195,9 @@ def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Создание новой задачи в бэклоге пользователя."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -141,7 +214,9 @@ def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM",
|
||||
conn.close()
|
||||
return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
|
||||
|
||||
|
||||
def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Обновление статуса и/или срока задачи с проверкой прав пользователя."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -168,7 +243,9 @@ def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED",
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
|
||||
|
||||
|
||||
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
|
||||
"""Удаление задачи из бэклога."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -187,7 +264,11 @@ def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
|
||||
conn.close()
|
||||
return {"status": "success", "message": f"Задача {formatted_id} удалена"}
|
||||
|
||||
|
||||
# --- [SECTION 6: SYSTEM PROMPTS & KNOWLEDGE BASE] ---
|
||||
|
||||
def db_get_active_system_prompt() -> str:
|
||||
"""Извлечение текущего активного системного промпта из БД."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1")
|
||||
@@ -195,7 +276,12 @@ def db_get_active_system_prompt() -> str:
|
||||
conn.close()
|
||||
return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI."
|
||||
|
||||
|
||||
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
⚠️ AI-INVARIANT: Прямая запись нового активного системного промпта в SQLite.
|
||||
Вызывается ТОЛЬКО после подтверждения превью через db_confirm_prompt_preview.
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
@@ -223,7 +309,9 @@ 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]]:
|
||||
"""Получение правил арбитража и базы знаний из ai_knowledge_base."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
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()
|
||||
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):
|
||||
"""Сохранение временного состояния сессии (например, PROMPT_PREVIEW)."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
@@ -245,7 +337,9 @@ def db_set_session_state(session_id: str, state_type: str, data: str):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Получение активного сессионного состояния по session_id."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
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()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def db_clear_session_state(session_id: str):
|
||||
"""Сброс и очистка сессионного состояния (при отмене или подтверждении)."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_get_session_states() -> Dict[str, Any]:
|
||||
"""Список всех активных предпросмотров и сессий."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT session_id, state_type, updated_at FROM session_states")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return {"status": "success", "active_sessions": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# --- [SECTION 8: SYSTEM STATS & REFERENCE] ---
|
||||
|
||||
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']
|
||||
@@ -275,8 +384,9 @@ def db_get_stats() -> Dict[str, Any]:
|
||||
conn.close()
|
||||
return {"status": "success", "tables_stats": stats}
|
||||
|
||||
|
||||
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Возвращает историю аномалий СКУД с опциональной фильтрацией по дате."""
|
||||
"""История аномалий СКУД ⟷ 1С с опциональной фильтрацией по дате."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -301,36 +411,9 @@ def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[s
|
||||
"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()
|
||||
|
||||
|
||||
+50
-23
@@ -1,37 +1,53 @@
|
||||
import io
|
||||
import base64
|
||||
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, таблиц и текстовых файлов."""
|
||||
def extract_text_from_file(file_bytes: bytes, filename: str) -> dict:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
|
||||
# Создаем временный файл во избежание проблем с памятью
|
||||
temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}"
|
||||
|
||||
with open(temp_filepath, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
try:
|
||||
# 1. ИЗОБРАЖЕНИЯ (OCR через системный /usr/bin/tesseract)
|
||||
# 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM
|
||||
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: На изображении не удалось распознать текст]"
|
||||
b64_str = base64.b64encode(file_bytes).decode('utf-8')
|
||||
return {
|
||||
"text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]",
|
||||
"image_b64": b64_str
|
||||
}
|
||||
|
||||
# 2. PDF ДОКУМЕНТЫ (через системный /usr/bin/pdftotext из poppler-utils)
|
||||
# 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM)
|
||||
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]"
|
||||
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
pdf_text = res.stdout.strip()
|
||||
|
||||
# 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']:
|
||||
if ext == '.csv':
|
||||
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)
|
||||
|
||||
total_rows = len(df)
|
||||
df_preview = df.head(100) # Показываем первые 100 строк
|
||||
|
||||
df_preview = df.head(100)
|
||||
table_str = df_preview.to_string(index=False)
|
||||
note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else ""
|
||||
return 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']:
|
||||
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:
|
||||
return f"[ОШИБКА: Формат {ext} не поддерживается для анализа]"
|
||||
return {
|
||||
"text": f"[ОШИБКА: Формат {ext} не поддерживается]",
|
||||
"image_b64": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при анализе файла {filename}: {e}")
|
||||
return f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]"
|
||||
return {
|
||||
"text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]",
|
||||
"image_b64": None
|
||||
}
|
||||
finally:
|
||||
if os.path.exists(temp_filepath):
|
||||
os.remove(temp_filepath)
|
||||
+34
-34
@@ -3,7 +3,7 @@ TOOLS_SCHEMA = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_tasks",
|
||||
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ПРОЕКТА. Вызывай ТОЛЬКО когда пользователь просит показать задачи, бэклог или список дел.",
|
||||
"description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -27,7 +27,7 @@ TOOLS_SCHEMA = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_get_system_prompt",
|
||||
"description": "ПОЛУЧИТЬ ТЕКУЩИЙ СИСТЕМНЫЙ ПРОМПТ ИИ (system_prompts). Вызывай когда пользователь просит показать системный промпт, инструкции ассистента или промпт из базы.",
|
||||
"description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ. Ты ОБЯЗАН СРАЗУ вызывать эту функцию при любых запросах 'покажи системный промпт', 'покажи промпт', 'текущие инструкции'. Запрещено выводить промпт из памяти без вызова этой функции!",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
@@ -62,21 +62,35 @@ 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_snapshots",
|
||||
"description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date_str": {
|
||||
"type": "string",
|
||||
"description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_delete_snapshots",
|
||||
"description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
|
||||
"day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -136,20 +150,6 @@ TOOLS_SCHEMA = [
|
||||
"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": {
|
||||
@@ -170,7 +170,7 @@ TOOLS_SCHEMA = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_add_system_prompt",
|
||||
"description": "ВНУТРЕННИЙ СИСТЕМНЫЙ ИНСТРУМЕНТ. ЗАПРЕЩЕНО вызывать напрямую при запросах пользователя на изменение промпта! Для ЛЮБЫХ изменений системного промпта ты ОБЯЗАН сначала вызвать db_preview_prompt_merge.",
|
||||
"description": "Прямое сохранение системного промпта в БД без предварительного просмотра.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -185,16 +185,16 @@ TOOLS_SCHEMA = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "db_preview_prompt_merge",
|
||||
"description": "ОБЯЗАТЕЛЬНЫЙ ИНСТРУМЕНТ для ЛЮБЫХ изменений системного промпта (добавление пунктов, удаление, форматирование, отступы). Вызывай его ВСЕГДА, когда пользователь просит изменить промпт. В параметре proposed_prompt передавай ИТОГОВЫЙ полный текст со всеми разделами целиком.",
|
||||
"description": "Создать предварительное изменённое превью системного промпта перед сохранением.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"proposed_prompt": {
|
||||
"type": "string",
|
||||
"description": "Полный текст системного промпта, содержащий все разделы от 1 до 3 с учетом внесенных изменений."
|
||||
"prompt_text": {
|
||||
"type": "string",
|
||||
"description": "Новый полный или частично измененный текст системного промпта."
|
||||
}
|
||||
},
|
||||
"required": ["proposed_prompt"]
|
||||
"required": ["prompt_text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -104,28 +104,6 @@ async def favicon():
|
||||
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):
|
||||
@@ -245,37 +223,36 @@ async def chat_endpoint(
|
||||
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 = ""
|
||||
parsed_file = {"text": "", "image_b64": None}
|
||||
if file and file.filename:
|
||||
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(
|
||||
user_id=current_user["id"],
|
||||
user_message=message,
|
||||
file_context=file_content_text,
|
||||
file_context=parsed_file["text"],
|
||||
image_b64=parsed_file["image_b64"],
|
||||
session_id=session_id
|
||||
)
|
||||
return {"reply": reply, "history": history}
|
||||
|
||||
# ЕДИНЫЙ ГОСТЕВОЙ ЧАТ (FormData + Файлы)
|
||||
@app.post("/api/v1/chat/guest")
|
||||
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 = ""
|
||||
parsed_file = {"text": "", "image_b64": None}
|
||||
if file and file.filename:
|
||||
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(
|
||||
user_id=0,
|
||||
user_message=message,
|
||||
file_context=file_content_text,
|
||||
file_context=parsed_file["text"],
|
||||
image_b64=parsed_file["image_b64"],
|
||||
session_id=session_id
|
||||
)
|
||||
return {"reply": reply, "history": history}
|
||||
@@ -299,4 +276,4 @@ def serve_static_fallback(file_path: str):
|
||||
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")
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 id="chat-window" class="flex-1 p-3.5 overflow-y-auto space-y-3 bg-slate-50/50">
|
||||
<div id="drop-overlay" class="absolute inset-0 bg-indigo-600/10 backdrop-blur-sm border-2 border-dashed border-indigo-600 rounded-2xl hidden flex-col items-center justify-center z-30 transition-all pointer-events-none">
|
||||
<div class="bg-white p-4 rounded-2xl shadow-xl flex flex-col items-center gap-2">
|
||||
<i class="fa-solid fa-cloud-arrow-up text-3xl text-indigo-600 animate-bounce"></i>
|
||||
<p class="text-sm font-bold text-slate-800">Перетащите файл сюда</p>
|
||||
<p class="text-xs text-slate-500">Поддерживаются PDF, изображения, таблицы, TXT</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm">
|
||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||
<i class="fa-solid fa-robot mr-1"></i> ИИ-Ассистент
|
||||
@@ -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">
|
||||
<textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
|
||||
placeholder="Команда, вопрос или описание файла..."
|
||||
class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto max-h-[80px] leading-normal no-scrollbar"></textarea>
|
||||
placeholder="Команда, вопрос или перетащите файл сюда..."
|
||||
class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto h-[24px] max-h-[120px] leading-[24px] fade-scroll-top no-scrollbar"></textarea>
|
||||
</div>
|
||||
<button type="button" id="send-btn" onclick="sendMessage()" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm">
|
||||
<span>Отправить</span>
|
||||
|
||||
+3
-52
@@ -15,58 +15,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
if (userInputEl) {
|
||||
userInputEl.addEventListener("input", function() {
|
||||
this.style.height = "auto";
|
||||
this.style.height = Math.min(this.scrollHeight, 80) + "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);
|
||||
}
|
||||
}
|
||||
this.style.height = "24px";
|
||||
const newHeight = Math.min(this.scrollHeight, 120);
|
||||
this.style.height = newHeight + "px";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+124
-3
@@ -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;
|
||||
|
||||
function handleFileSelect(e) {
|
||||
@@ -57,7 +65,7 @@ async function sendMessage(e) {
|
||||
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
|
||||
|
||||
input.value = "";
|
||||
input.style.height = "auto";
|
||||
updateInputHeight(input);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
|
||||
if (sendBtn) {
|
||||
@@ -70,7 +78,6 @@ async function sendMessage(e) {
|
||||
|
||||
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
|
||||
|
||||
// Формируем единый FormData без дублирования
|
||||
const formData = new FormData();
|
||||
formData.append("session_id", "web_session_main");
|
||||
formData.append("message", text || "Проанализируй прикрепленный файл");
|
||||
@@ -142,4 +149,118 @@ function escapeHtml(text) {
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const input = document.getElementById("user-input");
|
||||
const dropZone = document.getElementById("chat-window")?.parentElement;
|
||||
const dropOverlay = document.getElementById("drop-overlay");
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user