llm-agent-backend/app/graph_history_manager.py
2025-09-24 02:27:03 +03:00

455 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# отключаем 40-ка строчное ограничение для этого файла
# ----------------------------------------------- Imports ------------------------------------------------------------
from typing import Dict, Any, List, Optional
import json
import sqlite3
import uuid # Для генерации UUID
# ----------------------------------------------- Exceptions ------------------------------------------------------------
class GraphUpdateConflictError(Exception):
"""Исключение возникает при конфликте обновления графа."""
pass
# ----------------------------------------------- GraphHistoryManager ------------------------------------------------------------
class GraphHistoryManager:
"""
Менеджер для хранения истории графов запросов в SQLite.
Использует отдельные таблицы для узлов и ребер для инкрементального обновления.
Обеспечивает конкурентный доступ к разным графам, полагаясь на транзакционность SQLite.
"""
def __init__(self, db_path="graph_history.db"):
self.db_path = db_path
self._create_tables()
# ----------------------------------------------- Internal Methods ------------------------------------------------------------
def _get_connection(self):
"""
Получает соединение с базой данных.
Устанавливаем таймаут для ожидания блокировки базы данных при конкурентной записи.
"""
return sqlite3.connect(self.db_path,
timeout=5.0) # Увеличил таймаут до 5 секунд
def _create_tables(self):
"""Создает таблицы для хранения графов, если они не существуют."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS graphs (
id TEXT PRIMARY KEY,
current_node_id TEXT DEFAULT NULL,
title TEXT DEFAULT NULL,
title_generated BOOLEAN DEFAULT FALSE,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS graph_nodes_data (
graph_id TEXT NOT NULL,
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
node_data TEXT NOT NULL,
title TEXT DEFAULT NULL,
title_generated BOOLEAN DEFAULT FALSE,
PRIMARY KEY (graph_id, node_id),
FOREIGN KEY (graph_id) REFERENCES graphs (id) ON DELETE CASCADE
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS graph_edges_data (
graph_id TEXT NOT NULL,
edge_id TEXT NOT NULL,
source_node_id TEXT NOT NULL,
target_node_id TEXT NOT NULL,
PRIMARY KEY (graph_id, edge_id),
FOREIGN KEY (graph_id) REFERENCES graphs (id) ON DELETE CASCADE,
FOREIGN KEY (source_node_id) REFERENCES graph_nodes_data (node_id) ON DELETE CASCADE,
FOREIGN KEY (target_node_id) REFERENCES graph_nodes_data (node_id) ON DELETE CASCADE
)
""")
# Очередь для обработки заголовков
cursor.execute("""
CREATE TABLE IF NOT EXISTS title_generation_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_type TEXT NOT NULL, -- 'graph' or 'node'
graph_id TEXT NOT NULL,
node_id TEXT DEFAULT NULL, -- только для узлов
priority INTEGER DEFAULT 0, -- для приоритета обработки
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
# _get_max_graph_id - удален, так как используем UUID для graph_id
# ----------------------------------------------- Graph Node/Edge Access Helpers ------------------------------------------------------------
def _get_graph_nodes(self, conn, graph_id: str) -> List[Dict[str, Any]]:
"""Извлекает все узлы для заданного графа."""
cursor = conn.cursor()
cursor.execute(
"SELECT node_id, node_type, node_data, title, title_generated FROM graph_nodes_data WHERE graph_id = ?",
(graph_id, ))
nodes = []
for node_id, node_type, node_data_json, title, title_generated in cursor.fetchall():
node_data = json.loads(node_data_json)
# Добавляем заголовок в данные узла
if title and title_generated:
node_data["title"] = title
node_data["title_generated"] = bool(title_generated)
nodes.append({
"id": node_id,
"type": node_type,
"data": node_data
})
return nodes
def _get_graph_edges(self, conn, graph_id: str) -> List[Dict[str, Any]]:
"""Извлекает все ребра для заданного графа."""
cursor = conn.cursor()
cursor.execute(
"SELECT edge_id, source_node_id, target_node_id FROM graph_edges_data WHERE graph_id = ?",
(graph_id, ))
edges = []
for edge_id, source, target in cursor.fetchall():
edges.append({"id": edge_id, "source": source, "target": target})
return edges
def _update_graph_current_node_id(self, conn, graph_id: str,
new_current_node_id: str):
"""Обновляет current_node_id для графа."""
cursor = conn.cursor()
cursor.execute("UPDATE graphs SET current_node_id = ? WHERE id = ?",
(new_current_node_id, graph_id))
def _add_graph_node(self, conn, graph_id: str, node: Dict[str, Any]):
"""Добавляет новый узел в граф. Использует INSERT OR IGNORE для избежания конфликтов."""
cursor = conn.cursor()
cursor.execute(
"INSERT OR IGNORE INTO graph_nodes_data (graph_id, node_id, node_type, node_data) VALUES (?, ?, ?, ?)",
(graph_id, node["id"], node["type"], json.dumps(node["data"])))
def _add_graph_edge(self, conn, graph_id: str, edge: Dict[str, Any]):
"""Добавляет новое ребро в граф. Использует INSERT OR IGNORE для избежания конфликтов."""
cursor = conn.cursor()
cursor.execute(
"INSERT OR IGNORE INTO graph_edges_data (graph_id, edge_id, source_node_id, target_node_id) VALUES (?, ?, ?, ?)",
(graph_id, edge["id"], edge["source"], edge["target"]))
# ----------------------------------------------- Public Methods ------------------------------------------------------------
def save_graph_changes(self, graph_id: str, new_nodes: List[Dict[str,
Any]],
new_edges: List[Dict[str, Any]],
current_node_id: str) -> str:
"""
Сохраняет *изменения* в граф в базе данных.
Добавляет новые узлы и ребра и обновляет current_node_id.
Операции проводятся в рамках одной транзакции.
Конфликты при добавлении существующих узлов/ребер игнорируются.
Если graph_id пуст, генерируется новый UUID для графа.
"""
if not graph_id:
graph_id = str(uuid.uuid4()) # Генерируем новый UUID для ID графа
with self._get_connection(
) as conn: # Одна транзакция для всех изменений
try:
# Убедимся, что запись о графе существует в главной таблице `graphs`
# INSERT OR IGNORE создаст новую запись, если её нет.
# Если граф уже существует, это ничего не изменит.
conn.execute("INSERT OR IGNORE INTO graphs (id) VALUES (?)",
(graph_id, ))
# Проверяем, новый ли это граф
cursor = conn.cursor()
cursor.execute(
"SELECT COUNT(*) FROM graph_nodes_data WHERE graph_id = ?",
(graph_id, ))
existing_nodes_count = cursor.fetchone()[0]
is_new_graph = existing_nodes_count == 0
# Добавляем новые узлы
for node in new_nodes:
self._add_graph_node(conn, graph_id, node)
# Добавляем в очередь заголовков с высоким приоритетом
cursor.execute(
"INSERT INTO title_generation_queue (item_type, graph_id, node_id, priority) VALUES (?, ?, ?, ?)",
("node", graph_id, node["id"], 10))
# Добавляем новые ребра
for edge in new_edges:
self._add_graph_edge(conn, graph_id, edge)
# Обновляем current_node_id - это атомарное изменение в рамках транзакции
self._update_graph_current_node_id(conn, graph_id,
current_node_id)
# Если новый граф, добавляем его в очередь заголовков
if is_new_graph:
cursor.execute(
"INSERT INTO title_generation_queue (item_type, graph_id, priority) VALUES (?, ?, ?)",
("graph", graph_id, 10))
conn.commit() # Фиксируем все изменения
print(f"Изменения графа {graph_id} сохранены.")
return graph_id
except sqlite3.OperationalError as e:
conn.rollback(
) # Откатываем транзакцию при ошибке (например, DB Locked)
print(
f"Ошибка блокировки SQLite при сохранении изменений графа {graph_id}: {e}"
)
raise
except Exception as e:
conn.rollback() # Откатываем транзакцию при других ошибках
print(f"Ошибка при сохранении изменений графа {graph_id}: {e}")
raise
def get_graph(
self,
graph_id: str,
target_node_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Получает данные графа по ID из базы данных.
"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT current_node_id FROM graphs WHERE id = ?",
(graph_id, ))
result = cursor.fetchone()
if result:
db_current_node_id = result[0]
# Получаем все узлы и ребра
graph_nodes = self._get_graph_nodes(conn, graph_id)
graph_edges = self._get_graph_edges(conn, graph_id)
resolved_current_node_id = target_node_id if target_node_id else db_current_node_id
messages = self.get_messages_from_root_to_node(
{
"graph_nodes": graph_nodes,
"graph_edges": graph_edges
}, resolved_current_node_id)
return {
"id": graph_id,
"messages": messages,
"graph_nodes": graph_nodes,
"graph_edges": graph_edges,
"current_node_id": resolved_current_node_id
}
else:
return None
def get_all_graphs_summary(self) -> List[Dict[str, str]]:
"""
Возвращает краткий список всех сохраненных графов, отсортированных по дате добавления.
"""
with self._get_connection() as conn:
cursor = conn.cursor()
# Сортируем по timestamp по убыванию, чтобы самые новые были сверху
cursor.execute(
"SELECT id, current_node_id, title, title_generated FROM graphs ORDER BY timestamp DESC"
)
graphs_data = cursor.fetchall()
summaries = []
for gid, current_node_id, title, title_generated in graphs_data:
if title and title_generated:
display_title = title
else:
# Fallback к первому сообщению
graph_nodes = self._get_graph_nodes(conn, gid)
graph_edges = self._get_graph_edges(conn, gid)
# Получаем все сообщения до current_node_id
full_messages = self.get_messages_from_root_to_node(
{
"graph_nodes": graph_nodes,
"graph_edges": graph_edges
}, current_node_id)
first_message_content = "No content"
if full_messages:
for msg in full_messages:
if msg.get("role") == "user":
first_message_content = msg.get(
"content", "No content")
break
if first_message_content == "No content" and full_messages:
first_message_content = full_messages[0].get(
"content", "No content")
# Обрезаем до 50 символов для отображения
display_title = first_message_content[:50] + "..." if len(
first_message_content) > 50 else first_message_content
summaries.append({
"id": gid,
"title": display_title,
"title_generated": bool(title_generated)
})
return summaries
def get_messages_from_root_to_node(
self, graph_data: Dict[str, Any],
target_node_id: str) -> List[Dict[str, str]]:
"""
Получает список сообщений от корневого узла до указанного узла.
Сообщения извлекаются из данных узлов, а не из отдельного поля.
"""
all_nodes = graph_data.get("graph_nodes", [])
edges = graph_data.get("graph_edges", [])
# Создаем словарь для быстрого поиска узлов по ID
node_map = {node['id']: node for node in all_nodes}
# Создаем словарь для быстрого поиска родительских узлов
parent_map = {edge['target']: edge['source'] for edge in edges}
def get_path_to_root(node_id: str) -> List[str]:
path = [node_id]
current = node_id
while current in parent_map:
current = parent_map[current]
path.append(current)
return path[::-1]
path_to_target = get_path_to_root(target_node_id)
# Собираем сообщения, соответствующие узлам в пути.
# Сообщения теперь хранятся в 'data' каждого узла при создании.
messages_for_path = []
for node_id in path_to_target:
node = node_map.get(node_id)
if node and 'message' in node.get(
'data', {}): # Проверяем наличие поля 'message'
original_message = node['data']['message']
messages_for_path.append({
"role":
original_message.get("role", "unknown"),
"type":
node.get("type", "unknown"), # Используем 'type' узла
"content":
original_message.get("content", ""),
"node_id":
original_message.get("node_id", node_id)
})
return messages_for_path
def delete_graph(self, graph_id: str) -> bool:
"""
Удаляет граф из базы данных.
Благодаря CASCADE, удалит также все узлы и ребра, связанные с этим графом.
"""
with self._get_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM graphs WHERE id = ?", (graph_id, ))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
conn.rollback()
print(f"Ошибка при удалении графа: {e}")
return False
# ----------------------------------------------- Title Generation Queue Methods ------------------------------------------------------------
def add_to_title_queue(self,
item_type: str,
graph_id: str,
node_id: str = None,
priority: int = 0):
"""Добавляет элемент в очередь генерации заголовков."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO title_generation_queue (item_type, graph_id, node_id, priority) VALUES (?, ?, ?, ?)",
(item_type, graph_id, node_id, priority))
conn.commit()
def get_next_from_title_queue(self) -> Optional[Dict[str, Any]]:
"""Получает следующий элемент из очереди для обработки."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id, item_type, graph_id, node_id
FROM title_generation_queue
ORDER BY priority DESC, id ASC
LIMIT 1
""")
result = cursor.fetchone()
if result:
queue_id, item_type, graph_id, node_id = result
# Удаляем из очереди
cursor.execute(
"DELETE FROM title_generation_queue WHERE id = ?",
(queue_id, ))
conn.commit()
return {
"item_type": item_type,
"graph_id": graph_id,
"node_id": node_id
}
return None
def populate_initial_title_queue(self):
"""Заполняет очередь элементами без сгенерированных заголовков при старте сервера."""
with self._get_connection() as conn:
cursor = conn.cursor()
# Очищаем существующую очередь
cursor.execute("DELETE FROM title_generation_queue")
# Добавляем графы без заголовков
cursor.execute(
"SELECT id FROM graphs WHERE title_generated = FALSE OR title_generated IS NULL"
)
for (graph_id, ) in cursor.fetchall():
cursor.execute(
"INSERT INTO title_generation_queue (item_type, graph_id, priority) VALUES (?, ?, ?)",
("graph", graph_id, 1))
# Добавляем узлы без заголовков
cursor.execute(
"SELECT graph_id, node_id FROM graph_nodes_data WHERE title_generated = FALSE OR title_generated IS NULL"
)
for graph_id, node_id in cursor.fetchall():
cursor.execute(
"INSERT INTO title_generation_queue (item_type, graph_id, node_id, priority) VALUES (?, ?, ?, ?)",
("node", graph_id, node_id, 1))
conn.commit()
# ----------------------------------------------- Title Update Methods ------------------------------------------------------------
def update_graph_title(self, graph_id: str, title: str):
"""Обновляет заголовок графа и устанавливает флаг генерации."""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE graphs SET title = ?, title_generated = TRUE WHERE id = ?",
(title, graph_id))
conn.commit()
def update_node_title(self, graph_id: str, node_id: str, title: str):
"""Обновляет заголовок узла и устанавливает флаг генерации."""
with self._get_connection() as conn:
cursor = conn.cursor()
# Сначала проверим, существует ли узел
cursor.execute(
"SELECT COUNT(*) FROM graph_nodes_data WHERE graph_id = ? AND node_id = ?",
(graph_id, node_id))
count = cursor.fetchone()[0]
print(f"Найдено узлов для обновления: {count}")
cursor.execute(
"UPDATE graph_nodes_data SET title = ?, title_generated = TRUE WHERE graph_id = ? AND node_id = ?",
(title, graph_id, node_id))
updated_rows = cursor.rowcount
print(f"Обновлено строк: {updated_rows}")
conn.commit()