From cb195fddc66842cac73992a33b9a02fa5e4d1e71 Mon Sep 17 00:00:00 2001 From: dimitrievgs Date: Wed, 24 Sep 2025 22:58:38 +0300 Subject: [PATCH] split graph_history_manager and title_generator --- app/api.py | 11 ---- app/graph_history_manager.py | 105 ++++++------------------------- app/title_generator.py | 116 +++++++++++++++++++++++++++++++++-- graph_history.db | Bin 462848 -> 516096 bytes 4 files changed, 129 insertions(+), 103 deletions(-) diff --git a/app/api.py b/app/api.py index 0791cf8..c6dd29a 100644 --- a/app/api.py +++ b/app/api.py @@ -8,17 +8,12 @@ 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(): @@ -90,9 +85,3 @@ 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() \ No newline at end of file diff --git a/app/graph_history_manager.py b/app/graph_history_manager.py index 1ab084f..ef626e9 100644 --- a/app/graph_history_manager.py +++ b/app/graph_history_manager.py @@ -4,6 +4,7 @@ from typing import Dict, Any, List, Optional import json import sqlite3 import uuid # Для генерации UUID +from title_generator import TitleGenerator # ----------------------------------------------- Exceptions ------------------------------------------------------------ @@ -24,6 +25,22 @@ class GraphHistoryManager: self.db_path = db_path self._create_tables() + # Инициализируем сервис генерации заголовков + self.title_generator = TitleGenerator(self) + self.title_generator.start() + + def __del__(self): + """ + Деструктор класса. Останавливает генератор заголовков при уничтожении объекта. + """ + print("🚩 Завершение работы GraphHistoryManager. Останавливаем генератор заголовков...") + self.stop_title_generator() + + def stop_title_generator(self): + """Останавливает процесс генерации заголовков.""" + if self.title_generator: + self.title_generator.stop() + # ----------------------------------------------- Internal Methods ------------------------------------------------------------ def _get_connection(self): """ @@ -70,17 +87,6 @@ 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 @@ -174,10 +180,7 @@ class GraphHistoryManager: # Добавляем новые узлы 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)) + self.title_generator.add_node_to_queue(cursor, graph_id, node["id"]) # Добавляем новые ребра for edge in new_edges: @@ -189,9 +192,7 @@ class GraphHistoryManager: # Если новый граф, добавляем его в очередь заголовков if is_new_graph: - cursor.execute( - "INSERT INTO title_generation_queue (item_type, graph_id, priority) VALUES (?, ?, ?)", - ("graph", graph_id, 10)) + self.title_generator.add_graph_to_queue(cursor, graph_id) conn.commit() # Фиксируем все изменения print(f"Изменения графа {graph_id} сохранены.") @@ -356,74 +357,6 @@ class GraphHistoryManager: 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): diff --git a/app/title_generator.py b/app/title_generator.py index 3558b86..4abaa3c 100644 --- a/app/title_generator.py +++ b/app/title_generator.py @@ -3,10 +3,10 @@ Работает в фоновом режиме, обрабатывая очередь элементов без заголовков. """ +import sqlite3 import threading import time -from typing import Optional -from graph_history_manager import GraphHistoryManager +from typing import Any, Dict, Optional from nodes import DEFAULT_SUMMARIZATION_LLM_NAME from llm_client import get_llm from langchain_core.messages import SystemMessage, HumanMessage @@ -14,7 +14,9 @@ from langchain_core.messages import SystemMessage, HumanMessage class TitleGenerator: """Сервис для асинхронной генерации заголовков.""" - def __init__(self, history_manager: GraphHistoryManager): + def __init__(self, history_manager, db_path="graph_history.db"): + self.db_path = db_path + self._create_table() self.history_manager = history_manager self.llm = get_llm(DEFAULT_SUMMARIZATION_LLM_NAME) self.running = False @@ -27,6 +29,31 @@ class TitleGenerator: self.node_title_prompt = """Создай краткий заголовок (максимум 40 символов) для этого сообщения/действия. Заголовок должен кратко описывать суть сообщения или действия. Отвечай только заголовком, без дополнительных объяснений. Ты должен сделать саммери, а не ответить на вопросы, если они есть в сообщении.""" + def _get_connection(self): + """ + Получает соединение с базой данных. + Устанавливаем таймаут для ожидания блокировки базы данных при конкурентной записи. + """ + return sqlite3.connect(self.db_path, + timeout=5.0) # Увеличил таймаут до 5 секунд + + def _create_table(self): + """Создает таблицы для хранения графов, если они не существуют.""" + with self._get_connection() as conn: + cursor = conn.cursor() + # Очередь для обработки заголовков + 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() + def start(self): """Запускает фоновый процесс генерации заголовков.""" if self.running: @@ -35,13 +62,41 @@ class TitleGenerator: self.running = True # Заполняем очередь при старте - self.history_manager.populate_initial_title_queue() + self.populate_initial_title_queue() # Запускаем фоновый поток self.thread = threading.Thread(target=self._process_queue, daemon=True) self.thread.start() print("Сервис генерации заголовков запущен") + 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() + def stop(self): """Останавливает фоновый процесс.""" self.running = False @@ -53,7 +108,7 @@ class TitleGenerator: """Основной цикл обработки очереди заголовков.""" while self.running: try: - item = self.history_manager.get_next_from_title_queue() + item = self.get_next_from_title_queue() if item: if item["item_type"] == "graph": @@ -68,6 +123,31 @@ class TitleGenerator: print(f"Ошибка при обработке очереди заголовков: {e}") time.sleep(10) + 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 _generate_graph_title(self, graph_id: str): """Генерирует заголовок для графа.""" try: @@ -148,4 +228,28 @@ class TitleGenerator: print(f"Сгенерирован заголовок узла {node_id}: {title}") except Exception as e: - print(f"Ошибка генерации заголовка узла {node_id}: {e}") \ No newline at end of file + print(f"Ошибка генерации заголовка узла {node_id}: {e}") + + '''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 add_node_to_queue(self, cursor, graph_id, node_id): + # Добавляем в очередь заголовков с высоким приоритетом + cursor.execute( + "INSERT INTO title_generation_queue (item_type, graph_id, node_id, priority) VALUES (?, ?, ?, ?)", + ("node", graph_id, node_id, 10)) + + def add_graph_to_queue(self, cursor, graph_id): + cursor.execute( + "INSERT INTO title_generation_queue (item_type, graph_id, priority) VALUES (?, ?, ?)", + ("graph", graph_id, 10)) diff --git a/graph_history.db b/graph_history.db index 51cd0c51468e330233d28f6aa1409f69a1448307..bf413fac8e821a67e815e9a61abed89ba4a4d1b7 100644 GIT binary patch delta 23529 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 YTOJ<69vFki$-Ly-svHU2-ZJO^0FiUQ)c^nh delta 652 zcmZXR-%C?r9LC@G-Fe@+;W@8`H6quhP8zMT?F`d)p-V`udC|?m8&?yzO8cda80F2@ z#jc{xc!QT3=3d|kxTol-ih*wDj)kQ8NCD(DbVJWd8a9apQQ-KX%7l z=)cf!r?tD)sM;Nlo(`*VzZy@dv4pA$=et9i2IDtxWpg&g>fZlJH4%#ma`G>dryMxY z`fsR>)`|@ZllS$q4$C?!>y8y&f-ik|6!^g!kn3_qnN`9xM@?mede9TehrW|X{A3ab z*>Eyq6_WUiBsoK{m%)K9Ry>@a zH8u9hpF?^3hUbLq!EoMc3}fUu;^+omPyot?5>{rZDLQyi3NC^*)|v^rT{|cx*U1^M zW{qU<4noqa{jiG@dHWV&LH>Ev(aDZw=R^KLm0sBpL+i3-q{&2w%U%u&P4SIY66bHLgz&yShs?vjh|Ho}J^aNc za#>k}j8n;md16}X;=7CFFwcF%es0WT%!>u*0B?B@73=Oa8KEl%ky;2u8bI1oDTOSp IMxLjC1FHnS@&Et;