Add title generator

This commit is contained in:
dimitrievgs 2025-09-24 02:27:03 +03:00
parent b59c53ad07
commit 5a1fb4d482
7 changed files with 410 additions and 73 deletions

View File

@ -8,12 +8,17 @@ from flask import Flask, request, jsonify
from flask_cors import CORS
from workflows import run_agent, graph_history_manager
from llm_client import MODELS # Добавляем импорт списка моделей
from title_generator import TitleGenerator
api = Flask(__name__)
CORS(
api
) # Разрешаем CORS для всех доменов (в production нужно настроить более строго)
# Инициализируем сервис генерации заголовков
title_generator = TitleGenerator(graph_history_manager)
title_generator.start()
@api.route('/api/chat', methods=['POST'])
def chat():
@ -85,3 +90,9 @@ def get_messages_from_root_to_node(graph_id, node_id):
def get_available_models():
"""API endpoint для получения списка доступных моделей."""
return jsonify(list(MODELS.keys()))
@api.teardown_appcontext
def cleanup(error):
"""Очистка ресурсов при завершении приложения."""
if hasattr(api, '_title_generator'):
title_generator.stop()

View File

@ -5,11 +5,13 @@ import json
import sqlite3
import uuid # Для генерации UUID
# ----------------------------------------------- Exceptions ------------------------------------------------------------
class GraphUpdateConflictError(Exception):
"""Исключение возникает при конфликте обновления графа."""
pass
# ----------------------------------------------- GraphHistoryManager ------------------------------------------------------------
class GraphHistoryManager:
"""
@ -28,7 +30,8 @@ class GraphHistoryManager:
Получает соединение с базой данных.
Устанавливаем таймаут для ожидания блокировки базы данных при конкурентной записи.
"""
return sqlite3.connect(self.db_path, timeout=5.0) # Увеличил таймаут до 5 секунд
return sqlite3.connect(self.db_path,
timeout=5.0) # Увеличил таймаут до 5 секунд
def _create_tables(self):
"""Создает таблицы для хранения графов, если они не существуют."""
@ -38,7 +41,9 @@ class GraphHistoryManager:
CREATE TABLE IF NOT EXISTS graphs (
id TEXT PRIMARY KEY,
current_node_id TEXT DEFAULT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP -- Добавляем метку времени для сортировки
title TEXT DEFAULT NULL,
title_generated BOOLEAN DEFAULT FALSE,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
@ -46,7 +51,9 @@ class GraphHistoryManager:
graph_id TEXT NOT NULL,
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
node_data TEXT NOT NULL, -- JSON-строка для 'data' из узла
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
)
@ -63,6 +70,17 @@ class GraphHistoryManager:
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
@ -71,53 +89,59 @@ class GraphHistoryManager:
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 FROM graph_nodes_data WHERE graph_id = ?", (graph_id,))
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 in cursor.fetchall():
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": json.loads(node_data_json)
"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,))
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
})
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):
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))
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"]))
)
(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"])
)
(graph_id, edge["id"], edge["source"], edge["target"]))
# ----------------------------------------------- Public Methods ------------------------------------------------------------
def save_graph_changes(self, graph_id: str,
new_nodes: List[Dict[str, Any]],
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:
"""
@ -130,43 +154,71 @@ class GraphHistoryManager:
if not graph_id:
graph_id = str(uuid.uuid4()) # Генерируем новый UUID для ID графа
with self._get_connection() as conn: # Одна транзакция для всех изменений
with self._get_connection(
) as conn: # Одна транзакция для всех изменений
try:
# Убедимся, что запись о графе существует в главной таблице `graphs`
# INSERT OR IGNORE создаст новую запись, если её нет.
# Если граф уже существует, это ничего не изменит.
conn.execute("INSERT OR IGNORE INTO graphs (id) VALUES (?)", (graph_id,))
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)
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}")
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]]:
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,))
cursor.execute("SELECT current_node_id FROM graphs WHERE id = ?",
(graph_id, ))
result = cursor.fetchone()
if result:
@ -179,9 +231,10 @@ class GraphHistoryManager:
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
)
{
"graph_nodes": graph_nodes,
"graph_edges": graph_edges
}, resolved_current_node_id)
return {
"id": graph_id,
@ -200,31 +253,45 @@ class GraphHistoryManager:
with self._get_connection() as conn:
cursor = conn.cursor()
# Сортируем по timestamp по убыванию, чтобы самые новые были сверху
cursor.execute("SELECT id, current_node_id FROM graphs ORDER BY timestamp DESC")
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 in graphs_data:
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
)
{
"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")
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")
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,
"first_message": first_message_content
"title": display_title,
"title_generated": bool(title_generated)
})
return summaries
@ -257,18 +324,22 @@ class GraphHistoryManager:
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'
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)
"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:
"""
Удаляет граф из базы данных.
@ -277,10 +348,107 @@ class GraphHistoryManager:
with self._get_connection() as conn:
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM graphs WHERE id = ?", (graph_id,))
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()

View File

@ -17,7 +17,7 @@ MODELS: Dict[str, Dict[str, Any]] = {
"name": "gemini-2.0-flash",
"provider": "openai", # Изменено на "openai"
"model_name": "gemini-2.0-flash", # Добавлено имя модели для LangChain
"apiBase": "https://render-service-gsu7.onrender.com/g/v1beta",
"apiBase": "https://render-service-gsu7.onrender.com/g/v1beta", # "generativelanguage.googleapis.com", # "https://render-service-gsu7.onrender.com/g/v1beta",
"apiKey": "AIzaSyDpueKFWVqknVKlQn6TdasLmJ2lvAUiBik",
"stream": True,
"capabilities": ["vision"],
@ -26,7 +26,7 @@ MODELS: Dict[str, Dict[str, Any]] = {
"name": "gemini-2.5-flash",
"provider": "openai", # Изменено на "openai"
"model_name": "gemini-2.5-flash", # Добавлено имя модели для LangChain
"apiBase": "https://render-service-gsu7.onrender.com/g/v1beta",
"apiBase": "https://render-service-gsu7.onrender.com/g/v1beta", # "generativelanguage.googleapis.com", # "https://render-service-gsu7.onrender.com/g/v1beta",
"apiKey": "AIzaSyDpueKFWVqknVKlQn6TdasLmJ2lvAUiBik",
"stream": True,
"capabilities": ["vision"],
@ -43,7 +43,7 @@ MODELS: Dict[str, Dict[str, Any]] = {
"mistral-small-latest": {
"name": "mistral-small-latest",
"provider": "mistralai",
"baseUrl": "https://render-service-gsu7.onrender.com/m",
"baseUrl": "https://render-service-gsu7.onrender.com/m", # "api.mistral.ai", # "https://render-service-gsu7.onrender.com/m",
"apiKey": "Q0m29fvxBY0Cfdj4sjHaKqccy1NjonLW",
"stream": True,
"capabilities": ["vision"],

View File

@ -143,6 +143,8 @@ def execute_command_node(state: AgentState) -> AgentState:
DEFAULT_LLM_NAME = "gemini-2.0-flash" #"gemini-2.5-flash"
main_llm = get_llm(DEFAULT_LLM_NAME)
DEFAULT_SUMMARIZATION_LLM_NAME = "gemini-2.0-flash"
def call_llm_node(state: AgentState) -> AgentState:
"""

151
app/title_generator.py Normal file
View File

@ -0,0 +1,151 @@
"""
Асинхронный сервис для генерации заголовков графов и узлов.
Работает в фоновом режиме, обрабатывая очередь элементов без заголовков.
"""
import threading
import time
from typing import Optional
from graph_history_manager import GraphHistoryManager
from nodes import DEFAULT_SUMMARIZATION_LLM_NAME
from llm_client import get_llm
from langchain_core.messages import SystemMessage, HumanMessage
class TitleGenerator:
"""Сервис для асинхронной генерации заголовков."""
def __init__(self, history_manager: GraphHistoryManager):
self.history_manager = history_manager
self.llm = get_llm(DEFAULT_SUMMARIZATION_LLM_NAME)
self.running = False
self.thread: Optional[threading.Thread] = None
# Системные промпты для генерации заголовков
self.graph_title_prompt = """Создай краткий заголовок (максимум 80 символов) для диалога на основе первого сообщения пользователя.
Заголовок должен отражать основную тему или вопрос. Отвечай только заголовком, без дополнительных объяснений. Ты должен сделать саммери, а не ответить на вопросы, если они есть в сообщении."""
self.node_title_prompt = """Создай краткий заголовок (максимум 40 символов) для этого сообщения/действия.
Заголовок должен кратко описывать суть сообщения или действия. Отвечай только заголовком, без дополнительных объяснений. Ты должен сделать саммери, а не ответить на вопросы, если они есть в сообщении."""
def start(self):
"""Запускает фоновый процесс генерации заголовков."""
if self.running:
return
self.running = True
# Заполняем очередь при старте
self.history_manager.populate_initial_title_queue()
# Запускаем фоновый поток
self.thread = threading.Thread(target=self._process_queue, daemon=True)
self.thread.start()
print("Сервис генерации заголовков запущен")
def stop(self):
"""Останавливает фоновый процесс."""
self.running = False
if self.thread:
self.thread.join()
print("Сервис генерации заголовков остановлен")
def _process_queue(self):
"""Основной цикл обработки очереди заголовков."""
while self.running:
try:
item = self.history_manager.get_next_from_title_queue()
if item:
if item["item_type"] == "graph":
self._generate_graph_title(item["graph_id"])
elif item["item_type"] == "node":
self._generate_node_title(item["graph_id"], item["node_id"])
else:
# Если очередь пуста, ждем немного
time.sleep(5)
except Exception as e:
print(f"Ошибка при обработке очереди заголовков: {e}")
time.sleep(10)
def _generate_graph_title(self, graph_id: str):
"""Генерирует заголовок для графа."""
try:
graph_data = self.history_manager.get_graph(graph_id)
if not graph_data:
return
messages = graph_data.get("messages", [])
if not messages:
return
# Находим первое пользовательское сообщение
first_user_message = None
for msg in messages:
if msg.get("role") == "user":
first_user_message = msg.get("content", "")
break
if not first_user_message:
return
# Генерируем заголовок
llm_messages = [
SystemMessage(content=self.graph_title_prompt),
HumanMessage(content=first_user_message)
]
title = self.llm.invoke(llm_messages)
title = title.strip()[:80] # Ограничиваем длину
# Сохраняем заголовок
self.history_manager.update_graph_title(graph_id, title)
print(f"Сгенерирован заголовок графа {graph_id}: {title}")
except Exception as e:
print(f"Ошибка генерации заголовка графа {graph_id}: {e}")
def _generate_node_title(self, graph_id: str, node_id: str):
"""Генерирует заголовок для узла."""
try:
graph_data = self.history_manager.get_graph(graph_id)
if not graph_data:
return
# Находим узел по ID
target_node = None
for node in graph_data.get("graph_nodes", []):
if node["id"] == node_id:
target_node = node
break
if not target_node:
return
# Получаем содержимое узла
node_data = target_node.get("data", {})
message = node_data.get("message", {})
content = message.get("content", "")
if not content:
# Если нет content, используем label или type
content = node_data.get("label", target_node.get("type", ""))
if not content:
return
# Генерируем заголовок
llm_messages = [
SystemMessage(content=self.node_title_prompt),
HumanMessage(content=content)
]
title = self.llm.invoke(llm_messages)
title = title.strip()[:40] # Ограничиваем длину
# Сохраняем заголовок
self.history_manager.update_node_title(graph_id, node_id, title)
print(f"Сгенерирован заголовок узла {node_id}: {title}")
except Exception as e:
print(f"Ошибка генерации заголовка узла {node_id}: {e}")

Binary file not shown.

5
run-llm-backend.bat Normal file
View File

@ -0,0 +1,5 @@
chcp 65001
cd /d "D:\Docs\Гриша\Хобби\Настольные игры\ttrpg-obsidian-vault\llm-agent-backend"
call "env\Scripts\activate.bat"
python run.py
pause